Something went wrong. Try again.
スレッドを書くローカル執筆アプリ / A local writing app for Bluesky threads
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049#!/usr/bin/env python3"""Re: のサーバー。
127.0.0.1 にだけつなぐ。画面(index.html)を配り、スレッドを matters/<名前>/<名前>.md に読み書きする。上書きを防ぐため、書き込みのたびに画面が持っている更新時刻とファイルの更新時刻を比べる。Blueskyへの投稿は bluesky.py が受け持つ。公開した投稿は時刻の注釈に URI と CID を残し、以後は書き換えさせない。"""
import base64import jsonimport secretsimport socketimport shutilimport osimport reimport sysimport threadingimport unicodedataimport urllib.parseimport urllib.requestfrom datetime import timezonefrom datetime import datetimefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom pathlib import Pathfrom urllib.parse import parse_qs, urlparse
import bluesky
HERE = Path(__file__).resolve().parentMATTERS = Path(os.environ.get("RE_MATTERS") or HERE.parent / "matters")PILES = MATTERS.parent / "piles" # 片づけたスレッドの行き先。消さずに移すので Finder から戻せるPORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8778
MARK = re.compile(r"^<!-- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?: (at://\S+) (\S+))? -->$")IMG = re.compile(r"^!\[((?:\\.|[^\]\\])*)\]\((images/[A-Za-z0-9_.\-]+)\)$")IMG_NAME = re.compile(r"^images/[A-Za-z0-9_.\-]+$")SIZE = re.compile(r"_(\d+)x(\d+)\.jpg$")BAD_NAME = re.compile(r'[/\\:\x00-\x1f]')
def valid_name(name): return bool(name) and name.strip() == name and not name.startswith(".") \ and not BAD_NAME.search(name) and len(name) <= 80
def md_path(name): return MATTERS / name / f"{name}.md"
def mtime(path): return str(path.stat().st_mtime_ns) if path.exists() else None
LOCK = "<!-- re:lock -->" # 見出しの下のこの1行が、Blueskyへ投稿させない鍵だ
def locked(text): return any(line.strip() == LOCK for line in text.split("\n")[:5])
def parse(text): """投稿の前に置いた時刻の注釈で区切る。注釈より前の本文は、見出しを除いて時刻なしの投稿にする。""" posts, cur, buf = [], {}, []
def flush(): lines_ = "\n".join(buf).strip("\n").split("\n") images = [] while lines_ and (IMG.match(lines_[-1]) or (images and not lines_[-1].strip())): m = IMG.match(lines_.pop()) if m: images.insert(0, {"alt": re.sub(r"\\(.)", r"\1", m.group(1)), "src": m.group(2)}) body = "\n".join(lines_).strip("\n") if body.strip() or images: post = dict(cur, text=body) if images: post["images"] = images posts.append(post)
lines = text.split("\n") if lines and lines[0].startswith("# "): lines = lines[1:] for line in lines: if line.strip() == LOCK or line.startswith("<!-- re:keywords ") or line.startswith("<!-- re:claim "): continue m = MARK.match(line) if m: flush() cur, buf = {"time": m.group(1)}, [] if m.group(2): cur.update(uri=m.group(2), cid=m.group(3)) else: buf.append(line) flush() return posts
def render(name, posts, lock=False): out = [f"# {name}", ""] + ([LOCK, ""] if lock else []) for p in posts: if p.get("uri"): out.append(f"<!-- {p.get('time') or datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {p['uri']} {p['cid']} -->") elif p.get("time"): out.append(f"<!-- {p['time']} -->") if p["text"].strip(): out.append(p["text"].strip("\n")) out.append("") for im in p.get("images", []): alt = " ".join(im.get("alt", "").split()).replace("\\", "\\\\").replace("]", "\\]") out.append(f"") if p.get("images"): out.append("") return "\n".join(out)
def store_images(name, posts): """画面から来た新しい画像(data)をファイルにして src に置き換える。名前に縦横の画素数を入れておく。""" for p in posts: for im in p.get("images", []): if im.get("data"): raw = base64.b64decode(im.pop("data").split(",", 1)[-1]) w, h = int(im.pop("w", 0)), int(im.pop("h", 0)) fn = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}-{secrets.token_hex(3)}_{w}x{h}.jpg" d = MATTERS / name / "images" d.mkdir(parents=True, exist_ok=True) (d / fn).write_bytes(raw) im["src"] = "images/" + fn im.pop("w", None); im.pop("h", None) if not IMG_NAME.match(im.get("src", "")): raise ValueError("画像の場所が読めない")
def same_published(new, old): key = lambda p: (p.get("uri"), p.get("text"), [(i.get("src"), i.get("alt", "")) for i in p.get("images", [])]) return [key(p) for p in new] == [key(p) for p in old]
def write(name, posts, lock=None): """lock を渡さなければ、ファイルにある鍵をそのまま保つ。タグの行も保つ。""" f = md_path(name) tags, claim = [], "" if f.exists(): old = f.read_text(encoding="utf-8") if lock is None: lock = locked(old) tags, claim = read_tags(old), read_claim(old) f.parent.mkdir(parents=True, exist_ok=True) tmp = f.with_suffix(".md.tmp") tmp.write_text(render(name, posts, bool(lock)), encoding="utf-8") os.replace(tmp, f) if tags: write_tags(name, tags) if claim: write_claim(name, claim)
# ---- 紙切れへ送る ----# スレッドを材料に、紙切れ(長文の書く道具)で文章に仕上げる。紙切れの下書きの置き場と画面の URL は .env に書く。# 書いていなければボタンを出さないdef kamikire_conf(): env = bluesky.load_env() d = os.environ.get("KAMIKIRE_MATTERS") or env.get("KAMIKIRE_MATTERS", "") url = os.environ.get("KAMIKIRE_URL") or env.get("KAMIKIRE_URL", "") or "http://127.0.0.1:8779/" d = Path(d).expanduser() if d else None return (d, url) if d and d.is_dir() else (None, url)
def kamikire_linked(name): """このスレッドから送った紙切れの下書きの名前。フロントマターの re: で探す。紙切れで改名しても見つかる。""" d, _ = kamikire_conf() if not d: return None for f in sorted(d.glob("*.md"), key=lambda x: -x.stat().st_mtime): head = f.read_text(encoding="utf-8")[:600] if head.startswith("---\n") and re.search(r"^re: " + re.escape(name) + r"$", head.split("\n---", 1)[0], re.M): return f.stem return None
def kamikire_body(posts): """1投稿を1段落にする。投稿の中の段落はそのまま残す。画像は移さない。ALT を引用の行にして材料に残す。""" parts = [] for p in posts: if p["text"].strip(): parts.append(p["text"].strip()) for im in p.get("images", []): alt = " ".join(im.get("alt", "").split()) if alt: parts.append("> 画像:" + alt) return "\n\n".join(parts) + "\n"
class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass
def send_json(self, code, obj): body = json.dumps(obj, ensure_ascii=False).encode("utf-8") self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body)
def do_GET(self): url = urlparse(self.path) if url.path in ("/", "/index.html"): body = (HERE / "index.html").read_bytes() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) elif url.path.startswith("/vendor/"): # 表示に使うライブラリ(markdown-it・KaTeX・html-to-image)。vendor/ の中だけを配る rel = url.path[len("/vendor/"):] f = (HERE / "vendor" / rel).resolve() types = {".js": "text/javascript", ".css": "text/css", ".woff2": "font/woff2"} if not str(f).startswith(str((HERE / "vendor").resolve()) + os.sep) or not f.is_file() or f.suffix not in types: self.send_json(404, {"error": "not found"}) return body = f.read_bytes() self.send_response(200) self.send_header("Content-Type", types[f.suffix] + ("; charset=utf-8" if f.suffix != ".woff2" else "")) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "max-age=604800") self.end_headers() self.wfile.write(body) elif url.path == "/api/threads": items = [] if MATTERS.exists(): for d in MATTERS.iterdir(): f = md_path(d.name) if d.is_dir() and f.exists(): text = f.read_text(encoding="utf-8") posts = parse(text) items.append({"name": d.name, "mtime": mtime(f), "count": len(posts), "lock": locked(text), "published": sum(1 for p in posts if p.get("uri")), "tags": read_tags(text), "words": tag_words(text), "claim": read_claim(text), "text": text, "head": (bluesky.md_links(posts[0]["text"])[0][:80] or "(画像)") if posts else ""}) # タグを書いていないスレッドには、その場で自動のタグを付ける docs = {x["name"]: x.pop("words") for x in items} for x in items: if not x["tags"]: x["tags"] = auto_tags(x["name"], docs) x["auto"] = True text = x.pop("text") if not x["claim"]: x["claim"], x["kind"] = auto_claim(text, x["tags"]) x["claimAuto"] = True items.sort(key=lambda x: x["mtime"], reverse=True) self.send_json(200, items) elif url.path == "/api/image": q = parse_qs(url.query) name, src = q.get("name", [""])[0], q.get("src", [""])[0] f = MATTERS / name / src if not valid_name(name) or not IMG_NAME.match(src) or not f.exists(): self.send_json(404, {"error": "画像がない"}) return body = f.read_bytes() self.send_response(200) self.send_header("Content-Type", "image/jpeg") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "max-age=86400") self.end_headers() self.wfile.write(body) elif url.path == "/api/search": q = parse_qs(url.query) self.send_json(200, search(q.get("q", [""])[0], q.get("piles", ["0"])[0] == "1")) elif url.path == "/api/thread": q = parse_qs(url.query) name = q.get("name", [""])[0] # 片づけたスレッドは読むだけ。書く画面には入れない f = piles_path(name) if q.get("piles", ["0"])[0] == "1" else md_path(name) if not valid_name(name) or not f or not f.exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return text = f.read_text(encoding="utf-8") posts = parse(text) self.send_json(200, {"name": name, "mtime": mtime(f), "posts": posts, "url": thread_url(posts), "lock": locked(text), "tags": read_tags(text), "claim": read_claim(text) or auto_claim(text, read_tags(text))[0], "claimAuto": not read_claim(text), "claimCandidates": claim_candidates(text, read_tags(text)), "kamikireOn": kamikire_conf()[0] is not None, "kamikire": kamikire_linked(name)}) else: self.send_json(404, {"error": "not found"})
def read_json(self): try: return json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0)))) except (ValueError, TypeError): self.send_json(400, {"error": "JSONが読めない"}) return None
def do_POST(self): path = urlparse(self.path).path if path == "/api/thread": self.save_thread() elif path == "/api/rename": self.rename_thread() elif path == "/api/publish": self.publish_thread() elif path == "/api/lock": self.lock_thread() elif path == "/api/tags": self.set_tags() elif path == "/api/claim": self.set_claim() elif path == "/api/sync": self.sync_thread() elif path == "/api/import": self.import_url() elif path == "/api/discard": self.discard_thread() elif path == "/api/kamikire": self.send_kamikire() else: self.send_json(404, {"error": "not found"})
def save_thread(self): data = self.read_json() if data is None: return posts = data.get("posts", []) if data.get("create"): # 名前は付けさせない。作った日時で決め、あとから変える base = datetime.now().strftime("%Y-%m-%d_%H%M") name, i = base, 2 while (MATTERS / name).exists(): name, i = f"{base}-{i}", i + 1 else: name = data.get("name", "") if not valid_name(name): self.send_json(400, {"error": "使えない名前だ"}) return current = mtime(md_path(name)) if current != data.get("mtime"): self.send_json(409, {"error": "ファイルが外で変更された", "mtime": current}) return # 公開した投稿は、画面から来た内容で書き換えさせない old = [p for p in parse(md_path(name).read_text(encoding="utf-8")) if p.get("uri")] if not same_published(posts[:len(old)], old): self.send_json(400, {"error": "Blueskyに公開した投稿は直せない"}) return try: store_images(name, posts) except (ValueError, TypeError): self.send_json(400, {"error": "画像が読めない"}) return write(name, posts) self.send_json(200, {"name": name, "mtime": mtime(md_path(name)), "posts": posts})
def rename_thread(self): data = self.read_json() if data is None: return old, new = data.get("name", ""), data.get("new", "") if not valid_name(old) or not md_path(old).exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return if not valid_name(new): self.send_json(400, {"error": "使えない名前だ"}) return if (MATTERS / new).exists(): self.send_json(409, {"error": "同じ名前のスレッドがある"}) return f = md_path(old) if mtime(f) != data.get("mtime"): self.send_json(409, {"error": "ファイルが外で変更された", "mtime": mtime(f)}) return text = f.read_text(encoding="utf-8") posts = parse(text) os.rename(MATTERS / old, MATTERS / new) os.remove(MATTERS / new / f"{old}.md") write(new, posts, locked(text)) self.send_json(200, {"name": new, "mtime": mtime(md_path(new))})
def sync_thread(self): """Bluesky 側で足したり消したりした投稿を、Re: の記録に合わせる。apply が真でなければ件数だけ返す。""" data = self.read_json() if data is None: return name = data.get("name", "") f = md_path(name) if not valid_name(name) or not f.exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return if mtime(f) != data.get("mtime"): self.send_json(409, {"error": "ファイルが外で変更された", "mtime": mtime(f)}) return text = f.read_text(encoding="utf-8") posts = parse(text) if not any(p.get("uri") for p in posts): self.send_json(400, {"error": "まだ Bluesky に投稿していないスレッドだ"}) return try: plan = plan_sync(name, posts, save_images=bool(data.get("apply"))) except (bluesky.PublishError, OSError, KeyError, ValueError) as e: self.send_json(502, {"error": f"Bluesky から読めなかった。{e}"}) return if plan["root_missing"]: self.send_json(400, {"error": "スレッドの最初の投稿が Bluesky から消えている。同期できない"}) return summary = {k: plan[k] for k in ("added", "removed", "kept", "drafts", "branches", "added_heads", "removed_heads")} if not data.get("apply") or not (plan["added"] or plan["removed"]): self.send_json(200, dict(summary, applied=False)) return PILES.mkdir(exist_ok=True) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") (PILES / f"{name}_同期する前_{stamp}.md").write_text(text, encoding="utf-8") write(name, plan["posts"], locked(text)) self.send_json(200, dict(summary, applied=True, mtime=mtime(f), posts=plan["posts"], url=thread_url(plan["posts"])))
def import_url(self): """URL を入れたスレッドを取り込む。手元にあれば Bluesky に合わせる。""" data = self.read_json() if data is None: return try: root = root_of_url(data.get("url", "")) except ValueError as e: self.send_json(400, {"error": str(e)}) return except (bluesky.PublishError, OSError, KeyError) as e: self.send_json(502, {"error": f"Bluesky から読めなかった。{e}"}) return name = thread_by_root(root) try: if not name: self.send_json(200, import_thread(root)) return f = md_path(name) text = f.read_text(encoding="utf-8") plan = plan_sync(name, parse(text), save_images=True) except (bluesky.PublishError, OSError, KeyError, ValueError) as e: self.send_json(502, {"error": f"Bluesky から読めなかった。{e}"}) return if plan["root_missing"]: self.send_json(400, {"error": "スレッドの最初の投稿が Bluesky から消えている。同期できない"}) return summary = {k: plan[k] for k in ("added", "removed", "kept", "drafts", "branches", "added_heads", "removed_heads")} if not (plan["added"] or plan["removed"]): self.send_json(200, dict(summary, name=name, found=True, applied=False)) return PILES.mkdir(exist_ok=True) stamp = datetime.now().strftime("%Y%m%d-%H%M%S") (PILES / f"{name}_同期する前_{stamp}.md").write_text(text, encoding="utf-8") write(name, plan["posts"], locked(text)) self.send_json(200, dict(summary, name=name, found=True, applied=True))
def discard_thread(self): """スレッドを消さずに piles/ へ移す。Bluesky の投稿には触れない。""" data = self.read_json() if data is None: return name = data.get("name", "") if not valid_name(name) or not md_path(name).exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return PILES.mkdir(exist_ok=True) dest, i = PILES / name, 2 while dest.exists(): dest, i = PILES / f"{name}-{i}", i + 1 shutil.move(str(MATTERS / name), str(dest)) self.send_json(200, {"moved": os.path.relpath(dest, MATTERS.parent)})
def lock_thread(self): data = self.read_json() if data is None: return name = data.get("name", "") f = md_path(name) if not valid_name(name) or not f.exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return if mtime(f) != data.get("mtime"): self.send_json(409, {"error": "ファイルが外で変更された", "mtime": mtime(f)}) return lock = bool(data.get("lock")) write(name, parse(f.read_text(encoding="utf-8")), lock) self.send_json(200, {"mtime": mtime(f), "lock": lock})
def set_tags(self): data = self.read_json() if data is None: return name = data.get("name", "") if not valid_name(name) or not md_path(name).exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return tags = [t for t in str(data.get("tags", "")).replace(" ", " ").split() if len(t) <= 20][:4] write_tags(name, tags) self.send_json(200, {"tags": tags, "mtime": mtime(md_path(name))})
def send_kamikire(self): """紙切れの下書きを1枚作る。送ったことがあれば作らずに、その下書きの名前を返す(紙切れで直した文を上書きしない)。""" data = self.read_json() if data is None: return name = data.get("name", "") f = md_path(name) if valid_name(name) else None if not f or not f.exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return d, url = kamikire_conf() if not d: self.send_json(400, {"error": "紙切れの置き場(KAMIKIRE_MATTERS)が .env にない"}) return linked = kamikire_linked(name) if linked: self.send_json(200, {"name": linked, "url": url, "created": False}) return text = f.read_text(encoding="utf-8") posts = parse(text) if not posts: self.send_json(400, {"error": "送る投稿がない"}) return out, k = name, 2 while (d / f"{out}.md").exists(): out, k = f"{name}_{k}", k + 1 # 題は、手で直した主張があればそれを使う。自動の下書きは使わない(紙切れの題は材料が出てから付ける) claim = read_claim(text) head = ["---", f"re: {name}"] + ([f"title: {claim}"] if claim else []) + ["style: mincho", "---"] (d / f"{out}.md").write_text("\n".join(head) + "\n" + kamikire_body(posts), encoding="utf-8") self.send_json(200, {"name": out, "url": url, "created": True})
def set_claim(self): data = self.read_json() if data is None: return name = data.get("name", "") if not valid_name(name) or not md_path(name).exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return claim = " ".join(str(data.get("claim", "")).split())[:60] if "-->" in claim: self.send_json(400, {"error": "「-->」は使えない"}) return write_claim(name, claim) self.send_json(200, {"claim": claim})
def publish_thread(self): data = self.read_json() if data is None: return name = data.get("name", "") f = md_path(name) if not valid_name(name) or not f.exists(): self.send_json(404, {"error": "スレッドが見つからない"}) return if mtime(f) != data.get("mtime"): self.send_json(409, {"error": "ファイルが外で変更された", "mtime": mtime(f)}) return text = f.read_text(encoding="utf-8") if locked(text): self.send_json(403, {"error": "鍵がかかっているスレッドは Bluesky に投稿しない"}) return posts = parse(text) todo = [i for i, p in enumerate(posts) if not p.get("uri")] if not todo: self.send_json(400, {"error": "未公開の投稿がない"}) return if any(posts[i].get("uri") for i in range(todo[0], len(posts))): self.send_json(400, {"error": "公開済みの投稿のあとに未公開の投稿がある。順番が崩れている"}) return # 画面が数式と表を画像にして送ってくる。本文は[数式1][表1]に置き換えたもの。MDファイルは書き換えない render = data.get("render") or {} for key, r in render.items(): if not key.isdigit() or int(key) not in todo: self.send_json(400, {"error": "置き換えの対象が未公開の投稿ではない"}) return if len(posts[int(key)].get("images", [])) + len(r.get("images", [])) > 4: self.send_json(400, {"error": f"{int(key) + 1}件目の画像が4枚を超える"}) return done, error = 0, None try: session = bluesky.Session() for i in todo: root = {"uri": posts[0]["uri"], "cid": posts[0]["cid"]} if i > 0 else None parent = {"uri": posts[i - 1]["uri"], "cid": posts[i - 1]["cid"]} if i > 0 else None images = [] for im in posts[i].get("images", []): m = SIZE.search(im["src"]) images.append({"bytes": (MATTERS / name / im["src"]).read_bytes(), "alt": im.get("alt", ""), "w": int(m.group(1)) if m else 0, "h": int(m.group(2)) if m else 0}) body = posts[i]["text"] r = render.get(str(i)) if r: body = r.get("text", body) for im in r.get("images", []): images.append({"bytes": base64.b64decode(im["data"].split(",", 1)[-1]), "alt": im.get("alt", ""), "w": int(im.get("w", 0)), "h": int(im.get("h", 0))}) # [サイト名](URL) は「サイト名」だけにして、そこへリンクを張る。MDファイルは記法のまま残す body, mdl = bluesky.md_links(body) ref = session.post(body, root, parent, images, thread_links(body, name) + mdl) posts[i].update(ref) # 1件ごとに書く。途中で止まっても、次は続きから投稿できる write(name, posts) done += 1 except bluesky.PublishError as e: error = str(e) self.send_json(200 if not error else 502, { "mtime": mtime(f), "posts": posts, "done": done, "left": len(todo) - done, "url": thread_url(posts), "error": error, })
# ---- Bluesky との同期 ----
def own_thread_records(root_uri): """スレッドの頭の投稿と、同じスレッドに属する自分の投稿を、PDS から全部集める。ログインは要らない。""" did = root_uri.split("/")[2] pds = bluesky.pds_for_did(did) root = bluesky.call(f"{pds}/xrpc/com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={root_uri.rsplit('/', 1)[1]}") started = root["value"]["createdAt"] recs, cursor = {root["uri"]: root}, None while True: q = f"{pds}/xrpc/com.atproto.repo.listRecords?repo={did}&collection=app.bsky.feed.post&limit=100" page = bluesky.call(q + (f"&cursor={urllib.parse.quote(cursor)}" if cursor else "")) older = False for r in page.get("records", []): v = r["value"] if v.get("createdAt", "") < started: older = True continue if (v.get("reply") or {}).get("root", {}).get("uri") == root_uri: recs[r["uri"]] = r cursor = page.get("cursor") if older or not cursor: break return pds, did, recs
def local_time(iso): try: t = datetime.fromisoformat(iso.replace("Z", "+00:00")) return t.astimezone().strftime("%Y-%m-%d %H:%M:%S") except ValueError: return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def record_to_post(name, rec, pds, did, save_images): """Bluesky の投稿を Re: の投稿の形にする。短く表示されたリンクは URL 全体に戻し、画像は取り込む。""" v = rec["value"] text = v.get("text", "") raw = text.encode("utf-8") spans = [] for f in v.get("facets") or []: for feat in f.get("features", []): if feat.get("$type") == "app.bsky.richtext.facet#link": spans.append((f["index"]["byteStart"], f["index"]["byteEnd"], feat["uri"])) for start, end, uri in sorted(spans, reverse=True): shown = raw[start:end].decode("utf-8", "ignore") if shown != uri and (shown.endswith("...") or not uri.endswith(shown)): raw = raw[:start] + uri.encode("utf-8") + raw[end:] text = raw.decode("utf-8", "ignore") post = {"time": local_time(v.get("createdAt", "")), "uri": rec["uri"], "cid": rec["cid"], "text": text} embed = v.get("embed") or {} quote = embed.get("record", {}).get("record") if embed.get("$type") == "app.bsky.embed.recordWithMedia" else embed.get("record") if isinstance(quote, dict) and quote.get("uri"): parts = quote["uri"].split("/") link = f"https://bsky.app/profile/{parts[2]}/post/{parts[4]}" if link not in post["text"]: post["text"] = (post["text"] + "\n" + link).strip() media = embed.get("media") if embed.get("$type") == "app.bsky.embed.recordWithMedia" else embed images = [] for i, im in enumerate((media or {}).get("images", [])): cid = im.get("image", {}).get("ref", {}).get("$link") ar = im.get("aspectRatio") or {} fn = f"{rec['uri'].rsplit('/', 1)[1]}-{i + 1}_{ar.get('width', 0)}x{ar.get('height', 0)}.jpg" if save_images and cid: data = urllib.request.urlopen(f"{pds}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}", timeout=30).read() d = MATTERS / name / "images" d.mkdir(parents=True, exist_ok=True) (d / fn).write_bytes(data) images.append({"alt": im.get("alt", ""), "src": "images/" + fn}) if images: post["images"] = images return post
def thread_chain(root, order=()): """スレッドの頭から自分の返信をたどった一本の並びを返す。order は手元の並びで、消えた投稿を跨ぐのに使う。""" pds, did, recs = own_thread_records(root) kids = {} for uri, r in recs.items(): parent = (r["value"].get("reply") or {}).get("parent", {}).get("uri") if parent: kids.setdefault(parent, []).append(r) for k in kids.values(): k.sort(key=lambda r: r["value"].get("createdAt", "")) order = list(order) chain, cur = [recs[root]] if root in recs else [], root while True: nxt = kids.get(cur) if not nxt: # 手元の次の投稿が Bluesky から消えていて、その投稿への返信が残っていれば、そこから続ける i = order.index(cur) if cur in order else -1 if 0 <= i < len(order) - 1 and order[i + 1] not in recs and kids.get(order[i + 1]): cur = order[i + 1] continue break chain.append(nxt[0]) cur = nxt[0]["uri"] return pds, did, recs, chain
def plan_sync(name, posts, save_images=False): """Re: の記録を Bluesky のスレッドに合わせた投稿の並びと、変わる件数を返す。""" published = [p for p in posts if p.get("uri")] drafts = [p for p in posts if not p.get("uri")] root = published[0]["uri"] local = {p["uri"]: p for p in published} pds, did, recs, chain = thread_chain(root, [p["uri"] for p in published]) on_chain = {r["uri"] for r in chain} branches = sum(1 for r in recs.values() if r["uri"] not in on_chain) new_posts, added, kept = [], 0, 0 for r in chain: if r["uri"] in local: new_posts.append(local[r["uri"]]) kept += 1 else: new_posts.append(record_to_post(name, r, pds, did, save_images)) added += 1 removed = [p for p in published if p["uri"] not in on_chain] return {"posts": new_posts + drafts, "added": added, "removed": len(removed), "kept": kept, "drafts": len(drafts), "branches": branches, "root_missing": root not in recs, "removed_heads": [p["text"][:30] for p in removed], "added_heads": [r["value"].get("text", "")[:30] for r in chain if r["uri"] not in local]}
def root_of_url(url): """bsky.app の投稿URLから、そのスレッドの頭の投稿の URI を引く。返信のURLでも頭まで遡る。""" m = bluesky.POST_URL.match((url or "").strip()) if not m: raise ValueError("Bluesky の投稿のURLを入れてください(https://bsky.app/profile/…/post/…)") who, rkey = m.group(1), m.group(2) did = who if who.startswith("did:") else bluesky.resolve_handle(who) if not did: raise ValueError(f"ハンドルが見つからない:{who}") pds = bluesky.pds_for_did(did) rec = bluesky.call(f"{pds}/xrpc/com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={urllib.parse.quote(rkey)}") return (rec["value"].get("reply") or {}).get("root", {}).get("uri") or rec["uri"]
def thread_by_root(root): """その頭の投稿を持つスレッドが手元にあれば、その名前を返す。""" for d in sorted(MATTERS.iterdir()) if MATTERS.exists() else []: f = md_path(d.name) if not f.exists(): continue try: posts = parse(f.read_text(encoding="utf-8")) except OSError: continue pub = [p for p in posts if p.get("uri")] if pub and pub[0]["uri"] == root: return d.name return None
def own_did(): """.env のハンドルの DID。引けなければ None。""" handle = bluesky.load_env().get("BSKY_HANDLE", "") return bluesky.resolve_handle(handle) if handle else None
def import_thread(root): """Bluesky のスレッドを、新しいスレッドとして手元に取り込む。自分の投稿でなければ鍵をかける。""" pds, did, recs, chain = thread_chain(root) if not chain: raise ValueError("スレッドの頭の投稿が Bluesky から読めない") base = local_time(chain[0]["value"].get("createdAt", ""))[:16].replace(" ", "_").replace(":", "") name, i = base, 2 while (MATTERS / name).exists(): name, i = f"{base}-{i}", i + 1 posts = [record_to_post(name, r, pds, did, True) for r in chain] lock = did != own_did() write(name, posts, lock) branches = sum(1 for r in recs.values() if r["uri"] not in {x["uri"] for x in chain}) return {"name": name, "found": False, "added": len(posts), "removed": 0, "kept": 0, "drafts": 0, "branches": branches, "lock": lock, "applied": True}
# ---- 全文検索 ----
def norm(s): """全角と半角、英字の大文字と小文字をそろえる。ひらがなとカタカナは区別する。""" return unicodedata.normalize("NFKC", s).casefold()
def piles_path(name): """piles/ の中のスレッド。フォルダで片づけたものと、版として置いた .md の両方を読む。""" for f in (PILES / name / f"{name}.md", PILES / f"{name}.md"): if f.exists(): return f return None
def thread_files(include_piles): out = [] if MATTERS.exists(): out += [(d.name, md_path(d.name), False) for d in MATTERS.iterdir() if d.is_dir() and md_path(d.name).exists()] if include_piles and PILES.exists(): for d in PILES.iterdir(): if d.is_dir() and (d / f"{d.name}.md").exists(): out.append((d.name, d / f"{d.name}.md", True)) elif d.is_file() and d.suffix == ".md": out.append((d.stem, d, True)) return out
def search(query, include_piles=False, limit=300): """空白で区切った語を全部含む投稿を探す。本文と画像の ALT を見る。名前に全部を含むスレッドも出す。""" terms = [norm(t) for t in query.split() if t.strip()] if not terms: return {"hits": [], "total": 0} hits = [] for name, f, piled in thread_files(include_piles): try: posts = parse(f.read_text(encoding="utf-8")) except OSError: continue stamp = f.stat().st_mtime_ns if all(t in norm(name) for t in terms): hits.append({"name": name, "index": None, "count": len(posts), "text": posts[0]["text"] if posts else "", "piles": piled, "mtime": stamp, "time": posts[0].get("time") if posts else None}) for i, p in enumerate(posts): hay = norm(p["text"] + "\n" + "\n".join(im.get("alt", "") for im in p.get("images", []))) if all(t in hay for t in terms): alts = [im.get("alt", "") for im in p.get("images", []) if any(t in norm(im.get("alt", "")) for t in terms)] hits.append({"name": name, "index": i, "count": len(posts), "text": p["text"], "alts": alts, "piles": piled, "mtime": stamp, "time": p.get("time"), "published": bool(p.get("uri"))}) # 新しく書いた投稿から並べる hits.sort(key=lambda h: (h.get("time") or "", h["mtime"]), reverse=True) return {"hits": hits[:limit], "total": len(hits)}
THREAD_LABEL = re.compile(r"\d{4}-\d{2}-\d{2}_\d{4}")
def thread_links(text, here=None): """本文に書いた日付の名前を、そのスレッドの第一投稿へのリンクにする。Bluesky に出したスレッドだけ。""" out = [] for m in THREAD_LABEL.finditer(text): name = m.group(0) if name == here or not md_path(name).exists(): continue try: url = thread_url(parse(md_path(name).read_text(encoding="utf-8"))) except OSError: continue if url: out.append((m.start(), m.end(), url)) return out
# ---- タグ ----# ほかのスレッドと比べて、そのスレッドだけに多い語を出す。形態素解析は使わない(追加の部品を要らなくするため)TAG_STOP = set("""投稿 自分 場合 今回 今日 スレッド 感じ 内容 本文 全体 以下 以上 一つ 二つ 件数 中央 平均 数字 時点 都度 月始 対応 完全 対象the and for you that this with have from are was were not but its they them their when what which will would""".split())TAG_WORD = re.compile(r"[一-龥]{2,6}|[ァ-ヴー]{3,8}|[A-Za-z][A-Za-z0-9.]{3,}")TAG_LINE = re.compile(r"^<!-- re:keywords (.*?) -->$", re.M)
def tag_words(text): text = re.sub(r"https?://\S+", "", re.sub(r"<!--.*?-->", "", text, flags=re.S)) return [w for w in TAG_WORD.findall(text) if w not in TAG_STOP]
def read_tags(text): m = TAG_LINE.search(text) return m.group(1).split() if m else []
def auto_tags(name, docs, top=2): """docs は {名前: 語の一覧}。そのスレッドに2回以上出て、ほかではあまり出ない語を選ぶ。""" import math here = docs.get(name) or [] if not here: return [] n = len(docs) df = {} for ws in docs.values(): for w in set(ws): df[w] = df.get(w, 0) + 1 tf = {} for w in here: tf[w] = tf.get(w, 0) + 1 score = {w: c * math.log(n / df[w]) for w, c in tf.items() if c >= 2 and df[w] <= max(1, n // 2)} return [w for w, _ in sorted(score.items(), key=lambda x: (-x[1], x[0]))[:top]]
# ---- 主張 ----# 一覧に出す一行。書き終わってから決まるものなので、自動で出した下書きを直して使うCLAIM_LINE = re.compile(r"^<!-- re:claim (.*?) -->$", re.M)CLAIM_HEAD = re.compile(r"^(この|その|それ|ここ|あの|そこ|これ|しかし|だから|ただ|だが|たとえば|しかも|そのうえ|また|つまり)")
def read_claim(text): m = CLAIM_LINE.search(text) return m.group(1) if m else ""
def first_sentence(text): body = re.sub(r"<!--.*?-->", "", bluesky.md_links(text)[0], flags=re.S) body = "\n".join(l for l in body.split("\n") if not l.startswith("# ") and not l.startswith("![")) for x in re.split(r"(?<=。)|\n", body): x = x.strip() if x: return x[:60] return ""
# 主張は「AがBを〜する」の一文が望ましい。他動詞で終わる文を上に置くTRANS = re.compile(r"を[^。]{0,16}(する|させる|生む|作る|決める|変える|下げる|上げる|保つ|示す|求める|避ける|埋める|分ける|狭める|広げる|呼ぶ|招く|防ぐ|支える|強める|弱める|奪う|与える|残す|消す|出す|取る|使う|置く|運ぶ|数える)。?$")
def _claim_score(ss, body, tags): n = len(ss)
def score(pair): i, x = pair v = 0.0 if TRANS.search(x): v += 3.5 elif "を" in x and re.search(r"[うくすつぬぶむる]。$", x): v += 1.5 if x.endswith("だ。") or x.endswith("である。"): v += 1.5 # 「〜と思う」「〜のだと思う」は主張になじまない。字数が増えるだけだ if re.search(r"(と思う|と考えている|と見ている|のだと思う)。$", x): v -= 2.5 if re.search(r"(のだろう|だろう|はずだ)。$", x): v += 0.5 if re.search(r"とは、|というのは|ということだ。$", x): v += 0.5 if re.search(r"とは、", x) and x.endswith("ということだ。"): # 「〜とは…ということだ」は冗長だ v -= 1 if re.search(r"(ほうがいい|たらいい|べきだ|要る)。$", x): v += 1 if CLAIM_HEAD.match(x): v -= 3 # 一覧では前の投稿が読めない。何についての話かを自分で持っている文でないと「何が?」になる # 「は」で題目を立てた文を上に置く。「が」は文の中の主語にも付くので、題目の印にはしない if re.search(r"^[^、。]{2,22}(は|とは)[、\s]?", x): v += 2 elif re.search(r"^[^、。]{2,22}が", x): v -= 0.5 else: v -= 2.5 if x.endswith("か。") or "?" in x: v -= 2 if len(re.findall(r"\d", x)) >= 3: # 数字の並ぶ文は記録であって主張ではない v -= 2.5 if x.count("、") >= 3: # 列挙も主張ではない v -= 1.5 v += min(2, sum(1 for g in tags if g in x)) v += 1.2 if i < 0.25 * n else (0.8 if i > 0.7 * n else 0) v -= abs(len(x) - 30) / 25 return v
return [score(p) for p in enumerate(ss)]
def auto_claim(text, tags): """主張になる一文を返す。見つからなければ最初の一文を返す(日記型のスレッドだ)。""" body = re.sub(r"https?://\S+", "", re.sub(r"<!--.*?-->", "", bluesky.md_links(text)[0], flags=re.S)) body = "\n".join(l for l in body.split("\n") if not l.startswith("# ")) ss = [x.strip() for x in re.split(r"(?<=。)|\n", body) if 14 <= len(x.strip()) <= 55] if not ss: return first_sentence(text), "diary" sc = _claim_score(ss, body, tags) i = max(range(len(ss)), key=lambda k: sc[k]) best = ss[i] # 論証しているスレッドかどうかを、確信度の刻みの多さで見る marks = len(re.findall(r"(だろう|はずだ|と思う|からだ|ということだ|のだ)", body)) arguing = marks >= 4 and len(ss) >= 8 fits = bool(TRANS.search(best)) or bool(re.search(r"(とは、|ということだ。$)", best)) # 主張の形にならない日記型のスレッドは、無理に立てず最初の一文を出す if not (fits or (arguing and sc[i] >= 2.5)): return first_sentence(text), "diary" return best, "claim"
def claim_candidates(text, tags, top=3): """主張になりそうな一文を、点の高い順に返す。は/が の選び方は機械では決まらないので、選んで直す""" body = re.sub(r"https?://\S+", "", re.sub(r"<!--.*?-->", "", bluesky.md_links(text)[0], flags=re.S)) body = "\n".join(l for l in body.split("\n") if not l.startswith("# ")) ss = [x.strip() for x in re.split(r"(?<=。)|\n", body) if 14 <= len(x.strip()) <= 55] if not ss: return [] sc = _claim_score(ss, body, tags) return [ss[k] for k in sorted(range(len(ss)), key=lambda k: -sc[k])[:top]]
def write_claim(name, claim): f = md_path(name) lines = [l for l in f.read_text(encoding="utf-8").split("\n") if not l.startswith("<!-- re:claim ")] at = 1 if lines and lines[0].startswith("# ") else 0 while at < len(lines) and (not lines[at].strip() or lines[at].strip() == LOCK or lines[at].startswith("<!-- re:keywords ")): at += 1 if claim: lines[at:at] = ["<!-- re:claim " + claim + " -->", ""] f.write_text("\n".join(lines), encoding="utf-8")
def write_tags(name, tags): """見出しと鍵の下に1行置く。投稿の本文には触らない。""" f = md_path(name) lines = [l for l in f.read_text(encoding="utf-8").split("\n") if not l.startswith("<!-- re:keywords ")] at = 1 if lines and lines[0].startswith("# ") else 0 while at < len(lines) and (not lines[at].strip() or lines[at].strip() == LOCK): if lines[at].strip() == LOCK: at += 1 break at += 1 while at < len(lines) and not lines[at].strip(): at += 1 if tags: lines[at:at] = ["<!-- re:keywords " + " ".join(tags) + " -->", ""] f.write_text("\n".join(lines), encoding="utf-8")
def thread_url(posts): return bluesky.post_url(posts[0]["uri"]) if posts and posts[0].get("uri") else None
class V6Server(ThreadingHTTPServer): address_family = socket.AF_INET6
if __name__ == "__main__": MATTERS.mkdir(exist_ok=True) # localhost は ::1 が先に返ることがある。127.0.0.1 と ::1 の両方で待つ server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) try: v6 = V6Server(("::1", PORT), Handler) except OSError: v6 = None if v6: threading.Thread(target=v6.serve_forever, daemon=True).start() print(f"Re: http://localhost:{PORT}") server.serve_forever()