This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# twikit: ホームタイムラインの GraphQL endpoint / FEATURES 定義の供給元
|
||||
# httpx: home timeline の raw GraphQL POST
|
||||
# PyYAML: twitter-cli 互換 YAML の出力
|
||||
twscrape>=0.19.0
|
||||
twikit>=2.3.3
|
||||
httpx>=0.27
|
||||
PyYAML>=6.0
|
||||
twscrape==0.19.2
|
||||
twikit==2.3.3
|
||||
httpx==0.28.1
|
||||
PyYAML==6.0.3
|
||||
|
||||
@@ -278,6 +278,80 @@ try:
|
||||
except ImportError:
|
||||
print("SKIP emit tests (PyYAML not installed)")
|
||||
|
||||
# ── txid indices deep scan ──
|
||||
# X が 2026-07 に未ログインページを x-web SSR 化し、indices ファイル (sign.o-*.js) が
|
||||
# entry チャンクの動的 import 先 (assets/*.js) に移った。twscrape 0.19.1 はページ直下の
|
||||
# スクリプトしか走査しないため到達できない。以下はもう 1 ホップ潜る実装のテスト。
|
||||
|
||||
ENTRY = "https://abs.twimg.com/x-web/x-web/entry-client-logged-out-B9F1BBbe.js"
|
||||
|
||||
check("chunkrefs: resolves ./assets relative to entry",
|
||||
"https://abs.twimg.com/x-web/x-web/assets/a-1234.js"
|
||||
in xa.collect_chunk_refs('import("./assets/a-1234.js")', ENTRY))
|
||||
check("chunkrefs: resolves parent-relative",
|
||||
"https://abs.twimg.com/x-web/messages/ja.js"
|
||||
in xa.collect_chunk_refs('x="../messages/ja.js"', ENTRY))
|
||||
check("chunkrefs: assets sorted ahead of non-assets",
|
||||
xa.collect_chunk_refs('"../messages/ja.js";"./assets/z-9.js"', ENTRY)[0].endswith("/assets/z-9.js"))
|
||||
check("chunkrefs: dedupes",
|
||||
len(xa.collect_chunk_refs('"./assets/a-1.js" "./assets/a-1.js"', ENTRY)) == 1)
|
||||
check("chunkrefs: ignores absolute cross-origin",
|
||||
xa.collect_chunk_refs('"https://evil.example/x.js"', ENTRY) == [])
|
||||
check("chunkrefs: caps fan-out",
|
||||
len(xa.collect_chunk_refs(" ".join(f'"./assets/c-{i}.js"' for i in range(500)), ENTRY))
|
||||
<= xa.CHUNK_SCAN_CAP)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""twscrape の HttpClient 代替。get(url).text を返すだけ。"""
|
||||
|
||||
def __init__(self, bodies):
|
||||
self.bodies = bodies
|
||||
self.fetched = []
|
||||
|
||||
async def get(self, url):
|
||||
self.fetched.append(url)
|
||||
return types.SimpleNamespace(text=self.bodies.get(url, ""))
|
||||
|
||||
|
||||
def _run(coro):
|
||||
import asyncio
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
SIGN_O = "https://abs.twimg.com/x-web/x-web/assets/sign.o-BS4wvWaM.js"
|
||||
SUB = "https://abs.twimg.com/x-web/x-web/assets/sentry-filter-CAS6J6cR.js"
|
||||
|
||||
# 1 段目に直接ある場合 (旧 = 上流と同じ挙動) は 1 ホップで返す。
|
||||
# entry と同じ階層で解決されるので assets/ は挟まらない。
|
||||
c = _FakeClient({ENTRY: 'import("./sign.o-BS4wvWaM.js")'})
|
||||
check("deepscan: level-1 hit",
|
||||
_run(xa.find_indices_url_deep([ENTRY], c))
|
||||
== "https://abs.twimg.com/x-web/x-web/sign.o-BS4wvWaM.js")
|
||||
|
||||
# 2 段目 (今回の実際の構造): entry -> assets/sentry-filter -> sign.o
|
||||
c = _FakeClient({ENTRY: 'import("./assets/sentry-filter-CAS6J6cR.js")',
|
||||
SUB: 'import("./sign.o-BS4wvWaM.js")'})
|
||||
check("deepscan: level-2 hit",
|
||||
_run(xa.find_indices_url_deep([ENTRY], c)) == SIGN_O)
|
||||
|
||||
# どこにも無ければ例外を上げ、txid 失敗を記録する (静かな空振りにしない)
|
||||
xa.TXID_STATE["failed"] = False
|
||||
c = _FakeClient({ENTRY: 'import("./assets/nothing-here.js")'})
|
||||
_raised = False
|
||||
try:
|
||||
_run(xa.find_indices_url_deep([ENTRY], c))
|
||||
except Exception:
|
||||
_raised = True
|
||||
check("deepscan: raises when absent", _raised)
|
||||
check("deepscan: records txid failure", xa.TXID_STATE["failed"] is True)
|
||||
|
||||
# 成功時は失敗フラグをクリアする (再試行で回復したケース)
|
||||
xa.TXID_STATE["failed"] = True
|
||||
c = _FakeClient({ENTRY: 'import("./sign.o-BS4wvWaM.js")'})
|
||||
_run(xa.find_indices_url_deep([ENTRY], c))
|
||||
check("deepscan: clears flag on success", xa.TXID_STATE["failed"] is False)
|
||||
|
||||
print()
|
||||
if _fails:
|
||||
print(f"{len(_fails)} FAILED: {_fails}")
|
||||
|
||||
@@ -402,6 +402,108 @@ def _silence_logs() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── txid: indices スクリプトの深掘り探索 ──────────────────────────────
|
||||
#
|
||||
# 2026-07 に X が未ログインページを x-web の SSR 版へ寄せた。ページ直下の <script> は
|
||||
# entry チャンク 1 本だけになり、署名用 indices ファイル (sign.o-*.js) はその entry が
|
||||
# 動的 import する assets/*.js のさらに中から参照される形に変わった。
|
||||
# twscrape 0.19.1 の _find_indices_url は 1 段目しか走査しないので到達できず、
|
||||
# "Couldn't get XClientTxId indices script" で txid 生成が落ちる。txid が無いリクエストは
|
||||
# X 側が黙って 0 件を返すため、症状は「エラー無しで常に空」になる (上流 issue #320)。
|
||||
#
|
||||
# 上流 PR #321 は「cookie 付きで取れば indices を含むバンドルが来る」という案だが、
|
||||
# 実測ではこちらの認証セッションには旧 responsive-web バンドルが配信され、そこに
|
||||
# indices は含まれていなかった。よって cookie 経路には頼らず、未ログインのまま
|
||||
# もう 1 ホップ潜る。上流ロジックは残し、この関数だけ差し替える。
|
||||
|
||||
CHUNK_SCAN_CAP = 200 # 1 段目 1 チャンクあたりに追跡するサブチャンク数の上限
|
||||
CHUNK_FETCH_CONCURRENCY = 16
|
||||
|
||||
# バンドル内の相対 import (`"./assets/x-Hash.js"` / `'../messages/ja.js'`) を拾う。
|
||||
# 絶対 URL は別オリジンを踏みに行かないよう意図的に対象外。
|
||||
_CHUNK_REF_RE = re.compile(r"""["'(](\.{1,2}/[\w./-]*?\.js)["')]""")
|
||||
|
||||
# indices ファイル名。上流 twscrape の INDICES_FILE_RE と同義だが、テストを
|
||||
# twscrape 非依存で回すため自前に持つ (上流のリネームからも切り離される)。
|
||||
# \b は design.o-*.js のような部分一致除け。
|
||||
INDICES_FILE_RE = re.compile(r"(?:\.{0,2}/)?[\w./-]*?\b(?:ondemand\.s|sign\.o)[\w.-]*\.js")
|
||||
|
||||
TXID_STATE = {"failed": False}
|
||||
|
||||
|
||||
def collect_chunk_refs(body: str, base_url: str) -> list[str]:
|
||||
"""チャンク本文から、次に辿るべきサブチャンクの絶対 URL 一覧を作る。
|
||||
|
||||
indices は assets/ 配下にあるので assets/ を含む参照を先に試す (i18n の
|
||||
../messages/*.js が数百件あり、後回しにしないと無駄打ちが増える)。
|
||||
"""
|
||||
from urllib.parse import urljoin
|
||||
seen, assets, others = set(), [], []
|
||||
for m in _CHUNK_REF_RE.finditer(body):
|
||||
url = urljoin(base_url, m.group(1))
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
(assets if "/assets/" in url else others).append(url)
|
||||
return (assets + others)[:CHUNK_SCAN_CAP]
|
||||
|
||||
|
||||
async def find_indices_url_deep(scripts: list, clt) -> str:
|
||||
"""indices ファイル (sign.o-*.js / ondemand.s*.js) の URL を解決する。
|
||||
|
||||
1 段目 = 上流と同じくページ直下のスクリプト本文を走査。見つからなければ
|
||||
2 段目 = 各スクリプトが import している相対チャンクを走査する。
|
||||
どこにも無ければ TXID_STATE に記録した上で例外を上げる (握り潰さない)。
|
||||
"""
|
||||
import asyncio
|
||||
from urllib.parse import urljoin
|
||||
|
||||
sem = asyncio.Semaphore(CHUNK_FETCH_CONCURRENCY)
|
||||
|
||||
async def fetch(url: str) -> tuple[str, str]:
|
||||
async with sem:
|
||||
try:
|
||||
return url, (await clt.get(url)).text
|
||||
except Exception:
|
||||
return url, ""
|
||||
|
||||
async def scan(urls: list) -> tuple[str, list]:
|
||||
"""走査してヒット URL を返す。未ヒットなら次段の候補も併せて返す。"""
|
||||
nxt = []
|
||||
tasks = [asyncio.ensure_future(fetch(u)) for u in urls]
|
||||
try:
|
||||
for fut in asyncio.as_completed(tasks):
|
||||
url, body = await fut
|
||||
m = INDICES_FILE_RE.search(body)
|
||||
if m:
|
||||
return urljoin(url, m.group(0)), []
|
||||
nxt.extend(collect_chunk_refs(body, url))
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
return "", nxt
|
||||
|
||||
hit, level2 = await scan(list(scripts))
|
||||
if not hit and level2:
|
||||
hit, _ = await scan(list(dict.fromkeys(level2)))
|
||||
|
||||
if not hit:
|
||||
TXID_STATE["failed"] = True
|
||||
raise Exception("Couldn't get XClientTxId indices script")
|
||||
|
||||
TXID_STATE["failed"] = False
|
||||
return hit
|
||||
|
||||
|
||||
def _install_xclid_deep_scan() -> None:
|
||||
"""twscrape の indices 探索を深掘り版へ差し替える (import 済みが前提)。"""
|
||||
try:
|
||||
import twscrape.xclid as _xclid
|
||||
_xclid._find_indices_url = find_indices_url_deep
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── backend (twscrape / twikit、遅延 import) ──────────────────────────
|
||||
|
||||
def _creds() -> tuple[str, str]:
|
||||
@@ -441,12 +543,19 @@ def _cleanup_stale_tmp() -> None:
|
||||
|
||||
|
||||
async def _ensure_alive(api) -> None:
|
||||
"""直近のリクエストでアカウントが 401/banned 扱いになっていないか確認。
|
||||
"""空結果が『本当に0件』か『取得できていない』かを切り分け、後者は nonzero で落とす。
|
||||
|
||||
twscrape は 401/429 を例外にせずログして停止し、空イテレーションを返す。空結果が
|
||||
『本当に0件』か『認証切れ/レート制限』かを区別するため、停止時に無効化される
|
||||
account.active を見て、無効なら nonzero で fail する (x.ts がエラー表示)。
|
||||
twscrape は 401/429 も txid 生成失敗も例外にせずログして停止し、空イテレーションを
|
||||
返す。黙って空を返すと利用者からは「エラーも出ないのに何も取れない」に見えるので、
|
||||
原因が分かる形で fail させる (x.ts がそのまま表示する)。
|
||||
- txid 生成失敗 … X のバンドル構成変更。TXID_STATE で検知
|
||||
- 認証切れ/BAN/レート制限 … 停止時に account.active が落ちる
|
||||
"""
|
||||
if TXID_STATE.get("failed"):
|
||||
fail("x-adapter: could not build the X request signature (x-client-transaction-id); "
|
||||
"X changed its web bundle layout again. Every request returns empty until the "
|
||||
"indices lookup is updated. See scripts/x-adapter/x_adapter.py "
|
||||
"(find_indices_url_deep) and twscrape upstream issue #320.")
|
||||
try:
|
||||
accs = await api.pool.get_all()
|
||||
except Exception:
|
||||
@@ -463,6 +572,7 @@ async def _make_api():
|
||||
import tempfile
|
||||
from twscrape import API
|
||||
_silence_logs() # twscrape import 後に呼ぶ (import 時に loguru sink が張られるため)
|
||||
_install_xclid_deep_scan()
|
||||
auth, ct0 = _creds()
|
||||
tmpdir = tempfile.mkdtemp(prefix=_TMP_PREFIX)
|
||||
db = os.path.join(tmpdir, "accounts.db")
|
||||
@@ -601,6 +711,7 @@ async def cmd_feed(args) -> None:
|
||||
from twikit.client.gql import Endpoint
|
||||
from twikit.constants import FEATURES
|
||||
_silence_logs() # twscrape import 後に呼ぶ
|
||||
_install_xclid_deep_scan() # feed は _make_api を通らないのでここでも要適用
|
||||
|
||||
auth, ct0 = _creds()
|
||||
following = (args.tab or "").strip().lower() == "following"
|
||||
|
||||
Reference in New Issue
Block a user