Files
Threadwell/scripts/twitter/walker.py
T
2026-11-14 19:48:11 -05:00

548 lines
20 KiB
Python

"""Recursive cursor walker for X conversation threads.
``gallery-dl`` with ``conversations=true`` follows only the top-level
``cursor-bottom-*`` continuation. Replies reachable only through a
``cursor-showmore-*`` nested inside a ``conversationthread-*`` module are
dropped without warning. This module issues the same ``TweetDetail``
request gallery-dl issues, then follows **every** cursor it finds at any
depth.
The request shape is read from the installed gallery-dl at runtime
rather than hardcoded, so a gallery-dl upgrade is picked up without a
code change. When gallery-dl does not expose the endpoint, this module
raises instead of falling back to a stale literal.
Nothing here writes to the vault.
"""
from __future__ import annotations
import ast
import inspect
import json
import logging
import re
import textwrap
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
from .paths import COOKIES
except ImportError: # pragma: no cover - script-mode import
from paths import COOKIES
logger = logging.getLogger(__name__)
# gallery-dl is imported lazily: only the walker needs it, and importing
# it costs ~100 ms at module import.
_gallery_dl: Any = None
# Marker standing in for gallery-dl's ``tweet_id`` argument in the
# variables template it reads from its own source.
FOCAL_TWEET_ID = "__focal_tweet_id__"
class WalkerError(RuntimeError):
"""Raised when the walker cannot build or run a request."""
@dataclass(frozen=True)
class WalkerResult:
"""What one walk found."""
post_ids: tuple[str, ...]
calls: int
per_focal: dict[str, tuple[str, ...]]
stopped_reason: str
@dataclass(frozen=True)
class _Page:
"""One TweetDetail response, reduced to what the walker needs."""
post_ids: tuple[str, ...]
conversation_ids: tuple[str, ...]
reply_counts: tuple[int, ...]
reply_to_ids: tuple[str | None, ...]
cursors: tuple[str, ...]
top_level_cursor: str | None
def _load_gallery_dl() -> Any:
"""Import gallery-dl's Twitter extractor module."""
global _gallery_dl
if _gallery_dl is None:
try:
from gallery_dl.extractor import twitter as gd_twitter
except ImportError as exc: # pragma: no cover - optional dependency
raise WalkerError(
"gallery-dl is required by the walker: pip install gallery-dl"
) from exc
_gallery_dl = gd_twitter
return _gallery_dl
def tweet_detail_endpoint() -> str:
"""Return the TweetDetail GraphQL endpoint from the installed gallery-dl.
Raises ``WalkerError`` when gallery-dl does not expose it. The
endpoint carries an operation hash that upstream rotates, so there is
deliberately no hardcoded fallback.
"""
twitter = _load_gallery_dl()
try:
constants = twitter.TwitterAPI.tweet_detail.__code__.co_consts
except AttributeError as exc: # pragma: no cover - upstream drift
raise WalkerError(
"gallery-dl no longer exposes TwitterAPI.tweet_detail"
) from exc
for value in constants:
if isinstance(value, str) and value.endswith("/TweetDetail"):
return value
raise WalkerError("gallery-dl does not expose a TweetDetail endpoint")
def _eval_literal(node: ast.AST) -> Any:
"""Evaluate a literal AST node, tolerating a bare name for the tweet id.
gallery-dl writes ``"focalTweetId": tweet_id`` where ``tweet_id`` is
the method argument. That name is replaced with the
``FOCAL_TWEET_ID`` marker and filled in per request.
"""
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.Name):
return FOCAL_TWEET_ID
if isinstance(node, ast.Dict):
return {
_eval_literal(key): _eval_literal(value)
for key, value in zip(node.keys, node.values)
if key is not None
}
if isinstance(node, ast.List):
return [_eval_literal(element) for element in node.elts]
if isinstance(node, ast.Tuple):
return tuple(_eval_literal(element) for element in node.elts)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval_literal(node.operand)
raise WalkerError(f"gallery-dl uses a non-literal expression: {type(node).__name__}")
def _literal_from_source(source: str, name: str) -> dict[str, Any]:
"""Extract a dict literal assigned to ``name`` from gallery-dl source.
The variables template and field toggles are literals in
``TwitterAPI.tweet_detail``. Reading them from the installed source
keeps the walker in step with gallery-dl instead of freezing a copy.
"""
tree = ast.parse(textwrap.dedent(source))
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id == name:
value = _eval_literal(node.value)
if isinstance(value, dict):
return value
raise WalkerError(f"gallery-dl does not define a {name} literal")
def request_shape() -> tuple[dict[str, Any], dict[str, Any]]:
"""Return the ``(variables, field_toggles)`` pair gallery-dl sends."""
twitter = _load_gallery_dl()
source = inspect.getsource(twitter.TwitterAPI.tweet_detail)
variables = _literal_from_source(source, "variables")
toggles = _literal_from_source(source, "field_toggles")
return variables, toggles
def build_extractor() -> Any:
"""Build a gallery-dl extractor that is authenticated with the vault cookies.
Cookies, the CSRF token, and the transaction id all come from
gallery-dl's own request layer, so the walker speaks the same
protocol gallery-dl does.
"""
twitter = _load_gallery_dl()
url = "https://x.com/i/web/status/1"
extractor = twitter.TwitterTweetExtractor.from_url(url)
if extractor is None:
raise WalkerError("gallery-dl could not build a Twitter tweet extractor")
extractor.initialize()
extractor.cookies_load(str(COOKIES))
csrf = extractor.cookies.get("ct0", domain=".x.com")
if csrf:
extractor.api.headers["x-csrf-token"] = csrf
extractor.api.headers["x-twitter-auth-type"] = "OAuth2Session"
extractor.login()
return extractor
def _cursor_value(entry: dict[str, Any]) -> str | None:
"""Extract a continuation cursor value from an entry.
Twitter uses several shapes for the same thing: a top-level cursor
entry, a cursor nested in a thread module, and a legacy
``operation.cursor`` wrapper. Cursor values are opaque and are never
logged.
"""
for wrapper_key in ("content", "item"):
wrapper = entry.get(wrapper_key)
if not isinstance(wrapper, dict):
continue
item_content = wrapper.get("itemContent")
if isinstance(item_content, dict):
value = item_content.get("value")
if isinstance(value, str) and value:
return value
value = wrapper.get("value")
if isinstance(value, str) and value:
return value
operation = wrapper.get("operation")
if isinstance(operation, dict):
cursor = operation.get("cursor")
if isinstance(cursor, dict):
value = cursor.get("value")
if isinstance(value, str) and value:
return value
return None
_CURSOR_ENTRY = re.compile(r"cursor-(bottom|top|showmore)")
def _is_cursor_entry(entry_id: str) -> bool:
"""True for any continuation cursor entry, at any depth."""
if not entry_id:
return False
return bool(_CURSOR_ENTRY.search(entry_id))
def _tweet_ids(node: Any, found: list[tuple[str, str, int, str | None]]) -> None:
"""Collect ``(post_id, conversation_id, reply_count, reply_to)`` tuples."""
if isinstance(node, dict):
result = node.get("result")
if isinstance(result, dict) and result.get("__typename") == "Tweet":
legacy = result.get("legacy")
legacy = legacy if isinstance(legacy, dict) else {}
tweet_id = str(legacy.get("id_str") or result.get("rest_id") or "")
if tweet_id:
conversation = str(legacy.get("conversation_id_str") or "")
reply_count = int(legacy.get("reply_count") or 0)
reply_to = legacy.get("in_reply_to_status_id_str")
entry = (
tweet_id,
conversation,
reply_count,
str(reply_to) if reply_to else None,
)
if entry not in found:
found.append(entry)
for value in node.values():
_tweet_ids(value, found)
elif isinstance(node, list):
for value in node:
_tweet_ids(value, found)
_THREAD_MODULE_PREFIXES = (
"homeConversation-",
"profile-conversation-",
"conversationthread-",
)
def _is_thread_module(entry_id: str) -> bool:
"""True for a thread module entry, false for its sub-entries.
A module entry id is a known prefix followed by digits only. Its
sub-entries share the prefix but carry a ``-tweet-`` or
``-cursor-`` suffix.
"""
if not entry_id.startswith(_THREAD_MODULE_PREFIXES):
return False
suffix = entry_id.split("-", 1)[1] if "-" in entry_id else ""
return suffix.isdigit()
def parse_page(body: dict[str, Any]) -> _Page:
"""Reduce a TweetDetail response to its post ids and its cursors.
Every cursor in the tree is collected, including the ones nested
inside thread modules and ``ShowMoreThreadsPrompt`` entries. The
top-level ``cursor-bottom-*`` is reported separately because that is
the only one gallery-dl follows.
"""
post_ids: list[tuple[str, str]] = []
cursors: list[str] = []
top_level: str | None = None
def add_cursor(entry: dict[str, Any], top_level_entry: bool) -> None:
nonlocal top_level
entry_id = entry.get("entryId")
if not isinstance(entry_id, str) or not _is_cursor_entry(entry_id):
return
value = _cursor_value(entry)
if not value:
return
if value not in cursors:
cursors.append(value)
if top_level_entry and entry_id.startswith("cursor-bottom-"):
if top_level is None:
top_level = value
def walk_entries(entries: Any, depth: int) -> None:
if not isinstance(entries, list):
return
for entry in entries:
if not isinstance(entry, dict):
continue
entry_id = entry.get("entryId")
entry_id = entry_id if isinstance(entry_id, str) else ""
if _is_cursor_entry(entry_id):
add_cursor(entry, depth == 0)
continue
if _is_thread_module(entry_id):
content = entry.get("content")
items = content.get("items") if isinstance(content, dict) else None
walk_entries(items, depth + 1)
_find_cursors(content, add_cursor)
continue
if "showmore" in entry_id.lower():
content = entry.get("content")
items = content.get("items") if isinstance(content, dict) else None
_find_cursors(items, add_cursor)
walk_entries(items, depth + 1)
_find_cursors(entry, add_cursor)
_tweet_ids(entry, post_ids)
def walk_instructions(instructions: Any) -> None:
if not isinstance(instructions, list):
return
for instruction in instructions:
if not isinstance(instruction, dict):
continue
kind = instruction.get("type")
if kind == "TimelineAddEntries":
walk_entries(instruction.get("entries"), 0)
elif kind == "TimelineAddToModule":
walk_entries(instruction.get("moduleItems"), 0)
elif kind in ("TimelineReplaceEntry", "TimelinePinEntry"):
entry = instruction.get("entry")
if isinstance(entry, dict):
add_cursor(entry, True)
_find_cursors(entry, add_cursor)
_tweet_ids(entry, post_ids)
data = body.get("data")
data = data if isinstance(data, dict) else {}
conversation = data.get("threaded_conversation_with_injections_v2")
conversation = conversation if isinstance(conversation, dict) else {}
walk_instructions(conversation.get("instructions"))
return _Page(
post_ids=tuple(entry[0] for entry in post_ids),
conversation_ids=tuple(entry[1] for entry in post_ids),
reply_counts=tuple(entry[2] for entry in post_ids),
reply_to_ids=tuple(entry[3] for entry in post_ids),
cursors=tuple(cursors),
top_level_cursor=top_level,
)
def _find_cursors(node: Any, add_cursor: Any) -> None:
"""Find every cursor anywhere below ``node``.
This is the part gallery-dl skips: cursors inside a thread module
or a ``ShowMoreThreadsPrompt`` are never followed.
"""
if isinstance(node, dict):
entry_id = node.get("entryId")
if isinstance(entry_id, str) and _is_cursor_entry(entry_id):
add_cursor(node, False)
for value in node.values():
_find_cursors(value, add_cursor)
elif isinstance(node, list):
for value in node:
_find_cursors(value, add_cursor)
def _request(
extractor: Any,
focal_id: str,
cursor: str | None,
variables: dict[str, Any],
toggles: dict[str, Any],
) -> dict[str, Any]:
"""Issue one TweetDetail request through gallery-dl's request layer."""
request_variables = dict(variables)
request_variables["focalTweetId"] = focal_id
if cursor:
request_variables["cursor"] = cursor
params = {
"variables": json.dumps(request_variables, separators=(",", ":"), ensure_ascii=False),
"features": json.dumps(
extractor.api.features_pagination, separators=(",", ":"), ensure_ascii=False
),
"fieldToggles": json.dumps(toggles, separators=(",", ":"), ensure_ascii=False),
}
body = extractor.api._call(tweet_detail_endpoint(), params, method="GET")
if not isinstance(body, dict):
raise WalkerError("TweetDetail returned a non-object body")
return body
def _walk_focal(
extractor: Any,
focal_id: str,
request_cap: int,
variables: dict[str, Any],
toggles: dict[str, Any],
capture_dir: Path | None = None,
) -> tuple[set[str], dict[str, int], dict[str, str | None], int, str]:
"""Walk every cursor under one focal.
Returns ``(post_ids, reply_counts, reply_to_ids, calls, reason)``.
"""
post_ids: set[str] = set()
reply_counts: dict[str, int] = {}
reply_to: dict[str, str | None] = {}
seen_cursors: set[str] = set()
pending: deque[str | None] = deque([None])
calls = 0
conversation: str | None = None
while pending and calls < request_cap:
cursor = pending.popleft()
calls += 1
body = _request(extractor, focal_id, cursor, variables, toggles)
if capture_dir is not None:
_capture_page(capture_dir, calls, body)
page = parse_page(body)
if conversation is None:
conversation = _focal_conversation(page, focal_id)
for index, tweet_id in enumerate(page.post_ids):
if page.conversation_ids[index] != conversation:
continue
post_ids.add(tweet_id)
reply_counts[tweet_id] = page.reply_counts[index]
reply_to[tweet_id] = page.reply_to_ids[index]
for value in page.cursors:
if value in seen_cursors:
continue
seen_cursors.add(value)
pending.append(value)
reason = "request-cap" if pending else "exhausted"
return post_ids, reply_counts, reply_to, calls, reason
def _capture_page(capture_dir: Path, ordinal: int, body: dict[str, Any]) -> None:
"""Write one response page so a run can be inspected afterwards.
The page holds tweet text and cursor values. It stays in scratch and
is never written to the vault.
"""
capture_dir.mkdir(parents=True, exist_ok=True)
target = capture_dir / f"page-{ordinal:03d}.json"
target.write_text(
json.dumps(body, indent=1, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def _focal_conversation(page: _Page, focal_id: str) -> str:
"""Return the conversation id the focal's replies share.
The focal's own conversation id is the one its replies carry. When
the focal is not in the first page, the page's modal conversation
id is used.
"""
pairs = list(zip(page.post_ids, page.conversation_ids))
for tweet_id, conversation in pairs:
if tweet_id == focal_id and conversation:
return conversation
counts: dict[str, int] = {}
for _tweet_id, conversation in pairs:
if conversation:
counts[conversation] = counts.get(conversation, 0) + 1
if not counts:
return ""
return max(counts.items(), key=lambda item: item[1])[0]
def _under_captured(
reply_counts: dict[str, int],
reply_to: dict[str, str | None],
) -> list[str]:
"""Return posts whose reply count exceeds the children actually seen.
A post that reports more replies than the walk found is a post with
a subtree the walk has not reached yet. Walking it is how the walk
expands without the operator naming every focal by hand.
"""
children: dict[str, int] = {}
for parent in reply_to.values():
if parent:
children[parent] = children.get(parent, 0) + 1
return [
post_id
for post_id, count in reply_counts.items()
if count > children.get(post_id, 0)
]
def discover(
focal_ids: tuple[str, ...] | list[str],
request_cap: int = 20,
extractor: Any | None = None,
max_focals: int = 40,
capture_dir: Path | None = None,
) -> WalkerResult:
"""Walk each focal's conversation and return every post id found.
The walk expands its own focal set: a post that reports more
replies than were found has a subtree not yet reached, so it is
walked too. That is what lets one focal find the whole thread.
``request_cap`` bounds the requests issued per focal.
``max_focals`` bounds how many focals the expansion may add, so a
conversation that keeps reporting unreached children cannot run
away. Requests are sequential. No cursor value is logged or
persisted.
"""
if not focal_ids:
return WalkerResult(post_ids=(), calls=0, per_focal={}, stopped_reason="no-focals")
variables, toggles = request_shape()
if extractor is None:
extractor = build_extractor()
all_posts: set[str] = set()
per_focal: dict[str, tuple[str, ...]] = {}
calls = 0
stopped = "exhausted"
walked: set[str] = set()
queue = list(dict.fromkeys(focal_ids))
while queue and len(walked) < max_focals:
focal = queue.pop(0)
if focal in walked:
continue
walked.add(focal)
posts, reply_counts, reply_to, focal_calls, reason = _walk_focal(
extractor, focal, request_cap, variables, toggles, capture_dir
)
per_focal[focal] = tuple(sorted(posts))
all_posts.update(posts)
calls += focal_calls
stopped = reason
if len(walked) >= max_focals:
stopped = "focal-cap"
break
for parent in _under_captured(reply_counts, reply_to):
if parent not in walked:
queue.append(parent)
logger.info(
"walker found %d posts across %d focals in %d requests",
len(all_posts), len(walked), calls,
)
return WalkerResult(
post_ids=tuple(sorted(all_posts)),
calls=calls,
per_focal=per_focal,
stopped_reason=stopped,
)