Running a blog, a Telegram channel, and a WeChat public account all by myself—the real bottleneck isn’t running out of ideas, it’s keeping up. Every day there’s an overwhelming flood of valuable AI news, and manually translating, rewriting, sourcing images, laying out, and distributing it all can eat up half a day before you even start. So I architected it as a pipeline I call LynxPipe: raw inputs go in, polished articles come out, and distribution to the blog, TG, and WeChat happens automatically. All the “moving parts” live in a single repo.
This post pulls back the curtain on the pipeline’s architecture, components, routing logic, and the hard-learned lessons along the way.
A Bird’s-Eye View of the Pipeline
| |
In one sentence: The blog repo only holds Hugo content; all the “moving logic” lives in the LynxPipe repo. This separation keeps the content site lean—tweaking the pipeline never touches the content, and vice versa. As of 2026-08-09, pipeline scripts formerly scattered across blog-lxlynx/scripts, lynxhot/scripts, and lynxWechat have all been consolidated into LynxPipe.
Trigger Layer: cron + systemd timer
| Trigger | Schedule | Runs | Logs to |
|---|---|---|---|
| cron | 0 5,12,17 * * * | run_pipeline.sh full chain (fetch → write → review → card → build → deploy → distribute → git) | runtime/pipeline.log + pipeline-err.log |
| cron | */30 * * * * | distribute.py incremental distribution (no re-fetching/building, just pushes new articles) | runtime/pipeline.log |
| cron | 17 */3 * * * | vendor_watch.py vendor blog monitoring | runtime/vendor_watch.log |
| Manual | — | publish.sh (also triggered by the “Publish” button in the lynxBlogEdit:1314 web editor) | runtime/pipeline.log |
| systemd timer | */30 | freebie-funnel.service standalone TG freebie funnel pipeline (see below) | journalctl |
| systemd timer | Daily | blog-image-check.service blog image health check | journalctl |
Worth noting: early docs specified “9/15/21 daily, 2 articles per run.” Starting 2026-08-11, it was changed to 5/12/17 daily, limit 10 per run—removing the hard cap (10 is just a safety ceiling to prevent a maintenance backlog from burning through the LLM quota in a single burst; anything beyond it stays in the source queue for the next run, zero data loss).
Input Layer: RSS + Vendor Blogs, Walking on Two Legs
RSS alone isn’t enough. RSS feeds serve up aggregated news, but first-party blogs from vendors like Anthropic, DeepMind, DeepSeek, and 智谱 (z.ai) are often the primary source. Plus, many of these are single-page applications (SPAs) that RSS scrapers simply can’t parse for body text.
So the input layer runs on two tracks:
news_rss.py: 12 RSS sources + AI keyword filtering (allowlist/blacklist) to cut the noise before it hits the pipeline. Consolidated from lynxhot.vendor_watch.py: Uses Playwright to render vendor blog listing pages, scrapes the full text, and hands it off to the samegen_article()function for rewriting. Runs every 3 hours.
Both tracks merge at a single deduplication entry point, then follow the exact same downstream flow. That way, both aggregated news and first-party vendor content get in without duplicates.
Processing Layer: Drafting, Second Review, and Card Rendering
This is where the pipeline does the heavy lifting—three steps chained together:
- Fetch + Deduplication:
seen.jsontracks what’s already been processed, so nothing runs through twice. - LLM Drafting (Bilingual): Routes through a local OpenAI-compatible gateway (CPA,:8317) using the
gpt-5.5model to generate both Chinese and English versions in one shot. Capped at 10 articles per run—not to throttle output, but to prevent a backlog from exhausting the LLM quota in a single burst. - Independent Second Review
lynx_reviewer.py: Drafts aren’t published straight away. They go through a separate LLM checkpoint that scans for three things: factual red lines, clickbait titles, and CN/EN consistency. If the reviewer throws an exception, the article passes through unblocked but is logged toruntime/reviews.jsonl. It’s a deliberate trade-off: better to ship with a flag than let a failed reviewer bottle-neck the entire pipeline. Post-hoc fixes are handled by reviewing the audit log.
Build & Deploy: Hugo + Cloudflare Pages
The blog is a Hugo site deployed to Cloudflare Pages (project: blog-lxlynx). The build flow is: CN↔EN sync → cover PNG to WebP (quality 82, for smaller payloads) → hugo --minify → wrangler pages deploy to push live.
A key detail on CN/EN sync: re-translation only triggers if the Chinese version is newer than the English by more than 60 seconds. We don’t full re-translate every time—otherwise, changing a single character would burn tokens regenerating the whole article. sync_en.py also uses PRESERVE_KEYS/EXCLUDE_FILES to shield special files from accidental translation.
Distribution Layer: Category-Based Subscriptions, Not Bulk Push
This is the part I’m most proud of. The early version blindly pushed every new article to every platform, which floocked TG, got the WeChat public account rate-limited, and made forums treat us like a reposter spam account. So I switched to a routing table:
Each platform subscribes to specific categories instead of ingesting everything. Articles are matched against categories in their frontmatter and only routed to platforms that actually follow that category. Want to adjust distribution scope? Just edit one YAML file (distribute_routes.yaml)—zero code changes.
Six category buckets (pull your article frontmatter categories from these):
| Sector | Chinese | English | Positioning |
|---|---|---|---|
ai-tools | AI 工具 | AI Tools | ① Primary traffic driver (tools/accounts/links) |
security | 安全技术 | Security | ② Payments/security (brand) |
automation | 自动化 | Automation | ③ Agent/automation (differentiation) |
ai-news | AI 资讯 | AI News | ④ Industry news (volume, auto pipeline) |
products | 产品落地 | Products | ⑤ Products (brand) |
deep-research | 深度研究 | Deep Research | Hand-written long-form (whitepapers/guides) |
Platform Routing (which sectors to subscribe to + language + per-round cap):
| Platform | Subscribed Sectors | Language | Per-Round Limit | Notes |
|---|---|---|---|---|
| Telegram @Lx_groups | ai-tools, ai-news | zh | Token bucket (burst of 3, then 1 per 50 min after) | Via lynxtg CF Worker; 08-09 removed per-round cap |
| LinuxDo | security, automation, ai-tools | zh | 1 | Discourse API; tech community values depth |
| WeChat Official Account 凌序之心 | ai-news, products, security, deep-research | zh | 2 | Direct push rate-limited 3h; draft box unlimited; never auto-broadcast |
| X (future) | ai-tools, automation, ai-news | en | 3 | English version feeds X |
| Xiaohongshu (future) | ai-tools | zh | 1 | Tutorials rewritten for format |
Key trade-offs:
- WeChat drafts only — never auto-broadcast. All broadcasts are always triggered manually from the admin backend. This is a red line; an auto-broadcast failure is irrecoverable.
- Telegram replaced the per-round cap with a token bucket: a burst of 3 posts, then 1 per 50 minutes after. No spam, no queue throttling.
- LinuxDo posts 1 article per round, and only publishes compliance-safe “mechanism breakdown” content. Tech communities value depth, not news aggregation.
- Non-sector categories (e.g., “Announcements”) don’t match any partition and won’t be distributed.
Repository Component Inventory
| Path | Responsibility |
|---|---|
scripts/pipe_config.py | Central config: BLOG_ROOT / RUNTIME, env-overridable |
scripts/news_rss.py | RSS source config + AI keyword filtering (aggregated from lynxhot) |
scripts/feed_pipeline.py | Main news pipeline: fetch → deduplicate → CPA drafting → second review → card render |
scripts/lynx_reviewer.py | Independent LLM pre-publish review; exceptions allowed without blocking pipeline; logs to reviews.jsonl |
scripts/lynxcard_client.py | LynxCard render client (HTTP-only, no file coupling) |
scripts/vendor_watch.py | Vendor official blog monitoring (playwright SPA scraping) |
scripts/sync_en.py | CN↔EN sync: re-translate if CN is newer than EN by >60s; protects special files |
scripts/distribute.py | Multi-platform distribution hub; routes per distribute_routes.yaml |
scripts/distribute_routes.yaml | Distribution route table: platforms subscribe to sectors, not full dump — scope changes only touch the YAML |
scripts/png2webp.py | Cover PNG→WebP incremental conversion (q82) |
scripts/article_images.py | Article cover images |
scripts/backfill_images.py | Backfill missing images |
scripts/publish.sh | Manual publish: sync EN → WebP → build → deploy → distribute → git backup |
scripts/run_pipeline.sh | Cron auto-pipeline: feed → build → deploy → distribute → git archive |
scripts/wechat/ | WeChat module: push_article.py pushes to draft box; update_draft.py edits in-place (no delete/re-push, media_id unchanged); wechat_cover.py cover+HTML; wechat_draft.py draft box API; wechat_writer.py LLM viral-article drafter |
tools/ | One-off migration scripts (archived, paths stale, historical reference only) |
runtime/ | Runtime state (gitignored) |
External Service Dependency Topology
| |
- CPA(:8317): OpenAI-compatible LLM gateway handling all draft writing, review, and translation.
- LynxCard(:8790): Universal card rendering service shared across TG / Xiaohongshu / the blog, kept separate.
- lynxtg (CF Worker): Universal TG push relay, shared across multi-project use such as freebie channels, kept separate.
- hugo (
~/bin/hugo) + wrangler (fnm Node global): Build and deploy. - Tavily: Web-verify connectivity check (covered in the next section).
A Separate Pipeline: The 5-Layer Funnel in freebie-funnel
The above describes the blog-news pipeline. There’s a second, independent TG freebie-channel pipeline (freebie-funnel), not in the LynxPipe repo but part of the same content ecosystem. It does something different: it scours various free AI resources (limited-time API offers, free-tier quotas, open-source tools), selects what’s worth promoting, and pushes it to the channel.
| |
Primary model: agnes-2.0-flash. Consolidation model: glm-5.2-fast-preview. Pushed to the channel via the worker /push route.
Why so many layers? Because content quality in the freebie channel directly drives retention—one bad push and you lose followers. The deeper the funnel, the fewer false positives. The cost is burning multiple LLM calls per candidate, but the saved “follower-churn cost” far outweighs the token cost.
Guarding Against False Precision: A Web-Verification Gate (Key Point)
This came out of a recent lesson learned and deserves its own section.
One day the funnel pushed news about “Tesla vehicle OS integrating Doubao LLM.” The core fact was true (confirmed across 16 sources), but the copy listed version 2026.14.11. That looked off, so I checked online—the actual version was 2026.14.13 (verifiable from the Sina source headline). The LLM had “misremembered” the version and stated it with such confidence that the human eye couldn’t catch it. This is false precision: the model fabricates an authoritative-sounding exact number (version, date) to appear credible.
The problem: my existing review layers (the second review, Layer 4 fact-check) cannot catch false precision—they check for format self-consistency and exaggeration, not “is this version number correct on the web.” An LLM cannot self-verify factual truth.
So I added a web-verification gate in two tiers:
| Tier | What it is | Status |
|---|---|---|
| Tier1 | Layer 4 “fact-check” lens extended to version numbers / dates / filings (LLM self-judgment, no key needed) | ✅ Active |
| Tier2 | webVerifyAndSanitize Tavily web check before push (fail-open: no key / error / no precise claim → pass through as-is) | ✅ Active (reusing existing Tavily key, end-to-end API verified at 200) |
I encoded this “anti-false-precision” logic into the global discipline: any content containing specific factual claims (version / date / partnership / price) must be web-verified before publishing; do not rely on LLM self-judgment as the safety net. LLM self-judgment verifies consistency, not truth.
Runtime State Files (runtime/, gitignored)
| File | Purpose |
|---|---|
seen.json | feed_pipeline deduplication pool |
dist_seen.json | distribute deduplication pool (rename keys in sync) |
vendor_seen.json | vendor_watch deduplication pool |
dist_rate.json | distribute token-bucket rate limiter |
reviews.jsonl | lynx_reviewer second-review audit log |
dist.env | Secrets: WORKER_URL / RUN_KEY / TAVILY_API_KEY, etc. |
pipeline.log / pipeline-err.log | Main pipeline logs |
vendor_watch.log / backfill.log | Per-component logs |
Red Lines
- WeChat public account drafts only go to the draft box; never auto-publish. All mass sends are done manually via the backend.
- The blog does not link to pay.153.ink (not my site); no mainland Chinese media (WeChat public account / WeChat) on the blog.
runtime/,.env, anddata/are all gitignored. Credentials never go into the repo.
Lessons the Hard Way (Full 8 Items)
WeChat Official Account IP whitelist: A 40164 error on the official account’s
/tokenendpoint means your outbound IP isn’t on the whitelist. Since Clash is running a proxy on port 8899 locally, the outbound IP keeps changing, which inevitably breaks the WeChat token API. The fix is to strip the proxy environment variables at the script level in thewechatmodule so it connects directly.Browser UA: Python’s
urllibuses a default User-Agent that Cloudflare blocks (403/1010). All outbound requests must carry a browser UA—never rely on the default.Hugo’s
data/directory: At one point, runtime state files were stored under the blog repo’sdata/folder, but Hugo interprets that directory as configuration, which breaks the build entirely. Lesson learned: always keep state files in the pipeline repo’sruntime/directory—never touch Hugo’s reserved directories.Detecting git changes: To check whether there’s new content to commit, use
git status --porcelain, notgit diff --quiet—new articles are untracked files, whichdiffcan’t see, causing false negatives and skipped commits.Pipeline exit codes: With
wrangler | tail, you must setset -o pipefail; otherwise a failed deployment gets silently swallowed and reports success.Full re-translation with
sync_en: When bulk-updating content files, you must bump the mtime of the English files first. Otherwise, the sync logic will conclude “Chinese is newer than English” and trigger a full re-translation, burning through your tokens in seconds. Renaming an article also requires同步 updating the corresponding key indist_seen.json.wranglerPATH mismatch:wrangleris installed in fnm’s (Node version manager global bin, but cron’s default PATH doesn’t include it, so deployments fail withcommand not found. Hard-coding a Node version number also breaks after an fnm upgrade (real case: after upgrading from v22.22.2 to v24.18.0, all deployments failed for three consecutive days). The fix is to use a wildcard to match$HOME/.local/share/fnm/node-versions/*/installation/bin.wranglerdeployment credentials: In cron’s non-interactive environment, you must explicitly provideCLOUDFLARE_API_TOKEN+CLOUDFLARE_ACCOUNT_ID+ the proxy on port 8899; otherwise you’ll hit “In a non-interactive environment, it’s necessary to set a CLOUDFLARE_API_TOKEN” and the deployment fails entirely (confirmed in practice on 08-10, 08-11, and 08-14).
Philosophical Takeaways
After building out this pipeline, here are a few design principles worth sharing:
- Consolidate mutable logic into a single repo, and keep the content site lean. Pipeline changes never touch the content, and content changes never touch the pipeline.
- Distribute by section subscription, not a blanket push. Different platforms consume different content; a one-size-fits-all full push either floods feeds or gets rate-limited.
- Err on the side of over-filtering. A five-layer funnel for free-tier channels may look excessive, but the follower loss from a single mis-push far outweighs the cost of burning a few extra tokens.
- Verify with live network access, not LLM self-judgment. LLMs check self-consistency, not truthfulness. Fake precision—version numbers, dates, etc.—is hard for humans to spot, so live verification is the necessary safety net.
- Fail-open: No verification or review step should ever block the pipeline. Better to let flawed content through and remediate later from the audit log than to halt everything.
Once this pipeline stabilized, I was able to sustain daily updates across three channels alone. The time saved goes toward writing in-depth long-form articles and doing work that genuinely requires human judgment.
Thanks to the Linux.do and OpenClaw communities for the ideas and tooling support throughout this build.
Further reading:
