| name | upstream-ban-mirror-fallback |
| description | Upstream API ban (200013)? Mirror RSS + unban watchdog. |
| version | 1.0.0 |
| author | Hermes Agent |
| tags | ["wechat","rss","mirror","ban","fallback","watchdog","cron",200013] |
Upstream Ban → Mirror RSS Fallback + Unban Watchdog
Trigger: an upstream content source's account gets API-banned (WeChat MP ret=200013 freq control, or analogous account-level blocks on X/Twitter, RSSHub targets, etc.), and you need to keep the content flow alive without the native account.
Phase 1 — Diagnose: is it account-level or endpoint-level?
Run the split test BEFORE assuming a ban is permanent:
- searchbiz (search API) works + appmsgpublish (article list) returns 200013 → the ban is on the LIST endpoint capability, not the whole account. Search, single-article pages, and the dashboard may all still work.
- Test with: old token, new token (fresh QR login), direct IP, proxied IP, different fakeid. If ALL combinations return 200013 → account-level.
- Escalation signal: if searchbiz later regresses from
ret:0 to ret=200003 invalid session, the ban is escalating — expect the long tier.
- Ban tiers observed: 24h / 7d / 30d. If 15+ days at 200013, it's the 30-day tier (~unban = block_date + 30d). Don't probe daily — each probe is a fresh violation that can extend the window.
- Root-cause hygiene: a 9-day outage of dead-cookie high-frequency retries (thousands of Invalid Session + freq-control responses) built the risk profile that triggered/escalated the ban. Fix monitoring BEFORE fixing the account (see Phase 5).
Phase 2 — Evaluate mirror services (do NOT self-build first)
For WeChat MP, the ecosystem options, ranked by verified experience:
| Option | Verdict |
|---|
| wechat2rss.xlab.app (online) | ✅ Best: free, 395+ accounts, own collection channel, ~6h latency, full-text. Zero account risk (uses its own accounts). |
| WeWe RSS self-host | ⚠️ Archived 2026-05; all requests proxy through the AUTHOR's server (weread.111965.xyz/api/v2/...) — single point of failure outside your control. Needs a WeChat-Read account (separate from the banned MP account, so unaffected by 200013). |
| RSSHub wechat routes | ⚠️ Needs gateway, heavy anti-scrape. |
| Self-implement (reverse-engineer i.weread.qq.com) | ❌ Not worth it: weread MP-article API is undocumented + volatile; you'd own the maintenance + account risk. |
Decision rule: use the free online mirror first. Self-host only if (a) coverage insufficient AND (b) the mirror's forwarding-server dependency is acceptable. Verify the mirror's feed actually returns content before wiring it in (curl the .xml, count <item>, check latest pubDate).
Phase 3 — Wire the mirror into the pipeline (3 touch points)
Verified end-to-end for the wiki RSS pipeline (blogwatcher → rss-inbox-recovery.py → inbox-screener):
- Source registry (blogwatcher DB
blogs table): insert one row per mirror feed, name prefixed to identify origin (e.g. WeChat-机器之心). ⚠️ Do NOT use blogwatcher-cli add — it dedupes by URL and mirror feeds share one domain, so only the first insert succeeds. Direct SQL INSERT with the unique .xml feed_url.
- Recovery script (
rss-inbox-recovery.py GOOD_FEEDS dict): hardcoded feed list — mirror entries MUST be added here or recovery never fetches them (it does NOT read the source registry DB).
- Cron prompt: note the proxy + any per-source flags (e.g. blogwatcher needs
--unsafe-client to allow loopback proxy for GFW-blocked mirror domains).
Pitfalls hit:
- Go SSRF guard rejects loopback proxies:
blogwatcher-cli scan with HTTPS_PROXY=127.0.0.1:10808 fails ... not authorized by the client: "127.0.0.1" address is loopback. Fix: --unsafe-client. Python (urllib) paths are unaffected.
- Vendor-density feeds get filtered by design: mirror feeds of vendor official accounts (阿里/腾讯/字节/小米/美团) are mostly auto-skipped by the inbox classifier — expect a small ingest fraction (e.g. 39/220 first round). Not a bug.
- First scan shows New:0 for feeds manually scanned during setup (articles marked read). Normal.
- requests cookie encoding: WeChat cookies contain non-latin-1 chars; passing raw cookie string in
headers={"Cookie": ...} raises 'latin-1' codec can't encode characters. Split into a dict and pass cookies=....
- Match feeds with fuzzy check (difflib ratio > 0.6 + substring), not just exact name match, before concluding a feed is uncovered.
Phase 4 — Unban watchdog (silent daily probe → auto-restore)
Keep the banned account at ZERO collection requests (stop internal scheduler + pause discover cron) so the ban window isn't extended. Then add a watchdog that does the waiting for you:
Design (verified as ~/.hermes/scripts/wechat-unblock-probe.py, cron wechat-unblock-probe, daily 09:20, no_agent=True):
- Probe ONCE/day: single
appmsgpublish request, count=1, fixed fakeid. Single daily request does NOT refresh the rate-limit window (window is minute-scale).
ret=200013 → exit silently (no_agent empty stdout = no alert). Keep it quiet — the ban is expected.
ret=0 → auto-restore: (1) re-enable the collection task (UPDATE message_tasks SET status=1 + container restart), (2) resume the paused discover cron (hermes cron resume <job_id> — a no_agent script CAN shell out to hermes CLI), (3) send notification email via the verified SMTP fallback chain (direct 587 ×3 retries → send-report.py 465 fallback).
- Any other ret (e.g. 200003) → log to stderr, stay silent, retry next day.
- Zombie-round guard: if the watchdog itself ever shows
running in executions.db with no subprocess (agent died mid-run), verify with ps aux | grep <job_id> (empty = dead) and re-trigger via cronjob action=run.
Probe BEFORE enabling the cron: run the script once manually — banned state must exit 0 with zero stdout, and the restore path must be verified (function imports + job IDs match DB) without actually flipping the DB.
Phase 5 — Monitoring hygiene (prevent the next silent outage)
The 9-day silent WeChat outage was caused by three false-healthy layers, ALL of which must be present in any pipeline:
- Discover script must count YIELD, not just exceptions — WeChat API failures return empty lists, not exceptions; a script counting
processed += 1 per non-exception reports "30/30 ok" while yielding 0 articles. Count DB rows before/after; ALERT + exit 1 on zero new.
- Credential renewal must check SERVER-side validity — cookie PRESENCE (
slave_sid exists) ≠ server-side valid session. Check the home-page URL token (token=\d+ in the dashboard URL) — logged-out sessions have no token.
- Alert email must have a fallback chain AND no permanent-silence bug — direct SMTP 587 ×3 retries → alternate sender; only set the dedup flag (
alerted=True) when the send actually succeeds, else a failed send suppresses future alerts forever.
- The freshness monitor must follow the LIVE channel — not the parked one (2026-08-18). After the feed moved to the mirror,
wechat-monitor.py (cron wechat-pipeline-monitor) kept polling the OLD direct-crawl DB (/Users/jinguo/data/we_mp_rss.db → MAX(create_time) stuck at the ban date) → fired bogus "481h stale" alerts for 20 days while the mirror channel was healthy. Diagnosis: compute the mirror's real freshness from raw/rss-inbox/ — a file counts as WeChat-mirror if feed_name: starts with WeChat- OR source_url: contains mp.weixin.qq.com; latest mtime among them = last discovery. If that is <48h old while the monitor says stale, the monitor is watching the wrong source → repoint it (re-verify with a dry run that prints the channel + hours). Rule: when a channel is parked under a ban, EVERY downstream monitor/alert that read it must be checked and redirected to the live mirror.
See Also
we-mp-rss-troubleshooting (user-owned) — WeChat-specific container/cookie/endpoint fix details, 200013 history, wechat2rss integration specifics
cron-management (user-owned) — cron error debugging, provider-failure waves, zombie rounds
rss-to-wiki-pipeline (user-owned) — blogwatcher/recovery pipeline operational details, WeChat-* mirror feed management