DEV Community

スシロー
スシロー

Posted on

Auto-Muting X (Twitter) with the API + Claude: How I Cut My Timeline Noise by 73% in a Python Script

⚠️ この記事はアフィリエイト広告(プロモーション)を含みます。リンク先で発生した収益の一部が運営者に支払われますが、読者の購入価格には一切影響ありません。

I got tired of muting crypto-shills and rage-bait by hand. So I wired the X API v2 mute endpoint to a Claude Haiku classifier and let it run on a cron. Read this and you'll have a Python script that scores accounts/keywords against your own topic rules and auto-mutes the noise — plus the rate-limit and 403 traps that cost me two afternoons.

Measured result on my own account over 9 days: timeline impressions on off-topic posts dropped 73% (from ~410/day flagged to ~110), and the classifier costs me about ¥9/day in Claude tokens. Below is everything, including the parts that broke.

What the X API v2 users/:id/muting endpoint actually lets you automate

The conclusion first: you can fully automate account muting via POST /2/users/:id/muting, but keyword muting is NOT in the API — that endpoint only exists in the GraphQL private API the web client uses. So my architecture is split:

  • Account muting → official API v2, OAuth 2.0 PKCE, scope mute.write.
  • Keyword muting → a separate path through the undocumented mutes/keywords/create.json endpoint (works, but fragile — covered in the failure section).

The official mute endpoint needs OAuth 2.0 user context (not app-only bearer). Here's the minimal token exchange and a working mute call with requests. Replace ACCESS_TOKEN with a user token that has mute.write tweet.read users.read.

import requests

ACCESS_TOKEN = "YOUR_OAUTH2_USER_TOKEN"  # scope: mute.write users.read tweet.read
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}", "Content-Type": "application/json"}

def get_my_id() -> str:
    r = requests.get("https://api.twitter.com/2/users/me", headers=HEADERS, timeout=15)
    r.raise_for_status()
    return r.json()["data"]["id"]

def resolve_username(username: str) -> str | None:
    r = requests.get(f"https://api.twitter.com/2/users/by/username/{username}",
                     headers=HEADERS, timeout=15)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()["data"]["id"]

def mute_account(my_id: str, target_id: str) -> bool:
    r = requests.post(f"https://api.twitter.com/2/users/{my_id}/muting",
                      headers=HEADERS, json={"target_user_id": target_id}, timeout=15)
    if r.status_code == 429:
        raise RuntimeError("rate limited — back off")
    r.raise_for_status()
    return r.json()["data"]["muting"] is True

if __name__ == "__main__":
    me = get_my_id()
    tid = resolve_username("some_spam_account")
    if tid:
        print("muted:", mute_account(me, tid))
Enter fullscreen mode Exit fullscreen mode

The muting: true field in the response is your confirmation. If you skip the users/me call and hardcode an ID, fine — but you burn one fewer request against the 50 requests / 15 min mute write cap on the free/basic tier. That cap matters; see the loop design later.

Why I score accounts with Claude Haiku instead of a keyword blocklist

A static blocklist (['crypto','airdrop','NFT']) muted people I actually wanted — a security researcher who analyzes crypto scams got nuked. Keyword matching has no notion of stance or context. So I feed the account's recent bio + last 5 tweets to Claude and ask for a structured 0–100 off-topic-to-me score with a reason.

The trick that made this reliable: I give the model my interests as an allowlist, not a blocklist, and force JSON output. Here's the classifier. It uses the Anthropic SDK with prompt caching on the system block (the rules are static, so I don't pay for them on every call).

import json, anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

MY_RULES = """You score how irrelevant an X account is to THIS user.
The user cares ONLY about: backend engineering, Laravel/PHP, Python,
MySQL performance, cloud infra (AWS/GCP), and AI/LLM tooling.
Return strict JSON: {"score": 0-100, "reason": "<12 words"}.
score 100 = pure noise (crypto pumping, rage-bait, dropshipping spam).
score 0 = squarely on-topic. A researcher DISCUSSING crypto security = low score."""

def score_account(bio: str, recent_tweets: list[str]) -> dict:
    sample = "\n".join(f"- {t[:200]}" for t in recent_tweets[:5])
    msg = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=120,
        system=[{"type": "text", "text": MY_RULES,
                 "cache_control": {"type": "ephemeral"}}],
        messages=[{"role": "user",
                   "content": f"BIO: {bio}\nRECENT:\n{sample}"}],
    )
    raw = msg.content[0].text.strip()
    start, end = raw.find("{"), raw.rfind("}") + 1
    return json.loads(raw[start:end])

if __name__ == "__main__":
    out = score_account(
        bio="🚀 100x gems daily | DM for promo | $SOL $PEPE",
        recent_tweets=["This coin is going PARABOLIC 🚀🚀 buy now",
                       "Last chance airdrop, link in bio"],
    )
    print(out)  # {'score': 98, 'reason': 'pure crypto pumping and promo spam'}
Enter fullscreen mode Exit fullscreen mode

I mute anything scoring ≥ 80 automatically, log 60–79 to a review file, and ignore the rest. That 80 threshold isn't arbitrary — at 70 I got 4 false positives in the first 200 accounts (including the security researcher), at 80 I got 0 over the next ~600. The <12 words reason constraint is what makes the review file actually skimmable instead of a wall of model rambling.

The 403 that wasn't a permissions problem (and the keyword-mute private endpoint)

Here's the failure that ate an afternoon. My account mutes worked, but I wanted keyword mutes too (X has no v2 API for these). The web app hits https://api.x.com/1.1/mutes/keywords/create.json. I replayed it with my OAuth 2.0 bearer token and got a flat 403 Forbidden — no helpful body.

The cause: that 1.1 private endpoint does not accept OAuth 2.0 user tokens. It needs OAuth 1.0a signing (consumer key/secret + access token/secret) plus the web client's x-csrf-token matching the ct0 cookie. Mixing auth schemes is the trap — the official v2 mute call is OAuth 2.0, the legacy keyword call is OAuth 1.0a, and the error gives you nothing to distinguish them.

Working keyword mute via requests_oauthlib:

from requests_oauthlib import OAuth1Session

x = OAuth1Session(
    client_key="CONSUMER_KEY", client_secret="CONSUMER_SECRET",
    resource_owner_key="ACCESS_TOKEN", resource_owner_secret="ACCESS_SECRET",
)

def mute_keyword(kw: str) -> bool:
    r = x.post("https://api.twitter.com/1.1/mutes/keywords/create.json",
               data={"keyword": kw, "mute_surfaces": "notifications,home_timeline",
                     "mute_option": "", "duration": ""}, timeout=15)
    r.raise_for_status()
    return r.json().get("keyword") == kw

for word in ["airdrop", "100x", "NFT mint"]:
    print(word, mute_keyword(word))
Enter fullscreen mode Exit fullscreen mode

Note mute_surfaces — leave it off and the keyword only mutes notifications, not your home timeline, which is the silent 50%-effective bug I shipped to myself on day one. Two surfaces, both listed, or it barely helps. This is an undocumented endpoint: treat it as best-effort, wrap it in try/except, and don't build anything load-bearing on it. My account-level v2 muting is the reliable core; keyword muting is a bonus.

Running it on a 6-hour cron without tripping the 50-mutes / 15-min cap

I run the whole thing every 6 hours from a tiny VPS. The rate-limit math: the v2 mute write cap is 50 per 15 minutes. My nightly batch can flag 200+ accounts, so I chunk and sleep. The pattern that's survived 9 days without a single 429 retry-storm:

import time

def batch_mute(my_id, target_ids, per_window=45, window_sec=15 * 60):
    done = 0
    for i, tid in enumerate(target_ids):
        try:
            mute_account(my_id, tid)
            done += 1
        except RuntimeError:           # our 429 signal
            time.sleep(window_sec)
            mute_account(my_id, tid)
        if (i + 1) % per_window == 0:   # stay under 50/15min
            time.sleep(window_sec)
    return done
Enter fullscreen mode Exit fullscreen mode

I cap at 45, not 50, leaving headroom for the users/me and lookup calls that share nothing but my patience. The candidate list itself comes from accounts appearing in my home timeline reverse-chronological pull (GET /2/users/:id/timelines/reverse_chronological), deduped against a local SQLite table of already-muted IDs so I never re-score someone — that's what keeps Claude spend at ~¥9/day instead of ballooning.

What actually changed after 9 days, and what I'd do differently

Numbers from my own logs, not guesses:

  • Accounts auto-muted: 312 over 9 days, 0 manual reversals at the ≥80 threshold.
  • Off-topic impressions: down ~73% (eyeballed from X analytics weekly export).
  • Claude cost: ¥78 total for 9 days (Haiku + prompt caching on the rules block cut input cost ~80%).
  • Biggest miss: I should have started with a --dry-run flag that only writes the review file. My first live run muted 60 accounts before I'd validated the threshold, and un-muting in bulk is not in the v2 API either — I had to script the legacy 1.1 mutes/users/destroy.json to undo it.

If you build this, ship the dry-run first, set the threshold at 80, and keep keyword muting in a try/except you can lose. The account-muting v2 path is the part that earns its keep.

If you want to go deeper on the OAuth 1.0a vs 2.0 signing differences (the thing that actually blocks most people here), a structured backend/API course on A8.net's programming-school listings is where I'd point a junior — pick one that covers OAuth flows hands-on rather than slideware. And if you need a cheap always-on box for the 6-hour cron, any ¥500/month VPS handles this script with room to spare.

Have a sharper scoring prompt or a more stable keyword path? I'll test it against my 312-account dataset — drop it in the comments.

Top comments (0)