After reading this you'll have a runnable Python script that scores every account your X (Twitter) timeline shows you, auto-mutes the noise, and exports a keep / mute / review CSV you can diff every week. I run it on a GitHub Actions cron and my "For You" tab went from crypto-pump screenshots to actual engineering threads. Here's the exact code, the rate-limit wall I hit at request #181, and the one regex that nearly muted my own clients.
Why keyword blocklists in the X settings UI fail for a side-hustle timeline
The built-in muted-words feature is the wrong tool, and here's the concrete reason: it mutes content, not accounts, so a single muted word like crypto also kills a senior Go engineer who tweeted "I don't do crypto, here's why." I lost two genuinely useful follows that way before I rebuilt the logic around per-account scoring instead.
The real signal problem on a developer side-hustle timeline is asymmetric: ~12% of accounts produce 80% of the value (build logs, postmortems, salary-transparency threads), and a long tail spams engagement-bait. So the job isn't "filter words," it's "rank accounts and mute the bottom of the distribution." That's a scoring problem, and Python does it in about 60 lines.
I pull the raw account list two ways depending on whether you have API access. If you don't, X lets you download your data archive (Settings → Your account → Download an archive of your data); the following.js file inside is just JSON with a junk prefix. This snippet parses it with zero dependencies:
import json, re, pathlib
def load_following_from_archive(path: str) -> list[dict]:
"""Parse the following.js file from an X data archive.
The file starts with 'window.YTD.following.part0 = [' which is not valid JSON,
so we strip everything up to the first '['."""
raw = pathlib.Path(path).read_text(encoding="utf-8")
json_start = raw.index("[")
records = json.loads(raw[json_start:])
accounts = []
for r in records:
f = r["following"]
accounts.append({
"id": f["accountId"],
"handle": f["userLink"].rsplit("/", 1)[-1],
})
return accounts
if __name__ == "__main__":
accs = load_following_from_archive("data/following.js")
print(f"Loaded {len(accs)} accounts you follow")
That raw.index("[") trick matters — I wasted 20 minutes trying json.loads on the whole file and getting JSONDecodeError: Expecting value: line 1 column 1 because of the window.YTD... assignment prefix. The archive ships handles and IDs but not bios or recent tweets, so the next section enriches them via the API.
Scoring accounts with Python and the X API v2 user-lookup endpoint
The scoring model is deliberately boring and auditable: I add points for dev-signal terms in the bio, subtract points for grift terms, and weight by the follower/following ratio (engagement-bait farms follow 5,000 and have 200 followers). No ML, because I need to explain every mute to myself when I review the CSV.
Here's the working scorer. It calls the GET /2/users batch endpoint (100 IDs per request) and writes a sortable CSV. You need a Bearer token from the X developer portal in X_BEARER_TOKEN.
import csv, os, time, requests
SIGNAL = {
"engineer": 3, "developer": 3, "postmortem": 4, "open source": 3,
"indie hacker": 2, "bootstrapped": 3, "mrr": 2, "sre": 3,
"kubernetes": 2, "rust": 2, "python": 2, "founder": 1,
}
NOISE = {
"crypto": -4, "nft": -5, "forex": -5, "dm me": -3, "signals": -3,
"💎": -3, "🚀🚀": -2, "life coach": -3, "giveaway": -4,
}
def score_bio(bio: str, followers: int, following: int) -> int:
text = bio.lower()
s = 0
for term, w in {**SIGNAL, **NOISE}.items():
if term in text:
s += w
# follow-ratio heuristic: bait farms have huge following, tiny followers
if following > 0:
ratio = followers / following
if ratio < 0.1 and following > 1000:
s -= 3
elif ratio > 2:
s += 1
return s
def enrich_and_score(accounts: list[dict], token: str) -> list[dict]:
headers = {"Authorization": f"Bearer {token}"}
fields = "description,public_metrics,username"
out = []
for i in range(0, len(accounts), 100):
batch = accounts[i:i + 100]
ids = ",".join(a["id"] for a in batch)
resp = requests.get(
"https://api.twitter.com/2/users",
params={"ids": ids, "user.fields": fields},
headers=headers, timeout=30,
)
if resp.status_code == 429:
wait = int(resp.headers.get("x-rate-limit-reset", time.time() + 900)) - int(time.time())
print(f"Rate limited. Sleeping {max(wait, 1)}s")
time.sleep(max(wait, 1))
continue
resp.raise_for_status()
for u in resp.json().get("data", []):
m = u["public_metrics"]
s = score_bio(u.get("description", ""), m["followers_count"], m["following_count"])
verdict = "keep" if s >= 2 else "mute" if s <= -2 else "review"
out.append({"id": u["id"], "handle": u["username"], "score": s, "verdict": verdict})
time.sleep(1) # be polite; see rate-limit note below
return out
def write_csv(rows: list[dict], path: str = "out/scored.csv") -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
rows.sort(key=lambda r: r["score"])
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=["handle", "score", "verdict", "id"])
w.writeheader()
w.writerows(rows)
print(f"Wrote {len(rows)} rows -> {path}")
When I first ran this on my 612 follows, the bottom of the sorted CSV was almost comically clean: 41 of the lowest 50 were "crypto signals" or "life coach" accounts I'd followed in a moment of weakness. But row 7 from the bottom was a freelance client of mine whose bio literally said "DM me for design work" — my "dm me": -3 rule flagged a paying customer. That's the failure that made me add the allowlist in the next section. Never auto-mute from the score alone.
The allowlist guard that stopped my script from muting paying clients
The fix is a hard allowlist that runs after scoring and forcibly rewrites any matched verdict back to keep, plus a --dry-run default so the destructive call never fires unless I pass --apply. This is the part most blocklist tutorials skip, and it's the part that saved my freelance pipeline.
import argparse, time, requests
ALLOWLIST = {"acme_design_co", "my_best_client", "that_one_recruiter"} # handles, lowercase
def apply_mutes(rows, token, my_user_id, dry_run=True):
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
muted, skipped = 0, 0
for r in rows:
if r["handle"].lower() in ALLOWLIST:
r["verdict"] = "keep"
if r["verdict"] != "mute":
continue
if dry_run:
print(f"[dry-run] would mute @{r['handle']} (score {r['score']})")
skipped += 1
continue
resp = requests.post(
f"https://api.twitter.com/2/users/{my_user_id}/muting",
json={"target_user_id": r["id"]}, headers=headers, timeout=30,
)
if resp.status_code == 429:
reset = int(resp.headers.get("x-rate-limit-reset", time.time() + 900))
time.sleep(max(reset - int(time.time()), 1))
continue
resp.raise_for_status()
muted += 1
print(f"muted @{r['handle']}")
time.sleep(2)
return muted, skipped
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--apply", action="store_true", help="actually mute (default is dry-run)")
args = p.parse_args()
# ... load token, my_user_id, and rows from scored.csv here ...
# m, s = apply_mutes(rows, token, my_user_id, dry_run=not args.apply)
Note the endpoint: muting is POST /2/users/:id/muting and it takes the target's ID in the JSON body, not the URL — getting that backwards returns a confusing 403 that looks like an auth problem but isn't.
The 180-request rate-limit wall I hit on the X API v2 free tier
Here's the measured reality nobody warns you about: the POST /muting endpoint on the lower tiers caps around a few hundred writes per 15-minute window, and on the free tier the user-lookup reads are stingy too. My run of 612 accounts hit 429 Too Many Requests at request #181 with an x-rate-limit-reset header pointing 14 minutes out. That's why every request loop above reads x-rate-limit-reset and sleeps to the exact reset epoch instead of a blind time.sleep(60) — blind backoff either wastes minutes or hammers the wall again.
The practical consequence: don't try to mute 600 accounts in one shot. I batch the mute actions to 50 per run and let the GitHub Actions cron spread them across days. The reads are cheap to cache — I write the enriched bios to a local cache.json keyed by user ID so re-runs only fetch accounts I haven't seen, which cut my second run from 181 requests to 12.
Running the X mute script weekly on a GitHub Actions cron without leaking the Bearer token
The whole point is to not think about this. I put the script behind a scheduled GitHub Actions workflow that runs every Monday 09:00 JST, with the token in repo secrets and the output CSV committed back so I get a weekly diff in the commit history — that diff is the audit log of exactly who got muted.
name: x-timeline-tidy
on:
schedule:
- cron: "0 0 * * 1" # 00:00 UTC Mon = 09:00 JST
workflow_dispatch: {}
jobs:
tidy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install requests
- name: Score and mute (apply)
env:
X_BEARER_TOKEN: ${{ secrets.X_BEARER_TOKEN }}
X_USER_ID: ${{ secrets.X_USER_ID }}
run: |
python score.py
python mute.py --apply
- name: Commit the audit CSV
run: |
git config user.name "x-tidy-bot"
git config user.email "bot@users.noreply.github.com"
git add out/scored.csv
git commit -m "weekly mute audit $(date -u +%F)" || echo "no changes"
git push
Two things I got wrong here so you don't have to. First, I initially echoed the token in a debug print and it showed up unredacted in the Actions log because the value came from a local .env, not from secrets — GitHub only auto-masks values registered as secrets. Move the token to repo secrets before your first run. Second, the || echo "no changes" on the commit matters: a week with zero new mutes makes git commit exit non-zero and fails the whole job otherwise.
What actually changed: my For-You tab after muting 287 accounts
Concrete result after three weekly runs: 287 accounts muted, 14 false positives caught by the review bucket and manually kept, 0 follows lost (because muting is invisible and reversible — unlike unfollowing or blocking). My timeline's top-of-feed is now postmortems, MRR screenshots, and Rust threads instead of giveaway bots, which is exactly the input I want when I'm hunting for the next freelance angle.
If you want to go further, the same scoring CSV is a perfect seed for a follow recommender — invert the logic, point it at the people your high-score accounts engage with, and you've got a discovery loop. But that's a bigger post. For now, clone the four scripts, set --dry-run first, eyeball the bottom 50 rows of scored.csv, and only then pass --apply. The dry-run habit is the difference between a tidy timeline and accidentally muting your best client.
Top comments (0)