Files
Threadwell/scripts/twitter/emit_archive.py
T
2026-08-23 14:46:05 -04:00

250 lines
7.1 KiB
Python

"""Emit Threadwell archive notes from a thread_data.json dump. No fetch, no lift."""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import date
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from twitter.models import ThreadData, load_thread
from twitter.render import render_branch, render_spine
from twitter.slug import branch_file_name, date_prefix, thread_dir_name
from twitter.tree import by_id, branch_roots, children_map, descendants, spine_ids
def _url_media_id(url: str, index: int) -> str:
segment = Path(unquote(urlparse(url).path)).name
return segment if segment else f"m{index}"
def _url_ext(url: str) -> str | None:
parsed = urlparse(url)
fmt = parse_qs(parsed.query).get("format", [None])[0]
if fmt:
return fmt.lstrip(".").lower()
suffix = Path(unquote(parsed.path)).suffix
if suffix:
return suffix.lstrip(".").lower()
return None
def _media_files(media_dir: Path) -> list[Path]:
if not media_dir.is_dir():
return []
return sorted(p for p in media_dir.iterdir() if p.is_file())
def _take_preferred(unused: list[Path], post_id: str, index: int) -> Path | None:
want = f"{post_id}_{index}"
for path in unused:
if path.stem == want:
return path
return None
def _take_fallback(unused: list[Path], post_id: str) -> Path | None:
prefix = f"{post_id}_"
for path in unused:
if path.name.startswith(prefix):
return path
return None
def collect_media(
thread: ThreadData,
input_dir: Path,
dest_dir: Path,
) -> tuple[list[dict[str, object]], dict[str, tuple[str, ...]]]:
unused = _media_files(input_dir / "media")
items: list[dict[str, object]] = []
by_post: dict[str, list[str]] = {}
for post in thread.posts:
for index, url in enumerate(post.media_urls, start=1):
media_id = _url_media_id(url, index)
src = _take_preferred(unused, post.post_id, index)
if src is None:
src = _take_fallback(unused, post.post_id)
if src is not None:
unused.remove(src)
ext = src.suffix.lstrip(".") or _url_ext(url) or "bin"
else:
ext = _url_ext(url) or "bin"
filename = f"{post.post_id}_{media_id}_orig.{ext}"
if src is not None:
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest_dir / filename)
items.append({
"post_id": post.post_id,
"media_id": media_id,
"handle": post.handle,
"role": "orig",
"filename": filename,
"publish": False,
"url": None,
})
by_post.setdefault(post.post_id, []).append(filename)
media_by_post = {pid: tuple(names) for pid, names in by_post.items()}
return items, media_by_post
def render_gaps(thread: ThreadData, spine: list[str]) -> str:
ids = by_id(thread)
suggested = spine[-1] if spine else ""
quote_ids = [p.post_id for p in thread.posts if p.quote_of_id]
missing = [
p.post_id
for p in thread.posts
if p.reply_to_id is not None and p.reply_to_id not in ids
]
empty = [p.post_id for p in thread.posts if not (p.text or "").strip()]
lines = [
"# gaps",
"input: root",
f"suggested_tip: {suggested}",
]
if quote_ids:
lines.append("quote_of:")
lines.extend(quote_ids)
else:
lines.append("quote_of: unset")
lines.append("missing_reply_to:")
lines.extend(missing)
lines.append("empty_text:")
lines.extend(empty)
return "\n".join(lines) + "\n"
def emit(
input_dir: Path,
vault: Path,
slug: str | None,
archived: str,
) -> None:
src = input_dir / "thread_data.json"
if not src.is_file():
raise SystemExit(f"missing {src}")
thread = load_thread(src)
if not thread.posts:
raise SystemExit(f"no posts in {src}")
spine = spine_ids(thread)
roots = branch_roots(thread, spine)
ids = by_id(thread)
kids = children_map(thread)
first = ids[spine[0]]
handle = first.handle
first_line = (first.text or "").split("\n", 1)[0]
dir_name = thread_dir_name(date_prefix(first.timestamp), first_line, slug)
branch_names: dict[str, str] = {}
for rid in roots:
branch = ids[rid]
fname = branch_file_name(
date_prefix(branch.timestamp),
branch.handle,
branch.text,
)
name = fname[:-3] if fname.endswith(".md") else fname
branch_names[rid] = name
note_dir = vault / "archive" / "threads" / handle / dir_name
asset_dir = vault / "assets" / "threads" / handle / dir_name
note_dir.mkdir(parents=True, exist_ok=True)
asset_dir.mkdir(parents=True, exist_ok=True)
items, media_by_post = collect_media(thread, input_dir, asset_dir)
(note_dir / "index.md").write_text(
render_spine(
thread,
spine,
roots,
branch_names,
archived,
media_by_post,
),
encoding="utf-8",
newline="\n",
)
for rid in roots:
(note_dir / f"{branch_names[rid]}.md").write_text(
render_branch(
thread,
rid,
descendants(rid, kids),
handle,
archived,
media_by_post,
),
encoding="utf-8",
newline="\n",
)
shutil.copy2(src, asset_dir / "thread_data.json")
media_doc = {
"root_post_id": thread.root_post_id,
"items": items,
}
(asset_dir / "media.json").write_text(
json.dumps(media_doc, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
(asset_dir / "gaps.md").write_text(
render_gaps(thread, spine),
encoding="utf-8",
newline="\n",
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Emit Threadwell archive notes from a thread_data.json dump. "
"No fetch, no lift."
),
)
parser.add_argument(
"--input",
required=True,
type=Path,
help="Dump directory containing thread_data.json",
)
parser.add_argument(
"--vault",
required=True,
type=Path,
help="Threadwell vault root",
)
parser.add_argument(
"--slug",
default=None,
help="Optional slug override for the thread directory",
)
parser.add_argument(
"--archived",
default=date.today().isoformat(),
help="Archive date YYYY-MM-DD (default: today local)",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
emit(args.input, args.vault, args.slug, args.archived)
return 0
if __name__ == "__main__":
raise SystemExit(main())