drafting up scripts

This commit is contained in:
ed committed 2026-08-23 14:46:05 -04:00
1 parent 20d5ea092d
commit c18ee1b4b5
19 files changed
+844

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# twitter
Emit Threadwell archive notes from a `thread_data.json` dump. No fetch, no lift.
## One dump
```
python C:\projects\Threadwell\scripts\twitter\emit_archive.py --input C:\projects\manual_slop\docs\twitter\2064858927829745887 --vault C:\projects\Threadwell --slug steamos-init --archived 2026-08-23
```
`--input` is the dump directory. `--vault` is the Threadwell root. `--slug` overrides the directory slug. `--archived` is `YYYY-MM-DD` and defaults to today.
The converter writes the spine and branch notes under `archive/threads/<handle>/<date-slug>/` and a working copy under `assets/threads/<handle>/<date-slug>/` (`thread_data.json`, `media.json`, `gaps.md`, and any local media). Media is not published.
+1
View File
@@ -0,0 +1 @@
"""Emit Threadwell archive notes from a thread_data.json dump. No fetch, no lift."""
+249
View File
@@ -0,0 +1,249 @@
"""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())
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import json
@dataclass(frozen=True)
class PostMetrics:
reply_count: int
repost_count: int
like_count: int
view_count: int | None
@dataclass(frozen=True)
class PostData:
post_id: str
author: str
handle: str
text: str
timestamp: str
media_urls: tuple[str, ...]
reply_to_id: str | None
quote_of_id: str | None
metrics: PostMetrics
@dataclass(frozen=True)
class ThreadData:
root_post_id: str
posts: tuple[PostData, ...]
source_url: str
def _norm_handle(raw: str) -> str:
h = (raw or "").strip()
return h[1:] if h.startswith("@") else h
def _norm_id(raw: object) -> str | None:
if raw is None:
return None
s = str(raw).strip()
return s or None
def load_thread(path: Path) -> ThreadData:
data = json.loads(path.read_text(encoding="utf-8"))
posts: list[PostData] = []
for pd in data.get("posts", []):
m = pd.get("metrics") or {}
view = m.get("view_count")
posts.append(PostData(
post_id=str(pd.get("post_id") or ""),
author=str(pd.get("author") or ""),
handle=_norm_handle(str(pd.get("handle") or "")),
text=str(pd.get("text") or ""),
timestamp=str(pd.get("timestamp") or ""),
media_urls=tuple(pd.get("media_urls") or []),
reply_to_id=_norm_id(pd.get("reply_to_id")),
quote_of_id=_norm_id(pd.get("quote_of_id")),
metrics=PostMetrics(
reply_count=int(m.get("reply_count") or 0),
repost_count=int(m.get("repost_count") or 0),
like_count=int(m.get("like_count") or 0),
view_count=int(view) if view is not None else None,
),
))
return ThreadData(
root_post_id=str(data.get("root_post_id") or ""),
posts=tuple(posts),
source_url=str(data.get("source_url") or ""),
)
+217
View File
@@ -0,0 +1,217 @@
"""Render archive note markdown from thread JSON. Strings only; no file I/O."""
from __future__ import annotations
from .models import PostData, ThreadData
from .slug import date_prefix
from .tree import by_id
_PROVENANCE = (
"> [!info] Provenance\n"
"> Primary source record. Do not editorialize here. "
"Interpretation goes in a `notes/` page."
)
def _yaml_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def _first_line(text: str) -> str:
return (text or "").split("\n", 1)[0].rstrip("\r")
def _title_text(text: str) -> str:
line = _first_line(text)
idx = line.find(". ")
if idx >= 0:
return line[: idx + 1]
return line
def _status_url(handle: str, post_id: str) -> str:
return f"https://x.com/{handle}/status/{post_id}"
def _blockquote(text: str) -> str:
lines = (text or "").split("\n")
return "\n".join(">" if line == "" else f"> {line}" for line in lines)
def _frontmatter(
*,
title: str,
source_url: str,
author: str,
handle: str,
post_id: str,
date: str,
archived: str,
spine_handle: str,
in_reply_to: str,
parent_post_id: str | None = None,
) -> str:
lines = [
"---",
f"title: {_yaml_quote(title)}",
"type: archive",
"source: twitter",
f"source_url: {_yaml_quote(source_url)}",
f"author: {_yaml_quote(author)}",
f"handle: {handle}",
f"post_id: {_yaml_quote(post_id)}",
f"date: {date}",
f"archived: {archived}",
"status: draft",
"draft: true",
"tags:",
" - archive",
" - twitter",
f" - {spine_handle}",
f"description: {_yaml_quote(title)}",
f"in_reply_to: {_yaml_quote(in_reply_to)}",
]
if parent_post_id is not None:
lines.append(f"parent_post_id: {_yaml_quote(parent_post_id)}")
lines.append("---")
return "\n".join(lines)
def _source_block(post: PostData) -> str:
url = _status_url(post.handle, post.post_id)
return (
"## Source\n"
"\n"
f"- URL: {url}\n"
f"- Author: {post.author} (@{post.handle})\n"
f"- Posted: {post.timestamp}"
)
def _post_block(
n: int,
post: PostData,
media_by_post: dict[str, tuple[str, ...]] | None,
branch_links: list[str] | None = None,
) -> str:
parts = [f"**{n}/**", "", post.text]
media = (media_by_post or {}).get(post.post_id) or ()
if media:
parts.append("")
parts.append("Media (not lifted): " + " ".join(f"`{fn}`" for fn in media))
if branch_links:
parts.append("")
parts.append("Branches: " + ", ".join(f"[[{name}]]" for name in branch_links))
parts.append("")
return "\n".join(parts)
def _foreign_parent(
first: PostData,
ids: dict[str, PostData],
) -> tuple[str, PostData | None]:
parent_id = first.reply_to_id or ""
if not parent_id:
return "", None
parent = ids.get(parent_id)
if parent is None:
return parent_id, None
if parent.handle != first.handle:
return parent_id, parent
return "", None
def render_spine(
thread: ThreadData,
spine: list[str],
branch_root_ids: list[str],
branch_names: dict[str, str],
archived: str,
media_by_post: dict[str, tuple[str, ...]] | None = None,
) -> str:
"""branch_names maps branch-root post_id -> wikilink target (filename without .md)."""
ids = by_id(thread)
first = ids[spine[0]]
spine_handle = first.handle
in_reply_to, parent = _foreign_parent(first, ids)
title = _title_text(first.text)
source_url = _status_url(first.handle, first.post_id)
chunks = [
_frontmatter(
title=title,
source_url=source_url,
author=first.author,
handle=first.handle,
post_id=first.post_id,
date=date_prefix(first.timestamp),
archived=archived,
spine_handle=spine_handle,
in_reply_to=in_reply_to,
),
"",
_PROVENANCE,
"",
_source_block(first),
"",
]
if parent is not None:
chunks.append(_blockquote(parent.text))
chunks.append("")
chunks.append("## Thread")
chunks.append("")
branches_under: dict[str, list[str]] = {}
for rid in branch_root_ids:
parent_id = ids[rid].reply_to_id
if parent_id:
branches_under.setdefault(parent_id, []).append(branch_names[rid])
for n, pid in enumerate(spine, 1):
post = ids[pid]
chunks.append(_post_block(n, post, media_by_post, branches_under.get(pid)))
return "\n".join(chunks).rstrip() + "\n"
def render_branch(
thread: ThreadData,
branch_root_id: str,
descendant_ids: list[str],
spine_handle: str,
archived: str,
media_by_post: dict[str, tuple[str, ...]] | None = None,
) -> str:
"""descendant_ids are posts after the root, in display order (tree.descendants)."""
ids = by_id(thread)
root = ids[branch_root_id]
title = _title_text(root.text)
source_url = _status_url(root.handle, root.post_id)
chunks = [
_frontmatter(
title=title,
source_url=source_url,
author=root.author,
handle=root.handle,
post_id=root.post_id,
date=date_prefix(root.timestamp),
archived=archived,
spine_handle=spine_handle,
in_reply_to="",
parent_post_id=root.reply_to_id or "",
),
"",
_PROVENANCE,
"",
_source_block(root),
"",
"## Branch",
"",
]
posts = [root] + [ids[i] for i in descendant_ids]
for n, post in enumerate(posts, 1):
chunks.append(_post_block(n, post, media_by_post))
chunks.append("## Related")
chunks.append("")
chunks.append("- Spine: [[index]]")
chunks.append("")
return "\n".join(chunks).rstrip() + "\n"
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import re
_MENTION = re.compile(r"^(@[A-Za-z0-9_]+(\s+|$))+")
_APOS = re.compile(r"['\u2019\u2018]")
_NON_SLUG = re.compile(r"[^a-z0-9]+")
def date_prefix(timestamp: str) -> str:
if len(timestamp) >= 10 and timestamp[4] == "-" and timestamp[7] == "-":
return timestamp[:10]
return "unknown-date"
def slugify(text: str, max_len: int = 50) -> str:
s = _MENTION.sub("", (text or "").strip()).lower()
s = _APOS.sub("", s)
s = _NON_SLUG.sub("-", s).strip("-")
if not s:
return "thread"
if len(s) <= max_len:
return s
cut = s[:max_len]
if cut.endswith("-"):
return cut.rstrip("-")
cut = cut.rstrip("-")
if "-" in cut:
cut = cut.rsplit("-", 1)[0]
return cut or s[:max_len]
def thread_dir_name(date: str, first_spine_line: str, override: str | None) -> str:
slug = override.strip() if override else slugify(first_spine_line)
return f"{date}-{slug}"
def branch_file_name(date: str, handle: str, first_line: str) -> str:
return f"{date}-{handle}-{slugify(first_line)}.md"
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from .models import ThreadData, PostData
def by_id(thread: ThreadData) -> dict[str, PostData]:
return {p.post_id: p for p in thread.posts}
def children_map(thread: ThreadData) -> dict[str, list[str]]:
ids = by_id(thread)
kids: dict[str, list[str]] = {p.post_id: [] for p in thread.posts}
for p in thread.posts:
if p.reply_to_id and p.reply_to_id in ids:
kids[p.reply_to_id].append(p.post_id)
for parent, lst in kids.items():
lst.sort(key=lambda i: (ids[i].timestamp, ids[i].post_id))
return kids
def spine_ids(thread: ThreadData) -> list[str]:
ids = by_id(thread)
kids = children_map(thread)
start = thread.root_post_id if thread.root_post_id in ids else thread.posts[0].post_id
handle = ids[start].handle
out = [start]
cur = start
while True:
same = [c for c in kids[cur] if ids[c].handle == handle]
if not same:
break
cur = same[0]
out.append(cur)
return out
def branch_roots(thread: ThreadData, spine: list[str]) -> list[str]:
spine_set = set(spine)
ids = by_id(thread)
roots: list[str] = []
for p in thread.posts:
if p.post_id in spine_set:
continue
parent_on_spine = p.reply_to_id in spine_set
parent_missing = p.reply_to_id is not None and p.reply_to_id not in ids
if parent_on_spine or parent_missing:
roots.append(p.post_id)
roots.sort(key=lambda i: (ids[i].timestamp, ids[i].post_id))
return roots
def descendants(start_id: str, kids: dict[str, list[str]]) -> list[str]:
out: list[str] = []
stack = list(reversed(kids.get(start_id, [])))
while stack:
n = stack.pop()
out.append(n)
stack.extend(reversed(kids.get(n, [])))
return out