項目値
ベースライブラリtwikit 2.3.3.post8 (phin/twikit fork)
認証方式サブ垢 (auth_token + ct0)
実装言語Python 3.13
インフラ追加コスト$0/月 (X API Basic $200 比で 100% 削減)
取得実績1 日 約 2,500 ツイート (10 アカウント + 20 検索)
エラー回避パッチ数3 (transaction.py + user.py + User-Agent 偽装)
突破できなかったもの1 (リプ欄 tweet.replies のスキーマエラー、別ルートで代替)
プラン月額読み取り上限
---------
Free$01,500 ツイート/月
Basic$20050,000 ツイート/月
Pro$5,0001,000,000 ツイート/月
API 課金を疑う前に、自分で組める選択肢を持っていますか?
Claude Code 研修
研修の内容を見る

4 つの破壊的変更との戦い

エラー 1: Couldn't get KEY_BYTE indices

twikit の本家 d60/twikit を pip install twikit でインストールし、最初のリクエストを叩いたら出たエラーです。
File "...twikit/x_client_transaction/transaction.py", line 54, in get_indices
    raise Exception("Couldn't get KEY_BYTE indices")
Exception: Couldn't get KEY_BYTE indices

Situation: ジェーン・スーのアカウント情報を client.get_user_by_screen_name("janesu112") で取得しようとした時。

Trip: スタックトレースを追うと transaction.py の get_indices() でコケている。X が認証用に使う X-Client-Transaction-Id を生成するために、X.com のホームページから JavaScript の ondemand.s バンドルを取得して、その中から「KEY_BYTE indices」と呼ばれる正規表現マッチを抽出するロジックです。

Aha: X 側が JavaScript チャンクの埋め込み形式を変更していました。旧形式は 'ondemand.s': 'HASH' という単純なキーでしたが、新形式は数値の chunk ID を経由する 2 段階マッピングに変わっていました。

旧: 'ondemand.s': '5888cd7'
新: {20113: 'ondemand.s', ...}, {20113: '5888cd7', ...}

突破: phin/twikit のフォークが PR #7 でこのマッピングに対応していたので、本家の transaction.py を phin 版で置き換えました。

# phin/twikit が追加した resolver
@staticmethod
def _resolve_on_demand_url(response_str: str):
    # Method 1 (legacy): direct 'ondemand.s': 'HASH'
    on_demand_file = ON_DEMAND_FILE_REGEX.search(response_str)
    if on_demand_file:
        return f"https://abs.twimg.com/.../ondemand.s.{on_demand_file.group(1)}a.js"

    # Method 2 (current): two-step chunk ID -> hash resolution
    chunk_id_match = ON_DEMAND_CHUNK_ID_REGEX.search(response_str)
    if chunk_id_match:
        chunk_id = chunk_id_match.group(1)
        hash_pattern = re.compile(rf"""[{{,]{chunk_id}:\s*['|\"]+([a-f0-9]+)['|\"]+""")
        hash_match = hash_pattern.search(response_str)
        if hash_match:
            return f"https://abs.twimg.com/.../ondemand.s.{hash_match.group(1)}a.js"
    return None

教訓: 本家ライブラリが追従できていない時は、フォークを探す。phin/twikit のように直近の変更に追従している派生があります。

エラー 2: KeyError: 'urls'



phin/twikit に切り替えて再実行したら、別のエラーが出ました。

File "...twikit/user.py", line 102, in __init__
    self.description_urls: list = legacy['entities']['description']['urls']
KeyError: 'urls'

Situation: 上記 transaction.py 修正後のリクエストで、ユーザーオブジェクトを構築する箇所。

Trip: X が User オブジェクトの legacy フィールド構造を変更し、一部のフィールドが省略されるようになった。phin/twikit ですら追従できていない。

Aha: legacy['xxx'] という直接アクセスは、フィールドが消えた瞬間に KeyError で死にます。防御的アクセスに書き換える のが安全です。

突破: user.py を全面的に .get() 形式に書き換え。

# Before (壊れる)
self.description_urls: list = legacy['entities']['description']['urls']
self.want_retweets: bool = legacy['want_retweets']
self.followers_count: int = legacy['followers_count']
self.withheld_in_countries: list[str] = legacy['withheld_in_countries']

# After (壊れない)
self.description_urls: list = legacy.get('entities', {}).get('description', {}).get('urls', [])
self.want_retweets: bool = legacy.get('want_retweets', False)
self.followers_count: int = legacy.get('followers_count', 0)
self.withheld_in_countries: list[str] = legacy.get('withheld_in_countries', [])

💡 KEY TAKEAWAYS
外部 API レスポンスを受け取るコードは、最初から dict.get(key, default) で書くべきです。dict[key] はテストが通っているうちは平気ですが、本番で API が仕様変更した瞬間に全件失敗します。

防御的アクセスへの書き換え Before/After
防御的アクセスへの書き換え Before/After


エラー 3: Cloudflare 1010 — 「お前の User-Agent は禁止」



スクレイピングが動き出して、12 キャンペーン分の Engagement Gate を一括登録する Python スクリプト (setup_campaigns.py) を書きました。X Harness の Cloudflare Worker に POST するだけのシンプルなコードです。

req = request.Request(
    url,
    data=json.dumps(body).encode("utf-8"),
    headers={
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    },
    method="POST",
)

実行したら HTTP 403 が返ってきました。

{
    "type": "https://developers.cloudflare.com/.../error-1010/",
    "title": "Error 1010: Access denied",
    "status": 403,
    "detail": "The site owner has blocked access based on your browser's signature.",
    "error_code": 1010,
    "error_name": "browser_signature_banned"
}

Situation: X Harness Worker は別エンドポイント (/api/health、/api/x-accounts) では curl で疎通確認できていました。Python urllib からだけ弾かれる。

Trip: 30 分悩みました。Cookie を疑い、API Key を疑い、JSON ペイロードを疑い…全部空振り。

Aha: Cloudflare の Bot Management が Python urllib のデフォルト User-Agent (Python-urllib/3.13) を Bot 認定して 1010 でブロックしていました。「browser's signature」というエラーメッセージのとおり、UA で判別している。

突破: User-Agent をブラウザ風に偽装するだけで通りました。

req = request.Request(
    url,
    data=json.dumps(body).encode("utf-8"),
    headers={
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",  # ← これ追加
    },
    method="POST",
)

教訓: 自社の Cloudflare Worker でも、自分の Python から弾かれることがある。Cloudflare の Bot Management は POST リクエストに対して特に厳しい。requests ライブラリ (デフォルト UA は python-requests/2.x) でも同じ症状です。

エラー 4: scenario_id が文字列リテラルのまま



最後は API スキーマの取り違えでした。Engagement Gate を登録する API ペイロードで、lineHarnessScenarioId フィールドに私が 設計書のドキュメント名を文字列リテラルのまま 入れていたのです。

# 間違い
{
    "lineHarnessScenarioId": "scenario-found-pin-PDF",  # ← これは設計書名
    ...
}

Situation: 12 キャンペーン分の setup_campaigns.py の CAMPAIGNS 定義を書いた時。

Trip: API は 201 を返してきた。「OK 動いた」と思ったのですが、verify API のテストでシナリオが起動しない。

Aha: LINE Harness 側でシナリオを実際に POST して取得した UUID は fbbcf8ea-4eb8-4203-bf37-5279de31e7c6 でした。X Harness の Gate には UUID を入れる べきところを、設計書の人間可読名のまま入れていた。

突破: 実装フローを見直したら、Form 側の onSubmitScenarioId で UUID を指定すれば実起動するので、Gate 側はそのままでも問題ない構造でした。Form を PUT で更新して、UUID を埋めて完了。

curl -X PUT \
  -H "Authorization: Bearer $LINE_KEY" \
  -H "Content-Type: application/json" \
  "https://line-harness.../api/forms/$FORM_ID" \
  -d "{
    \"onSubmitWebhookUrl\": \"https://x-harness.../api/engagement-gates/$GATE_ID/verify?username={x_username}\",
    \"onSubmitScenarioId\": \"fbbcf8ea-4eb8-4203-bf37-5279de31e7c6\"
  }"

教訓: API スキーマで「id」と書かれているフィールドには UUID を入れる。設計書の名前を流用してはいけません。

突破できなかったもの — tweet.replies のスキーマエラー



正直に書きます。1 つだけ突破できなかったエラーがあります。

ERROR: 'itemContent'

twikit の get_tweet_by_id() レスポンスから tweet.replies を辿ると、X 側のレスポンス構造で itemContent フィールドが見つからずに失敗します。Top50 投稿のリプライを 50 件ずつ取りたかったのですが、全件失敗しました。

修正は試みましたが、X 側のレスポンス構造が複雑で時間切れ。代替策として、取得済みの 492 投稿の本文から頻出語抽出 に切り替えました。結果として、リプ欄に近い「の一次言語」が取得できたので、実用上は問題なく着地しています。

教訓: 全部直そうとしない。代替手段がある場合は、未解決のままでもスケジュールを優先する判断が必要です。

4 つのエラーと突破策まとめ
4 つのエラーと突破策まとめ


完成した運用環境 — MCP/agent-reach/



最終的に組み上がった環境はこんな構造です。

:::details ディレクトリ構成

MCP/agent-reach/
├── .venv/                       # Python 仮想環境 (twikit 2.3.3.post8)
├── credentials/
│   ├── cookies_raw.json         # Cookie-Editor からの生エクスポート
│   └── cookies.json             # twikit 用 dict 形式 (gitignore済)
├── src/
│   ├── targets.json             # 10 アカウント + 10 検索 KW 定義
│   ├── collect.py               # アカウント+検索一括収集
│   ├── collect_underground.py   # min_faves:5000 純粋バズ抽出
│   ├── analyze.py               # 型分類・トリガー要素マッピング
│   ├── analyze_underground.py   # 4 フィルタ条件で多角解析
│   ├── language_extract.py     # 本文から頻出語抽出 (リプ代替)
│   └── gen_card_c05.py          # PIL によるブランド画像自動生成
└── results/
    ├── raw_collected.json       # 取得データ (492 ツイート)
    ├── viral_top.json           # スコア順 Top50
    ├── underground_filtered.json # フォロワー≤1000 ×fav≥5000 14 本
    └── Image01_C05_pin_saku.png # 自動生成画像

:::

改善したい点



1. 自動再パッチ機能



twikit を pip install --upgrade した瞬間に手動パッチが消えます。次は postinstall フック相当のスクリプトで、legacy['xxx'] を一括で .get(xxx, default) に置換するスクリプトを書く予定。

2. リプ欄取得のリトライ



search_tweet("conversation_id:NNN", "Latest") 経由でリプを取れる可能性があります。次の収集サイクルで実装する。

3. 監視ダッシュボード



twikit が壊れた瞬間に気づけるよう、毎朝のヘルスチェック (1 ツイート取得して成功率を Slack 通知) を組む。X 側のスキーマ変更は予測不能なので、検出だけは早くしたい。

次のアクション



EXBANK では、こうした 「課金前提を疑う」エンジニアリング支援 も行っています。「ウチも API 課金を見直したい」「外部ツール依存を減らしたい」と感じた方へ。社員が自分で業務のアプリや仕組みを作れるようになる Claude Code 研修 も行っています。

具体的なスクレイピング環境構築や、X / LINE Harness のクライアント案件支援は、お問い合わせフォームでご連絡ください。本記事の構成をベースに 1〜2 週間で立ち上げ可能です。