A Chinese-language tech news platform that brings together RSS fetching, DeepL translation, and Gemini AI summaries in one place.
Fetches global tech news sources on a schedule โ auto-translates English titles โ presents them clearly grouped by source.
English ยท ็ฎไฝไธญๆ
Features ยท Architecture Overview ยท Quick Start ยท Project Structure
|
๐ Multi-source Aggregation Articles grouped by source, homepage sorted by priority, with lazy loading for stale sources |
๐ English Title Translation DeepL API automatically translates Latin-script titles to Chinese, falling back to the original on failure |
|
๐ค AI Article Summaries One-click 50โ80 character Chinese summaries (Gemini flash-lite), results cached in D1 |
๐ฅ V2EX Hot Topics Support Firecrawl scrapes the homepage hot list + fetches full content item by item, covering sources without RSS |
|
โก Async Fetching Cloudflare Queues for async consumption, avoiding synchronous blocking on Pages |
๐ Hidden Admin Panel Triple-click the title to enter /fish, protected by GitHub login, admin-only access |
|
๐ค User Login & Read State GitHub OAuth login (better-auth), read status persisted in D1 |
โฌ๏ธ Works Offline Too Anonymous users fall back to localStorage for read state โ the anonymous experience is unchanged |
Two deployable units + one shared library, with the fetch logic maintained in a single place:
- web (Cloudflare Pages): TanStack Start SSR + Hono API โ both frontend and API server, and the Queue producer.
- fetcher (Cloudflare Workers): a cron-triggered fetching Worker, the Queue consumer, responsible for fetching RSS โ dedup โ DeepL translation โ writing to D1.
- @repo/ingest (packages/ingest): the core fetching library, shared by fetcher and web.
- Node.js โฅ 20 + pnpm 9
- Wrangler CLI logged in
pnpm installwrangler d1 create news-aggregator
# Fill the generated database_id into apps/web/wrangler.toml and apps/fetcher/wrangler.toml
wrangler d1 migrations apply news-aggregator --localwrangler queues create news-fetch-queue# Fetcher
cp apps/fetcher/.dev.vars.example apps/fetcher/.dev.vars
# Fill in DEEPL_API_KEY, FIRECRAWL_API_KEY
# Web โ AI article summaries
echo "GEMINI_API_KEY=your_key" >> apps/web/.dev.vars# Terminal 1: frontend + API
pnpm dev:web
# Terminal 2: fetching Worker
pnpm dev:fetcherVisit http://localhost:3000 for the homepage. The admin panel lives at /fish (triple-click the homepage title to enter).
Local development note: when the web
.dev.varssetsDIRECT_FETCH=true, the API skips the Queue and fetches synchronously; locally the fetcher and web do not share a Queue.
.
โโโ apps/
โ โโโ web/ # TanStack Start frontend + Hono API (Cloudflare Pages)
โ โ โโโ app/routes/ # File routes (index.tsx / fish.tsx / api/$.ts)
โ โ โโโ app/server/ # Hono routes (articles / sources / auth)
โ โ โโโ app/components/ # React components
โ โโโ fetcher/ # Cron fetching Worker (src/index.ts only, logic reused from @repo/ingest)
โโโ packages/
โ โโโ db/ # Drizzle schema + D1 client (sources / articles tables)
โ โโโ ingest/ # Core fetching library (RSS / dedup / DeepL / Firecrawl / V2EX)
โ โโโ shared/ # Shared types & utilities (normalizeUrl / shouldFetchNow, etc.)
โ โโโ telemetry/ # Structured logging (logger)
โโโ turbo.json # build / dev / typecheck / lint / deploy
โโโ pnpm-workspace.yaml
โโโ .pnpmfile.cjs # Pins @tanstack/* to 1.114.1
Two D1 tables (full definitions in packages/db/src/schema.ts):
| Table | Key columns |
|---|---|
| sources | id / name / url / priority / fetch_frequency (hourly|twice_daily|daily) / is_active / last_fetched_at |
| articles | id / source_id (FK cascade) / title / translated_title / url (unique constraint) / published_at / summary (AI cache) / metadata (json) |
Key indexes: articles_source_url_idx (per-source dedup across URLs), articles_source_published_idx, sources_priority_idx
All APIs are mounted under /api/* via app/routes/api/$.ts, with request validation via zValidator + Zod:
| Method | Path | Description |
|---|---|---|
| GET | /api/articles/grouped?scope=active|stale |
Articles grouped by source; active returns active sources only, with an X-Has-Stale response header |
| GET | /api/articles/:id/summary |
AI summary (four-level fallback: D1 cache โ live fetch with real browser UA โ AMP variant โ RSS intro โ Browser Rendering) |
| POST | /api/sources/:id/fetch |
Trigger a fetch for a single source |
| POST | /api/sources/fetch-all |
Trigger a fetch for all sources |
| POST | /api/sources/detect |
Auto-detect an RSS feed from a URL |
* |
/api/sources/* |
Source CRUD / bulk enable-disable / bulk delete |
| GET | /api/read/articles |
Get the current user's read article ID list |
| POST | /api/read/articles |
Mark a single article as read |
| POST | /api/read/articles/batch |
Mark multiple articles as read |
* |
/api/auth/* |
better-auth: GitHub OAuth login / logout / session |
husky runs the quality gates automatically before each commit (cannot be skipped with --no-verify):
pnpm lint # Biome checks (formatting + import sorting + lint)
pnpm typecheck # tsc --noEmit across all workspaces
pnpm test # Vitest unit tests (node pool, pure logic)Other common commands:
pnpm lint:fix # Biome auto-fix + formatting
pnpm test:watch # Unit tests in watch mode
pnpm test:e2e # End-to-end tests (real workerd + real D1, run manually)
pnpm build # Build all workspaces
pnpm cleanup # Clean up orphaned workerd / esbuild processes
pnpm db:generate # Generate Drizzle migrations- Create the D1 database
news-aggregatorin the Cloudflare dashboard and copy itsdatabase_idinto bothwrangler.tomlfiles - Create the Queue
news-fetch-queue - Apply migrations:
wrangler d1 migrations apply news-aggregator --remote - Configure secrets (
wrangler secret put <NAME>):
| Secret | Location | Purpose |
|---|---|---|
DEEPL_API_KEY |
fetcher | DeepL translation |
FIRECRAWL_API_KEY |
fetcher | V2EX anti-scraping fetch |
GEMINI_API_KEY |
web | AI article summaries |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
web | GitHub OAuth login |
BETTER_AUTH_SECRET |
web | Session signing (login state is lost after deploy if unset) |
GitHub OAuth note: an OAuth App can only have one callback URL. Recommended: create two Apps (production + local), or temporarily swap credentials during local development.
pnpm deploy:fetcher # Deploy the fetching Worker
pnpm deploy:web # Deploy the frontend + APIbetter-auth tables are created automatically on first request. After the first deploy, you may manually trigger a migration once (to avoid being swallowed by the /api/auth/* wildcard):
curl -X POST https://<your-domain>/api/admin-migrate- Local D1 separation: web and fetcher each use their own sqlite file โ after adding a migration, apply it to both local databases
- TanStack version pinning:
.pnpmfile.cjshard-pins@tanstack/*to1.114.1; update that file in sync when upgrading - Single static assets directory: favicon / logo /
_headerslive only inapps/web/public/; the rootpublic/has been removed - Test content-extraction changes for real: live fetching for article summaries relies on
@mozilla/readabilityโ after changingpackages/shared/src/extract.ts, always test against real article pages - Local fetcher doesn't receive web's messages: locally the two processes don't share a Queue; web needs
DIRECT_FETCH=trueto fetch synchronously