Private
Public Access
0
0
Files
manual_slop/scripts/twitter_threads/fetch_thread.py
T
ed ae9cc1d494 feat(twitter_threads): full-thread extraction (conversations) + front-matter from target + corpus dedupe
fetch_thread_from_url now passes -o conversations=true (full threads, not single tweets), sorts posts chronologically, and sets root_post_id to the URL's target tweet. render_markdown builds front-matter from the target (root_post_id) post, not the earliest conversation tweet (fixes wrong @handle). Added dedupe_corpus.py: keeps deepest thread per conversation, deletes duplicates, keeps+marks unique branches (branch_of) + writes threads_index.json. 33 tests pass.
2026-07-05 18:38:43 -04:00

264 lines
8.5 KiB
Python

"""Acquire a Twitter/X thread into a ThreadData: gallery-dl (URL) or html.parser (local HTML)."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from dataclasses import asdict
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
from urllib.parse import urlparse, urlunparse
try:
from scripts.twitter_threads.error_types import ErrorInfo, make_error, Result, PostMetrics, PostData, ThreadData
except ImportError:
from error_types import ErrorInfo, make_error, Result, PostMetrics, PostData, ThreadData
def _normalize_url(url: str) -> str:
parts = urlparse(url)
return urlunparse((parts.scheme, parts.netloc, parts.path, parts.params, "", ""))
def _to_int(value: str | None, default: int | None) -> int | None:
if value is None or value == "":
return default
try:
return int(value)
except ValueError:
return default
class _ThreadHTMLParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.posts: list[PostData] = []
self.source_url: str = ""
self._attrs: dict[str, str] = {}
self._media: list[str] = []
self._timestamp: str = ""
self._text_parts: list[str] = []
self._in_article: bool = False
self._in_text: bool = False
self._text_depth: int = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
a = {k: (v or "") for k, v in attrs}
if tag == "link" and a.get("rel") == "canonical" and a.get("href"):
self.source_url = a["href"]
if tag == "article" and "data-post-id" in a:
self._in_article = True
self._attrs = a
self._media = []
self._timestamp = ""
self._text_parts = []
self._in_text = False
self._text_depth = 0
if not self._in_article:
return
if tag == "time" and a.get("datetime"):
self._timestamp = a["datetime"]
if tag == "div" and a.get("data-testid") == "tweetText":
self._in_text = True
self._text_depth = 1
self._text_parts = []
elif tag == "div" and self._in_text:
self._text_depth += 1
if tag in ("img", "source", "video") and a.get("src"):
self._media.append(a["src"])
def handle_data(self, data: str) -> None:
if self._in_text:
self._text_parts.append(data)
def handle_endtag(self, tag: str) -> None:
if tag == "div" and self._in_text:
self._text_depth -= 1
if self._text_depth <= 0:
self._in_text = False
if tag == "article" and self._in_article:
self.posts.append(self._build())
self._in_article = False
def _build(self) -> PostData:
a = self._attrs
metrics = PostMetrics(
reply_count=_to_int(a.get("data-reply-count"), 0),
repost_count=_to_int(a.get("data-repost-count"), 0),
like_count=_to_int(a.get("data-like-count"), 0),
view_count=_to_int(a.get("data-view-count"), None),
)
return PostData(
post_id=a.get("data-post-id", ""),
author=a.get("data-author", ""),
handle=a.get("data-handle", ""),
text="".join(self._text_parts).strip(),
timestamp=self._timestamp,
media_urls=tuple(self._media),
reply_to_id=a.get("data-reply-to"),
quote_of_id=a.get("data-quote-of"),
metrics=metrics,
)
def fetch_thread_from_html(html_path: Path) -> Result[ThreadData]:
try:
text = Path(html_path).read_text(encoding="utf-8")
except OSError as e:
return Result.err(make_error("ReadError", "fetch_thread_from_html", str(e)))
parser = _ThreadHTMLParser()
try:
parser.feed(text)
parser.close()
except Exception as e:
return Result.err(make_error("ParseError", "fetch_thread_from_html", str(e)))
if not parser.posts:
return Result.err(make_error("ParseError", "fetch_thread_from_html", "no <article> elements found"))
posts = tuple(parser.posts)
return Result.ok(ThreadData(root_post_id=posts[0].post_id, posts=posts, source_url=parser.source_url))
def _find_tweet_meta(entry: Any) -> dict[str, Any] | None:
if isinstance(entry, dict):
if "tweet_id" in entry or ("content" in entry and "author" in entry):
return entry
return None
if isinstance(entry, (list, tuple)):
for item in entry:
meta = _find_tweet_meta(item)
if meta is not None:
return meta
return None
def _media_url_from_entry(entry: Any) -> str:
if isinstance(entry, (list, tuple)):
for item in entry:
if isinstance(item, str) and ("pbs.twimg.com" in item or "video.twimg.com" in item):
return item
return ""
def _post_from_meta(meta: dict[str, Any]) -> PostData:
author = meta.get("author") or {}
if isinstance(author, dict):
author_name = str(author.get("nick") or author.get("name") or "")
handle = str(author.get("name") or author.get("nick") or "")
else:
author_name = str(author)
handle = str(author)
view = meta.get("view_count")
metrics = PostMetrics(
reply_count=int(meta.get("reply_count") or 0),
repost_count=int(meta.get("retweet_count") or 0),
like_count=int(meta.get("favorite_count") or 0),
view_count=int(view) if view is not None else None,
)
return PostData(
post_id=str(meta.get("tweet_id") or meta.get("id") or ""),
author=author_name,
handle=handle,
text=str(meta.get("content") or ""),
timestamp=str(meta.get("date") or ""),
media_urls=(),
reply_to_id=str(meta["reply_id"]) if meta.get("reply_id") else None,
quote_of_id=str(meta["quote_id"]) if meta.get("quote_id") else None,
metrics=metrics,
)
def _posts_from_gallery_dl(data: Any) -> list[PostData]:
by_id: dict[str, PostData] = {}
media_by_id: dict[str, list[str]] = {}
order: list[str] = []
entries = data if isinstance(data, list) else []
for entry in entries:
meta = _find_tweet_meta(entry)
if meta is None:
continue
tid = str(meta.get("tweet_id") or meta.get("id") or "")
if not tid:
continue
if tid not in by_id:
by_id[tid] = _post_from_meta(meta)
media_by_id[tid] = []
order.append(tid)
media_url = _media_url_from_entry(entry)
if media_url:
media_by_id[tid].append(media_url)
posts: list[PostData] = []
for tid in order:
base = by_id[tid]
posts.append(PostData(
post_id=base.post_id,
author=base.author,
handle=base.handle,
text=base.text,
timestamp=base.timestamp,
media_urls=tuple(media_by_id[tid]),
reply_to_id=base.reply_to_id,
quote_of_id=base.quote_of_id,
metrics=base.metrics,
))
return posts
def fetch_thread_from_url(url: str, cookies_path: Path | None = None) -> Result[ThreadData]:
clean = _normalize_url(url)
args = ["gallery-dl", "--dump-json", "-o", "text-tweets=true", "-o", "conversations=true"]
if cookies_path is not None:
args += ["--cookies", str(cookies_path)]
args.append(clean)
try:
completed = subprocess.run(args, capture_output=True, text=True)
except OSError as e:
return Result.err(make_error("GalleryDlError", "fetch_thread_from_url", str(e)))
if completed.returncode != 0:
return Result.err(make_error("GalleryDlError", "fetch_thread_from_url", completed.stderr[:500]))
try:
data = json.loads(completed.stdout)
except json.JSONDecodeError as e:
return Result.err(make_error("JsonParseError", "fetch_thread_from_url", str(e)))
posts = _posts_from_gallery_dl(data)
if not posts:
return Result.err(make_error("ParseError", "fetch_thread_from_url", "no tweet metadata in gallery-dl output"))
posts.sort(key=lambda p: int(p.post_id) if p.post_id.isdigit() else 0)
target_id = clean.rstrip("/").split("/")[-1]
root_id = target_id if target_id.isdigit() else posts[0].post_id
return Result.ok(ThreadData(root_post_id=root_id, posts=tuple(posts), source_url=clean))
def thread_to_dict(thread: ThreadData) -> dict[str, Any]:
return asdict(thread)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Fetch a Twitter/X thread into a ThreadData JSON.")
parser.add_argument("source", help="X.com URL or local HTML file path")
parser.add_argument("--output", type=Path, required=True, help="Output directory")
parser.add_argument("--cookies", type=Path, default=None, help="cookies.txt for gallery-dl auth")
args = parser.parse_args(argv)
if args.source.startswith("http://") or args.source.startswith("https://"):
result = fetch_thread_from_url(args.source, args.cookies)
else:
result = fetch_thread_from_html(Path(args.source))
if not result.is_ok:
sys.stderr.write(f"error: {result.error.kind}: {result.error.detail}" + "`r`n")
return 1
thread = result.data
out_dir = args.output / thread.root_post_id
try:
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / "thread_data.json"
out_path.write_text(json.dumps(thread_to_dict(thread), indent=2, ensure_ascii=False), encoding="utf-8")
except OSError as e:
sys.stderr.write(f"error: WriteError: {e}" + "`r`n")
return 1
print(str(out_path))
return 0
if __name__ == "__main__":
sys.exit(main())