Private
Public Access
0
0

Merge remote-tracking branch 'tier2-clone/tier2/twitter_threads_extraction_20260705'

This commit is contained in:
2026-07-05 19:34:31 -04:00
58 changed files with 2789 additions and 70 deletions
+132
View File
@@ -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).
+15
View File
@@ -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.
"""
+63
View File
@@ -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())
+80
View File
@@ -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", ""))
+264
View File
@@ -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())
+125
View File
@@ -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"![Media {index}](./media/{name})"
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())