Merge remote-tracking branch 'tier2-clone/tier2/twitter_threads_extraction_20260705'
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# twitter_threads — standalone Twitter/X thread extraction tooling
|
||||
|
||||
Acquire Twitter/X posts + threads + their media and emit a self-contained Markdown
|
||||
document per thread (a `thread.md` plus a sibling `media/` directory). The output is
|
||||
archivable, readable offline, and consumable by downstream analysis pipelines without
|
||||
any dependency on the live X.com service.
|
||||
|
||||
This package is the Twitter/X analog of `scripts/video_analysis/`: a small,
|
||||
dependency-light pipeline that acquires source content and emits Markdown. It is
|
||||
**standalone** — it imports nothing from `src/` or `conductor/` and can be copied to
|
||||
another repo with only `gallery-dl` as an external dependency.
|
||||
|
||||
## Modules
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `fetch_thread.py` | Acquire a post/thread into a `ThreadData`. URL path wraps `gallery-dl` (subprocess); local-HTML path uses stdlib `html.parser`. Also the pipeline entry CLI (writes `thread_data.json`). |
|
||||
| `download_media.py` | Download the images/videos/GIFs referenced by a `ThreadData` via stdlib `urllib` into `media/`. Idempotent (skips existing files). |
|
||||
| `render_markdown.py` | Emit the Markdown document (YAML front-matter + per-post sections + `./media/` links) from a `ThreadData`. |
|
||||
| `error_types.py` | Shared `Result[T]` + `ErrorInfo` (data-oriented error handling) and the `ThreadData` / `PostData` / `PostMetrics` dataclasses. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.11+**
|
||||
- **`gallery-dl`** for the URL acquisition path: `pip install gallery-dl`
|
||||
- **`cookies.txt`** exported from your browser (for auth-gated content). Export via a
|
||||
browser extension (e.g. "Get cookies.txt") while logged in to x.com.
|
||||
|
||||
The script internals use **stdlib only** (`urllib`, `html.parser`, `json`, `pathlib`,
|
||||
`dataclasses`, `subprocess`). `gallery-dl` is invoked as a subprocess, never imported.
|
||||
|
||||
## Usage
|
||||
|
||||
Run each stage in order. From this repo (module form):
|
||||
|
||||
```bash
|
||||
# 1. Fetch the thread -> writes <output>/<post_id>/thread_data.json
|
||||
uv run python -m scripts.twitter_threads.fetch_thread \
|
||||
"https://x.com/NOTimothyLottes/status/1757198624818168210" \
|
||||
--output ./thread_output/ --cookies ./cookies.txt
|
||||
|
||||
# 2. Download the media -> writes <post_id>/media/<post_id>_img1.jpg etc.
|
||||
uv run python -m scripts.twitter_threads.download_media \
|
||||
--input ./thread_output/1757198624818168210/thread_data.json \
|
||||
--output ./thread_output/1757198624818168210/media/
|
||||
|
||||
# 3. Render Markdown -> writes thread.md linking ./media/<file>
|
||||
uv run python -m scripts.twitter_threads.render_markdown \
|
||||
--input ./thread_output/1757198624818168210/thread_data.json \
|
||||
--media-dir ./thread_output/1757198624818168210/media/ \
|
||||
--output ./thread_output/1757198624818168210/thread.md
|
||||
```
|
||||
|
||||
Standalone (after copying the directory to another repo — run the files directly):
|
||||
|
||||
```bash
|
||||
python fetch_thread.py "https://x.com/<user>/status/<id>" --output ./out/ --cookies ./cookies.txt
|
||||
python download_media.py --input ./out/<id>/thread_data.json --output ./out/<id>/media/
|
||||
python render_markdown.py --input ./out/<id>/thread_data.json --media-dir ./out/<id>/media/ --output ./out/<id>/thread.md
|
||||
```
|
||||
|
||||
Each module also supports `--help`.
|
||||
|
||||
### Local-HTML fallback (no network / gallery-dl broken)
|
||||
|
||||
If `gallery-dl` fails (auth wall, rate limit, X.com breakage), save the thread page
|
||||
from your browser (Ctrl+S, "Webpage, Complete") and pass the local `.html` file to
|
||||
`fetch_thread.py` instead of a URL:
|
||||
|
||||
```bash
|
||||
python fetch_thread.py ./saved_thread.html --output ./out/
|
||||
```
|
||||
|
||||
The parser reads `<article>` elements carrying `data-post-id` / `data-author` /
|
||||
`data-handle` / `data-reply-to` / `data-quote-of` / metric attributes, a
|
||||
`<time datetime>` element, a `<div data-testid="tweetText">` and `<img>`/`<source>`
|
||||
media elements.
|
||||
|
||||
## Output layout
|
||||
|
||||
```
|
||||
thread_output/
|
||||
<post_id>/
|
||||
thread_data.json # intermediate ThreadData (JSON)
|
||||
thread.md # the rendered document
|
||||
media/
|
||||
<post_id>_img1.jpg
|
||||
<post_id>_vid1.mp4
|
||||
```
|
||||
|
||||
## Output Markdown schema
|
||||
|
||||
A YAML front-matter block (author, handle, post_url, post_id, timestamp, post_count,
|
||||
reply/repost/like/view counts — `view_count: null` when unavailable), followed by an
|
||||
`# @<handle> — <title>` heading and one `## Post N (<timestamp>)` section per post (in
|
||||
chronological order, with `— reply to Post M` markers on replies). Media are linked via
|
||||
relative `./media/<filename>` paths. Quote-tweets render as a `>` blockquote linking the
|
||||
quoted post.
|
||||
|
||||
## URL normalization
|
||||
|
||||
Quote-share URLs carry a `?s=20` (or similar) suffix that is quote-share context, not
|
||||
part of the post identity. `fetch_thread.py` strips the query string + fragment before
|
||||
acquisition.
|
||||
|
||||
## Acquisition strategies (and their tradeoffs)
|
||||
|
||||
- **Strategy A — `gallery-dl` (default, implemented).** Mature, actively maintained;
|
||||
handles auth via `--cookies cookies.txt`. Wrapped as a subprocess.
|
||||
- **Strategy B — `snscrape` (legacy fallback, not bundled).** Was the standard pre-2023
|
||||
tool; increasingly broken against X.com. Usable for some older content; not a
|
||||
dependency here.
|
||||
- **Strategy C — local HTML parse (implemented, no-network fallback).** Save the page in
|
||||
the browser; parse it with stdlib `html.parser`. Most resilient when `gallery-dl`
|
||||
breaks.
|
||||
- **Strategy D — `tweepy` / Twitter API v2 (paid, not implemented).** If you have
|
||||
developer credentials, swap the acquisition backend in `fetch_thread_from_url`. Not
|
||||
the default because it requires paid API access.
|
||||
|
||||
## Copy to another repo
|
||||
|
||||
Copy the entire `scripts/twitter_threads/` directory. The only external dependency is
|
||||
`gallery-dl` (`pip install gallery-dl`). The modules use a dual-import so they run both
|
||||
as a package (`python -m ...`) and as bare scripts (`python fetch_thread.py ...`). There
|
||||
are **no imports from `src/`, `conductor/`, or `scripts.video_analysis`** — the package
|
||||
is self-contained.
|
||||
|
||||
## Conventions
|
||||
|
||||
Per `conductor/code_styleguides/python.md`: 1-space indentation, type hints, no inline
|
||||
comments. Per `conductor/code_styleguides/error_handling.md`: functions return
|
||||
`Result[T]` with `ErrorInfo` as data (no exceptions across the public API).
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Twitter/X thread extraction reusable tooling.
|
||||
|
||||
Standalone scripts that acquire posts, threads, and media from X (Twitter)
|
||||
and emit self-contained Markdown documents.
|
||||
|
||||
Scripts in this namespace:
|
||||
- fetch_thread.py: acquire a post/thread (gallery-dl subprocess for URLs; html.parser for local HTML)
|
||||
- download_media.py: download referenced media via stdlib urllib
|
||||
- render_markdown.py: emit the Markdown document + media links
|
||||
- error_types.py: shared Result-style ErrorInfo + the ThreadData/PostData/PostMetrics dataclasses
|
||||
|
||||
Standalone: no imports from src/ or conductor/; copy-pasteable to another repo with only gallery-dl as an external dep.
|
||||
|
||||
Per conductor/code_styleguides/python.md, 1-space indent + type hints + no comments in implementation code.
|
||||
"""
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Download a Twitter/X thread's media via gallery-dl (handles auth, 403, and video/GIF -> mp4)."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
try:
|
||||
from scripts.twitter_threads.error_types import ErrorInfo, make_error, Result, ThreadData, thread_from_dict
|
||||
except ImportError:
|
||||
from error_types import ErrorInfo, make_error, Result, ThreadData, thread_from_dict
|
||||
|
||||
|
||||
|
||||
def download_media(thread: ThreadData, output_dir: Path, cookies_path: Path | None = None) -> Result[list[Path]]:
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
return Result.err(make_error("WriteError", "download_media", str(e)))
|
||||
args = ["gallery-dl", "-o", "text-tweets=true", "-o", "conversations=true", "-D", str(output_dir)]
|
||||
if cookies_path is not None:
|
||||
args += ["--cookies", str(cookies_path)]
|
||||
args.append(thread.source_url)
|
||||
try:
|
||||
completed = subprocess.run(args, capture_output=True, text=True)
|
||||
except OSError as e:
|
||||
return Result.err(make_error("GalleryDlError", "download_media", str(e)))
|
||||
if completed.returncode != 0:
|
||||
return Result.err(make_error("GalleryDlError", "download_media", completed.stderr[:500]))
|
||||
post_ids = {p.post_id for p in thread.posts}
|
||||
files = [f for f in output_dir.rglob("*") if f.is_file() and f.name.split("_")[0] in post_ids]
|
||||
return Result.ok(sorted(files, key=lambda f: f.name))
|
||||
|
||||
def media_files_for_post(output_dir: Path, post_id: str) -> list[str]:
|
||||
return sorted(f.name for f in output_dir.glob(f"{post_id}_*") if f.is_file())
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Download media for a Twitter/X thread_data.json via gallery-dl.")
|
||||
parser.add_argument("--input", type=Path, required=True, help="thread_data.json path")
|
||||
parser.add_argument("--output", type=Path, required=True, help="media output directory")
|
||||
parser.add_argument("--cookies", type=Path, default=None, help="cookies.txt for gallery-dl auth")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
raw = args.input.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"error: ReadError: {e}\n")
|
||||
return 1
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
sys.stderr.write(f"error: JsonParseError: {e}\n")
|
||||
return 1
|
||||
thread = thread_from_dict(data)
|
||||
result = download_media(thread, args.output, args.cookies)
|
||||
if not result.is_ok:
|
||||
sys.stderr.write(f"error: {result.error.kind}: {result.error.detail}\n")
|
||||
return 1
|
||||
print(f"downloaded {len(result.data)} file(s) to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Shared dataclasses for the scripts.twitter_threads namespace."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ErrorInfo:
|
||||
kind: str
|
||||
source: str
|
||||
detail: str
|
||||
|
||||
def make_error(kind: str, source: str, detail: str) -> ErrorInfo:
|
||||
return ErrorInfo(kind=kind, source=source, detail=detail)
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostMetrics:
|
||||
reply_count: int
|
||||
repost_count: int
|
||||
like_count: int
|
||||
view_count: int | None
|
||||
|
||||
@dataclass(frozen=True, slots=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, slots=True)
|
||||
class ThreadData:
|
||||
root_post_id: str
|
||||
posts: tuple[PostData, ...]
|
||||
source_url: str
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Result(Generic[T]):
|
||||
data: T | None = None
|
||||
error: ErrorInfo | None = None
|
||||
|
||||
@property
|
||||
def is_ok(self) -> bool:
|
||||
return self.error is None
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: T) -> "Result[T]":
|
||||
return cls(data=data, error=None)
|
||||
|
||||
@classmethod
|
||||
def err(cls, error: ErrorInfo) -> "Result[T]":
|
||||
return cls(data=None, error=error)
|
||||
|
||||
def thread_from_dict(d: dict[str, Any]) -> ThreadData:
|
||||
posts: list[PostData] = []
|
||||
for pd in d.get("posts", []):
|
||||
m = pd.get("metrics", {})
|
||||
metrics = PostMetrics(
|
||||
reply_count=int(m.get("reply_count", 0)),
|
||||
repost_count=int(m.get("repost_count", 0)),
|
||||
like_count=int(m.get("like_count", 0)),
|
||||
view_count=m.get("view_count"),
|
||||
)
|
||||
posts.append(PostData(
|
||||
post_id=pd.get("post_id", ""),
|
||||
author=pd.get("author", ""),
|
||||
handle=pd.get("handle", ""),
|
||||
text=pd.get("text", ""),
|
||||
timestamp=pd.get("timestamp", ""),
|
||||
media_urls=tuple(pd.get("media_urls", [])),
|
||||
reply_to_id=pd.get("reply_to_id"),
|
||||
quote_of_id=pd.get("quote_of_id"),
|
||||
metrics=metrics,
|
||||
))
|
||||
return ThreadData(root_post_id=d.get("root_post_id", ""), posts=tuple(posts), source_url=d.get("source_url", ""))
|
||||
@@ -0,0 +1,264 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Render ThreadData into a Markdown file with YAML front-matter."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
try:
|
||||
from scripts.twitter_threads.error_types import ErrorInfo, make_error, Result, ThreadData, PostData, thread_from_dict
|
||||
except ImportError:
|
||||
from error_types import ErrorInfo, make_error, Result, ThreadData, PostData, thread_from_dict
|
||||
|
||||
_EM_DASH = "\u2014"
|
||||
|
||||
def _build_id_to_index(thread: ThreadData) -> dict[str, int]:
|
||||
return {post.post_id: i + 1 for i, post in enumerate(thread.posts)}
|
||||
|
||||
def _format_view_count(value: int | None) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
return str(value)
|
||||
|
||||
def _root_post(thread: ThreadData) -> PostData:
|
||||
for p in thread.posts:
|
||||
if p.post_id == thread.root_post_id:
|
||||
return p
|
||||
return thread.posts[0]
|
||||
|
||||
def _render_yaml(thread: ThreadData, title: str) -> list[str]:
|
||||
root = _root_post(thread)
|
||||
return [
|
||||
"---",
|
||||
f'title: "{title}"',
|
||||
f'author: "{root.author}"',
|
||||
f'handle: "@{root.handle}"',
|
||||
f'post_url: "{thread.source_url}"',
|
||||
f'post_id: "{thread.root_post_id}"',
|
||||
f'timestamp: "{root.timestamp}"',
|
||||
f'post_count: {len(thread.posts)}',
|
||||
f'reply_count: {root.metrics.reply_count}',
|
||||
f'repost_count: {root.metrics.repost_count}',
|
||||
f'like_count: {root.metrics.like_count}',
|
||||
f'view_count: {_format_view_count(root.metrics.view_count)}',
|
||||
"---",
|
||||
]
|
||||
|
||||
def _media_line(index: int, name: str) -> str:
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||
if ext in ("mp4", "mov", "webm", "m4v"):
|
||||
return f'<video controls src="./media/{name}"></video>'
|
||||
if ext in ("jpg", "jpeg", "png", "gif", "webp", "bmp"):
|
||||
return f""
|
||||
return f"[Media {index}](./media/{name})"
|
||||
|
||||
def _render_post(post: PostData, n: int, id_to_index: dict[str, int], media_names: dict[str, list[str]]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
header = f"## Post {n} ({post.timestamp})"
|
||||
if post.reply_to_id is not None and post.reply_to_id in id_to_index:
|
||||
header += f" {_EM_DASH} reply to Post {id_to_index[post.reply_to_id]}"
|
||||
lines.append(header)
|
||||
lines.append("")
|
||||
lines.append(post.text)
|
||||
lines.append("")
|
||||
if post.quote_of_id is not None:
|
||||
lines.append(f"> [Quoting post {post.quote_of_id}](https://x.com/i/web/status/{post.quote_of_id})")
|
||||
lines.append("")
|
||||
names = media_names.get(post.post_id, [])
|
||||
if names:
|
||||
for k, name in enumerate(names, start=1):
|
||||
lines.append(_media_line(k, name))
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
def render_markdown(thread: ThreadData, media_names: dict[str, list[str]], output: Path) -> Result[Path]:
|
||||
lines: list[str] = []
|
||||
id_to_index = _build_id_to_index(thread)
|
||||
root = _root_post(thread)
|
||||
first_line = root.text.splitlines()[0] if root.text.splitlines() else ""
|
||||
title = first_line[:80]
|
||||
lines.extend(_render_yaml(thread, title))
|
||||
lines.append("")
|
||||
lines.append(f"# @{root.handle} {_EM_DASH} {title}")
|
||||
lines.append("")
|
||||
for i, post in enumerate(thread.posts):
|
||||
n = i + 1
|
||||
lines.extend(_render_post(post, n, id_to_index, media_names))
|
||||
content = "\n".join(lines)
|
||||
try:
|
||||
output.write_text(content, encoding="utf-8")
|
||||
except OSError as e:
|
||||
return Result.err(make_error("WriteError", "render_markdown", str(e)))
|
||||
return Result.ok(output)
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Render a Twitter/X thread_data.json to Markdown.")
|
||||
parser.add_argument("--input", type=Path, required=True, help="thread_data.json path")
|
||||
parser.add_argument("--media-dir", type=Path, required=True, help="media directory to link")
|
||||
parser.add_argument("--output", type=Path, required=True, help="output .md path")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
raw = args.input.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
sys.stderr.write(f"error: ReadError: {e}\n")
|
||||
return 1
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
sys.stderr.write(f"error: JsonParseError: {e}\n")
|
||||
return 1
|
||||
thread = thread_from_dict(data)
|
||||
media_names: dict[str, list[str]] = {}
|
||||
for post in thread.posts:
|
||||
if args.media_dir.exists():
|
||||
files = sorted(f.name for f in args.media_dir.glob(f"{post.post_id}_*"))
|
||||
if files:
|
||||
media_names[post.post_id] = files
|
||||
result = render_markdown(thread, media_names, args.output)
|
||||
if not result.is_ok:
|
||||
sys.stderr.write(f"error: {result.error.kind}: {result.error.detail}\n")
|
||||
return 1
|
||||
print(str(args.output))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user