Featured image of post Solo Multi-Channel Content: How I Built My AI Automation Pipeline LynxPipe

Solo Multi-Channel Content: How I Built My AI Automation Pipeline LynxPipe

From RSS aggregation to three-way distribution across a blog, Telegram, and WeChat — all powered by a single LLM-driven content pipeline, with a 5-layer funnel, a web-verification gate against AI hallucinations, a full component list, and hard-earned lessons.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
RSS 源(12 个) + 厂商官方博客监控
  feed_pipeline.py
    抓取 → 去重(seen.json) → CPA LLM 写中英双语稿
    → lynx_reviewer.py 独立二审(事实红线/夸大标题/中英一致)
    → lynxcard_client.py 渲染文章卡(HTTP 调 LynxCard:8790)
  blog-lxlynx 内容仓(Hugo,外部仓库)
    sync_en.py 中英互同步 → png2webp.py → hugo 构建
    → wrangler 部署 Cloudflare Pages
  distribute.py 按 distribute_routes.yaml 路由分发(dist_seen.json 去重)
    ├→ Telegram @Lx_groups(经 lynxtg CF Worker)
    ├→ 公众号草稿箱(scripts/wechat/ 模块)
    └→ LinuxDo Discourse
  git 归档(blog 仓 push)

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

TriggerScheduleRunsLogs to
cron0 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
cron17 */3 * * *vendor_watch.py vendor blog monitoringruntime/vendor_watch.log
Manualpublish.sh (also triggered by the “Publish” button in the lynxBlogEdit:1314 web editor)runtime/pipeline.log
systemd timer*/30freebie-funnel.service standalone TG freebie funnel pipeline (see below)journalctl
systemd timerDailyblog-image-check.service blog image health checkjournalctl

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 same gen_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:

  1. Fetch + Deduplication: seen.json tracks what’s already been processed, so nothing runs through twice.
  2. LLM Drafting (Bilingual): Routes through a local OpenAI-compatible gateway (CPA,:8317) using the gpt-5.5 model 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.
  3. 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 to runtime/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 --minifywrangler 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):

SectorChineseEnglishPositioning
ai-toolsAI 工具AI Tools① Primary traffic driver (tools/accounts/links)
security安全技术Security② Payments/security (brand)
automation自动化Automation③ Agent/automation (differentiation)
ai-newsAI 资讯AI News④ Industry news (volume, auto pipeline)
products产品落地Products⑤ Products (brand)
deep-research深度研究Deep ResearchHand-written long-form (whitepapers/guides)

Platform Routing (which sectors to subscribe to + language + per-round cap):

PlatformSubscribed SectorsLanguagePer-Round LimitNotes
Telegram @Lx_groupsai-tools, ai-newszhToken bucket (burst of 3, then 1 per 50 min after)Via lynxtg CF Worker; 08-09 removed per-round cap
LinuxDosecurity, automation, ai-toolszh1Discourse API; tech community values depth
WeChat Official Account 凌序之心ai-news, products, security, deep-researchzh2Direct push rate-limited 3h; draft box unlimited; never auto-broadcast
X (future)ai-tools, automation, ai-newsen3English version feeds X
Xiaohongshu (future)ai-toolszh1Tutorials 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

PathResponsibility
scripts/pipe_config.pyCentral config: BLOG_ROOT / RUNTIME, env-overridable
scripts/news_rss.pyRSS source config + AI keyword filtering (aggregated from lynxhot)
scripts/feed_pipeline.pyMain news pipeline: fetch → deduplicate → CPA drafting → second review → card render
scripts/lynx_reviewer.pyIndependent LLM pre-publish review; exceptions allowed without blocking pipeline; logs to reviews.jsonl
scripts/lynxcard_client.pyLynxCard render client (HTTP-only, no file coupling)
scripts/vendor_watch.pyVendor official blog monitoring (playwright SPA scraping)
scripts/sync_en.pyCN↔EN sync: re-translate if CN is newer than EN by >60s; protects special files
scripts/distribute.pyMulti-platform distribution hub; routes per distribute_routes.yaml
scripts/distribute_routes.yamlDistribution route table: platforms subscribe to sectors, not full dump — scope changes only touch the YAML
scripts/png2webp.pyCover PNG→WebP incremental conversion (q82)
scripts/article_images.pyArticle cover images
scripts/backfill_images.pyBackfill missing images
scripts/publish.shManual publish: sync EN → WebP → build → deploy → distribute → git backup
scripts/run_pipeline.shCron 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
                 LynxPipe Pipeline
   ┌─────────┬───────┼───────┬─────────┬────────┬─────────┐
   ▼         ▼       ▼       ▼         ▼        ▼         ▼
 CPA:8317  LynxCard  lynxtg   hugo    wrangler  微信API   Tavily
 LLM Gateway :8790     Worker   ~/bin   fnm Node  Draft Box   Web Verify
 Draft/Review  Card Render   CF Worker  Hugo CF Pages   (never   (web-verify)
 Translate/Draft  TG/Xiaohongshu    Generic TG Gateway    broadcast)
              Blog          Multi-project shared     blog-lxlynx
   │                                      Shared          ▲
   ├── agnes-2.0-flash (primary funnel)                 │          │
   └── glm-5.2-fast-preview (integrated funnel)        └──────────┘
                                                       web-verify
  • 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Source collection (free AI resource RSS / sites)
Layer 1 · 14-angle screening   (baseline threshold + 14 focused lenses, culls ~90%)
Layer 2 · 7-angle review       (each item scored across 7 angles, averaged)
Layer 3 · 3-style copy         (3 drafts from the same source, re-review to pick best)
Layer 4 · 3-angle verification (fact-check / exaggeration / compliance, all pass to proceed)
    │  ← includes Tier1 fact-check lens (version / date / filing)
Layer 5 · 1 consolidation      (glm-5.2-fast-preview picks the final version)
🛡 webVerifyAndSanitize gate (Tier2 Tavily web verification)
    │  Extract version/date/partnership claims → Tavily search → LLM judge
    │  Conflicting? Use the correct value. Not found? Skip, don't push. fail-open.
worker /push route → LynxCard + push + quota
📢 @Lx_groups TG 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:

TierWhat it isStatus
Tier1Layer 4 “fact-check” lens extended to version numbers / dates / filings (LLM self-judgment, no key needed)✅ Active
Tier2webVerifyAndSanitize 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)

FilePurpose
seen.jsonfeed_pipeline deduplication pool
dist_seen.jsondistribute deduplication pool (rename keys in sync)
vendor_seen.jsonvendor_watch deduplication pool
dist_rate.jsondistribute token-bucket rate limiter
reviews.jsonllynx_reviewer second-review audit log
dist.envSecrets: WORKER_URL / RUN_KEY / TAVILY_API_KEY, etc.
pipeline.log / pipeline-err.logMain pipeline logs
vendor_watch.log / backfill.logPer-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, and data/ are all gitignored. Credentials never go into the repo.

Lessons the Hard Way (Full 8 Items)

  1. WeChat Official Account IP whitelist: A 40164 error on the official account’s /token endpoint 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 the wechat module so it connects directly.

  2. Browser UA: Python’s urllib uses a default User-Agent that Cloudflare blocks (403/1010). All outbound requests must carry a browser UA—never rely on the default.

  3. Hugo’s data/ directory: At one point, runtime state files were stored under the blog repo’s data/ folder, but Hugo interprets that directory as configuration, which breaks the build entirely. Lesson learned: always keep state files in the pipeline repo’s runtime/ directory—never touch Hugo’s reserved directories.

  4. Detecting git changes: To check whether there’s new content to commit, use git status --porcelain, not git diff --quiet—new articles are untracked files, which diff can’t see, causing false negatives and skipped commits.

  5. Pipeline exit codes: With wrangler | tail, you must set set -o pipefail; otherwise a failed deployment gets silently swallowed and reports success.

  6. 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 in dist_seen.json.

  7. wrangler PATH mismatch: wrangler is installed in fnm’s (Node version manager global bin, but cron’s default PATH doesn’t include it, so deployments fail with command 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.

  8. wrangler deployment credentials: In cron’s non-interactive environment, you must explicitly provide CLOUDFLARE_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: