sync: update from private repo (ddadfd71)
CI / build-and-test (push) Waiting to run

This commit is contained in:
oss-sync
2026-07-08 23:35:00 +00:00
parent b1292e34b2
commit 77ee3bc426
187 changed files with 19918 additions and 10938 deletions
+178 -5
View File
@@ -27,6 +27,8 @@
- id, text, author{name,screenName,profileImageUrl,id},
metrics{likes,retweets,replies,views}, createdAtISO, isRetweet,
media[]{type, url, variants[]{url,bitrate,contentType}}
article{title, previewText, plainText, ...} # X 長文記事のみ (tweet サブコマンド)
# 長文記事の埋め込み画像・動画は media[] に合流する (自動 DL 対象)
注意: twscrape / twikit / httpx は遅延 import (関数内)。pure なマッピング関数だけ
なら依存なしで import でき、test_x_adapter.py がネットワーク・依存なしで回る。
@@ -39,7 +41,7 @@ import re
import sys
from email.utils import parsedate_to_datetime
VERSION = "x-adapter 1.0.0 (twscrape backend)"
VERSION = "x-adapter 1.2.0 (twscrape backend)"
# twitter-cli の YAML スキーマバージョン (x.ts は使わないが互換のため踏襲)
SCHEMA_VERSION = "1"
@@ -48,13 +50,17 @@ SCHEMA_VERSION = "1"
# ── pure helpers (依存なし・テスト対象) ──────────────────────────────
def parse_tweet_ref(raw: str) -> str | None:
"""tweet ID もしくは status URL から数値 ID を取り出す。"""
"""tweet ID もしくは status / article URL から数値 ID を取り出す。
X の長文記事 (X Articles) は `x.com/{user}/article/{id}` 形式で共有されるが、
その id はツイート ID と同じ空間なので TweetDetail でそのまま引ける。
"""
s = (raw or "").strip()
if not s:
return None
if s.isdigit():
return s
m = re.search(r"(?:x\.com|twitter\.com)/[^/]+/status(?:es)?/(\d+)", s)
m = re.search(r"(?:x\.com|twitter\.com)/[^/]+/(?:status(?:es)?|article)/(\d+)", s)
return m.group(1) if m else None
@@ -219,6 +225,113 @@ def parse_graphql_tweet_result(result: dict) -> dict | None:
}
# ── X 長文記事 (X Articles) の抽出 ──────────────────────────────────
#
# 長文記事ポストの legacy.full_text は記事への t.co リンク 1 本だけで本文を含まない。
# 本文は TweetDetail レスポンスの tweet result 直下 `article.article_results.result`
# に入る (plain_text は fieldToggles.withArticlePlainText: true を付けた時のみ)。
# plain_text の既定上限 (文字数)。LLM コンテキスト保護。--full-text で解除。
ARTICLE_TEXT_CAP = 12000
# 記事内埋め込みメディアの件数上限。x.ts の downloadTweetMedia は media[] を
# 全件 DL する (件数制限なし・サイズ上限のみ) ので、画像だらけの記事で
# ダウンロードが暴発しないようアダプタ側で頭を抑える。
ARTICLE_MEDIA_CAP = 20
def find_tweet_result_in_detail(payload, tid: str) -> dict | None:
"""TweetDetail レスポンス全体から rest_id == tid の tweet_results.result を探す。
conversation スレッド内の別ツイート (リプライ等) も同じ形で並ぶので、
rest_id の一致で focal tweet を特定する。visibility wrapper は剥がして返す。
"""
if isinstance(payload, dict):
tr = payload.get("tweet_results")
if isinstance(tr, dict):
r = tr.get("result")
if isinstance(r, dict):
if r.get("__typename") == "TweetWithVisibilityResults":
r = r.get("tweet") if isinstance(r.get("tweet"), dict) else r
if isinstance(r, dict) and str(r.get("rest_id") or "") == str(tid):
return r
for v in payload.values():
hit = find_tweet_result_in_detail(v, tid)
if hit is not None:
return hit
elif isinstance(payload, list):
for v in payload:
hit = find_tweet_result_in_detail(v, tid)
if hit is not None:
return hit
return None
def extract_article(result, full_text: bool = False) -> dict | None:
"""tweet result の article.article_results.result から記事情報を YAML dict に。
記事でないツイート・形が崩れている場合は None (呼び出し側は無視して従来出力)。
"""
art = _dig(result if isinstance(result, dict) else {}, "article", "article_results", "result")
if not isinstance(art, dict):
return None
out: dict = {
"title": art.get("title") or "",
"previewText": art.get("preview_text") or "",
}
plain = art.get("plain_text")
if isinstance(plain, str) and plain:
if not full_text and len(plain) > ARTICLE_TEXT_CAP:
out["plainText"] = plain[:ARTICLE_TEXT_CAP]
out["plainTextTruncated"] = True
else:
out["plainText"] = plain
pub = _dig(art, "metadata", "first_published_at_secs")
if isinstance(pub, (int, float)) and pub > 0:
from datetime import datetime, timezone
out["publishedAtISO"] = datetime.fromtimestamp(int(pub), tz=timezone.utc).isoformat()
cover = _dig(art, "cover_media", "media_info", "original_img_url")
if cover:
out["coverImageUrl"] = cover
return out
def extract_article_media(result) -> list:
"""記事内の埋め込みメディア (media_entities) を row の media[] と同じ形に変換。
media_entities は fieldToggles.withArticleRichContentState: true の時のみ返る。
row["media"] に合流させることで x.ts の downloadTweetMedia がそのまま
自動 DL し localPath を付ける (記事ポストの tweet-level media は常に空なので
衝突しない)。未知の __typename (音声スペース等) は黙ってスキップ。
"""
art = _dig(result if isinstance(result, dict) else {}, "article", "article_results", "result")
if not isinstance(art, dict):
return []
out: list = []
for entity in art.get("media_entities") or []:
if len(out) >= ARTICLE_MEDIA_CAP:
break
info = _dig(_as_dict(entity), "media_info") or {}
tname = info.get("__typename")
if tname == "ApiImage":
url = info.get("original_img_url")
if url:
out.append({"type": "photo", "url": url})
elif tname in ("ApiVideo", "ApiGif"):
variants = [
{"url": v.get("url"), "bitrate": v.get("bit_rate") or 0,
"contentType": v.get("content_type") or ""}
for v in (info.get("variants") or [])
if isinstance(v, dict) and v.get("url")
]
out.append({
"type": "video" if tname == "ApiVideo" else "animated_gif",
"url": _dig(info, "preview_image", "original_img_url") or "",
"variants": variants,
})
return out
def _collect_tweet_results(node, out: list, limit: int) -> None:
"""entry / module item から tweet_results.result を拾って out に積む。
@@ -405,17 +518,77 @@ async def cmd_user_posts(args) -> None:
shutil.rmtree(tmpdir, ignore_errors=True)
async def _tweet_detail_raw_with_article(api, tid: str):
"""TweetDetail を fieldToggles.withArticlePlainText 付きで直接叩く。
twscrape の tweet_details_raw は fieldToggles を渡せない (_gql_item が
variables/features のみ) ので、同じ op / variables / QueueClient を使い
パラメータだけ足す。X 長文記事の本文 (plain_text) は withArticlePlainText、
記事内埋め込みメディア (media_entities) は withArticleRichContentState が
無いとレスポンスに含まれない (後者は content_state も同乗するが emit しない)。
"""
from twscrape.api import GQL_URL, OP_TweetDetail
from twscrape.api import GQL_FEATURES # type: ignore[attr-defined]
from twscrape.queue_client import QueueClient
from twscrape.utils import encode_params
kv = {
"focalTweetId": str(tid),
"with_rux_injections": True,
"includePromotedContent": True,
"withCommunity": True,
"withQuickPromoteEligibilityTweetFields": True,
"withBirdwatchNotes": True,
"withVoice": True,
"withV2Timeline": True,
}
params = {
"variables": kv,
"features": GQL_FEATURES,
"fieldToggles": {"withArticlePlainText": True, "withArticleRichContentState": True},
}
queue = OP_TweetDetail.split("/")[-1]
async with QueueClient(api.pool, queue, False) as client:
return await client.get(f"{GQL_URL}/{OP_TweetDetail}", params=encode_params(params))
async def cmd_tweet(args) -> None:
tid = parse_tweet_ref(args.tweet)
if not tid:
fail(f"x-adapter: could not parse tweet id from '{args.tweet}'")
api, tmpdir, shutil = await _make_api()
try:
t = await api.tweet_details(int(tid))
# 記事本文トグル付きの直接呼び出しを優先。twscrape 内部 API の変化などで
# 壊れたら従来の tweet_details に落として XPostDetail 自体は生かす。
t = None
article = None
article_media: list = []
try:
from twscrape.models import parse_tweet
rep = await _tweet_detail_raw_with_article(api, tid)
if rep is not None:
t = parse_tweet(rep, int(tid))
result = find_tweet_result_in_detail(rep.json(), tid)
article = extract_article(result, full_text=bool(args.full_text))
article_media = extract_article_media(result)
except SystemExit:
raise
except Exception as e:
print(f"x-adapter: article-aware fetch failed, falling back: {type(e).__name__}: {e}",
file=sys.stderr)
if not t:
t = await api.tweet_details(int(tid))
if not t:
await _ensure_alive(api) # 認証切れなら nonzero
fail(f"x-adapter: tweet {tid} not found or inaccessible")
emit([twscrape_tweet_to_dict(t)])
row = twscrape_tweet_to_dict(t)
if article:
row["article"] = article
if article_media:
# 記事ポストの tweet-level media は空なので合流しても衝突しない。
# x.ts の downloadTweetMedia がここを見て自動 DL → localPath 付与する。
row["media"] = (row.get("media") or []) + article_media
emit([row])
finally:
shutil.rmtree(tmpdir, ignore_errors=True)