From eaf31ad8e69686664943ae6f3ff1be10f81a4115 Mon Sep 17 00:00:00 2001
From: David Gabriel <54734492+Felix8686@users.noreply.github.com>
Date: Sun, 30 Aug 2026 10:19:42 +0800
Subject: [PATCH 01/19] feat: implement AI-Radar Cloudflare MVP core
---
.gitignore | 6 +++
README.md | 79 ++++++++++++++++++++-------
migrations/0001_init.sql | 91 ++++++++++++++++++++++++++++++++
migrations/0002_seed_sources.sql | 19 +++++++
package.json | 18 +++++++
public/app.js | 1 +
public/index.html | 1 +
public/styles.css | 1 +
src/collectors.ts | 70 ++++++++++++++++++++++++
src/daily.ts | 11 ++++
src/db.ts | 35 ++++++++++++
src/index.ts | 15 ++++++
src/rules.ts | 40 ++++++++++++++
src/telegram.ts | 6 +++
src/types.ts | 51 ++++++++++++++++++
src/utils.ts | 22 ++++++++
tsconfig.json | 14 +++++
wrangler.jsonc | 37 +++++++++++++
18 files changed, 497 insertions(+), 20 deletions(-)
create mode 100644 .gitignore
create mode 100644 migrations/0001_init.sql
create mode 100644 migrations/0002_seed_sources.sql
create mode 100644 package.json
create mode 100644 public/app.js
create mode 100644 public/index.html
create mode 100644 public/styles.css
create mode 100644 src/collectors.ts
create mode 100644 src/daily.ts
create mode 100644 src/db.ts
create mode 100644 src/index.ts
create mode 100644 src/rules.ts
create mode 100644 src/telegram.ts
create mode 100644 src/types.ts
create mode 100644 src/utils.ts
create mode 100644 tsconfig.json
create mode 100644 wrangler.jsonc
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..036bcb4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.dev.vars
+.env
+.wrangler/
+dist/
+*.log
diff --git a/README.md b/README.md
index 651edc1..c47440b 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,68 @@
# AI-Radar
-AI-Radar is a zero-cost-first, Cloudflare-native workflow for discovering AI coding-plan, API, token, free-credit, limited-time offer, and price-change information.
+AI-Radar is a zero-cost-first, Cloudflare-native workflow for discovering AI Coding Plan, API, Token, free-credit, limited-time offer, and price-change information.
-## Product direction
-
-- Cloud-first: runs on Cloudflare Workers without a local always-on machine.
-- Zero-cost-first: the default architecture must stay within free tiers; paid APIs are not required.
-- Low-token monitoring: HTTP/RSS/GitHub/page-change detection runs continuously; AI is only used after a meaningful change is found.
-- Telegram-first delivery: P1 items are pushed immediately; P2/P3 are summarized into a daily report at 12:30 Asia/Shanghai.
-- Public-ready: the public web view is separated from the admin/control plane from the beginning.
-
-## MVP pipeline
+## MVP architecture
```text
-Sources
- -> collectors (RSS / Web / GitHub / future X adapter)
- -> change detection + deduplication
- -> deterministic rules
- -> optional Workers AI enrichment
- -> D1
- -> P1 Telegram push
- -> P2/P3 daily report
- -> public web dashboard
+RSS / Web / GitHub / future X adapter
+ ↓
+Cloudflare Cron (every 5 minutes)
+ ↓
+ETag / Last-Modified / content hash
+ ↓
+Dedup + deterministic rules
+ ↓
+Optional Workers AI enrichment
+ ↓
+Cloudflare D1
+ ↙ ↘
+P1 Telegram P2/P3 daily report
+immediate push 12:30 Asia/Shanghai
+ ↓
+ public web page
```
+The public UI never exposes the internal P1/P2/P3 field. It only affects delivery and ordering.
+
+## Zero-cost rule
+
+The default system must not require paid APIs or paid infrastructure. Workers AI is optional and disabled by default. If a source requires a paid API, the collector should degrade or remain unsupported rather than silently incur cost.
+
+## Current collectors
+
+- RSS / Atom
+- Ordinary HTML pages with conditional requests and content hashing
+- GitHub repositories through public Atom feeds
+- X adapter reserved, intentionally not implemented until a sustainable zero-cost method is selected
+
+## Cloudflare bindings
+
+The Worker expects `DB` (D1), `AI` (Workers AI), and `ASSETS` (Static Assets). Secrets: `ADMIN_TOKEN`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, optional `TELEGRAM_TOPIC_P1`, optional `TELEGRAM_TOPIC_DAILY`. Set `PUBLIC_BASE_URL` after deployment. Never commit secrets.
+
+## Initial setup
+
+1. Create D1 database `ai-radar-db`.
+2. Replace `REPLACE_WITH_D1_DATABASE_ID` in `wrangler.jsonc`.
+3. `npm install`
+4. `npm run db:migrate:remote`
+5. Configure secrets with `wrangler secret put ...`.
+6. `npm run deploy`
+
+Cloudflare Cron runs in UTC. `30 4 * * *` equals 12:30 Asia/Shanghai.
+
+## API
+
+Public: `GET /api/health`, `GET /api/items`, `GET /daily/YYYY-MM-DD`, `GET /latest`.
+
+Admin (Bearer `ADMIN_TOKEN`): `GET/POST /api/admin/sources`, `POST /api/admin/run-harvest`, `POST /api/admin/run-daily`.
+
+## Delivery policy
+
+- P1: immediate Telegram push.
+- P2/P3: daily report at 12:30 Asia/Shanghai.
+- First fetch establishes a baseline and does not flood Telegram.
+
## Status
-Initial implementation in progress.
+MVP core is implemented on the `ai-radar-mvp` branch. Cloudflare resource creation, Telegram secrets, live deployment, and X acquisition remain for the deployment/configuration phase.
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql
new file mode 100644
index 0000000..8cc33fb
--- /dev/null
+++ b/migrations/0001_init.sql
@@ -0,0 +1,91 @@
+PRAGMA foreign_keys = ON;
+
+CREATE TABLE IF NOT EXISTS sources (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ url TEXT NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('rss','web','github','x')),
+ trust_level TEXT NOT NULL DEFAULT 'C' CHECK(trust_level IN ('A','B','C','D')),
+ enabled INTEGER NOT NULL DEFAULT 1,
+ interval_minutes INTEGER NOT NULL DEFAULT 15,
+ config_json TEXT,
+ etag TEXT,
+ last_modified TEXT,
+ content_hash TEXT,
+ next_fetch_at TEXT,
+ last_fetch_at TEXT,
+ last_success_at TEXT,
+ failure_count INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'new',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_sources_due ON sources(enabled, next_fetch_at);
+
+CREATE TABLE IF NOT EXISTS items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source_id INTEGER NOT NULL,
+ external_id TEXT,
+ fingerprint TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ summary TEXT,
+ url TEXT,
+ published_at TEXT,
+ discovered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ kind TEXT NOT NULL DEFAULT 'other',
+ priority TEXT NOT NULL DEFAULT 'P3' CHECK(priority IN ('P1','P2','P3')),
+ score INTEGER NOT NULL DEFAULT 0,
+ source_confidence TEXT NOT NULL DEFAULT 'low',
+ verification_status TEXT NOT NULL DEFAULT 'unverified',
+ vendor TEXT,
+ product TEXT,
+ previous_price REAL,
+ current_price REAL,
+ currency TEXT,
+ expires_at TEXT,
+ raw_excerpt TEXT,
+ ai_enriched INTEGER NOT NULL DEFAULT 0,
+ pushed_at TEXT,
+ daily_report_date TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_items_discovered ON items(discovered_at DESC);
+CREATE INDEX IF NOT EXISTS idx_items_priority ON items(priority, discovered_at DESC);
+CREATE INDEX IF NOT EXISTS idx_items_report ON items(daily_report_date, priority);
+
+CREATE TABLE IF NOT EXISTS price_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ item_id INTEGER,
+ vendor TEXT,
+ product TEXT,
+ price REAL NOT NULL,
+ currency TEXT,
+ observed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE SET NULL
+);
+
+CREATE TABLE IF NOT EXISTS daily_reports (
+ report_date TEXT PRIMARY KEY,
+ window_start TEXT NOT NULL,
+ window_end TEXT NOT NULL,
+ item_count INTEGER NOT NULL DEFAULT 0,
+ html TEXT NOT NULL,
+ generated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ telegram_pushed_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS fetch_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source_id INTEGER NOT NULL,
+ fetched_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ status_code INTEGER,
+ changed INTEGER NOT NULL DEFAULT 0,
+ duration_ms INTEGER,
+ error TEXT,
+ FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_fetch_logs_source ON fetch_logs(source_id, fetched_at DESC);
diff --git a/migrations/0002_seed_sources.sql b/migrations/0002_seed_sources.sql
new file mode 100644
index 0000000..77a8d6c
--- /dev/null
+++ b/migrations/0002_seed_sources.sql
@@ -0,0 +1,19 @@
+INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at)
+SELECT 'AI Coding Deals','https://github.com/codertesla/ai-coding-deals','github','B',10,
+ '{"githubOwner":"codertesla","githubRepo":"ai-coding-deals","githubMode":"commits","githubBranch":"main"}',CURRENT_TIMESTAMP
+WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://github.com/codertesla/ai-coding-deals');
+
+INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at)
+SELECT 'LLM Price Tracker','https://github.com/llerandi/llm-price-tracker','github','B',15,
+ '{"githubOwner":"llerandi","githubRepo":"llm-price-tracker","githubMode":"commits","githubBranch":"main"}',CURRENT_TIMESTAMP
+WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://github.com/llerandi/llm-price-tracker');
+
+INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at)
+SELECT 'Free LLM API Resources','https://github.com/cheahjs/free-llm-api-resources','github','B',15,
+ '{"githubOwner":"cheahjs","githubRepo":"free-llm-api-resources","githubMode":"commits","githubBranch":"main"}',CURRENT_TIMESTAMP
+WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://github.com/cheahjs/free-llm-api-resources');
+
+INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at)
+SELECT 'Coding Plan CN','https://github.com/xiaotiewinner/coding-plan','github','B',15,
+ '{"githubOwner":"xiaotiewinner","githubRepo":"coding-plan","githubMode":"commits","githubBranch":"main"}',CURRENT_TIMESTAMP
+WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://github.com/xiaotiewinner/coding-plan');
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6f16140
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "ai-radar",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "wrangler dev --test-scheduled",
+ "deploy": "wrangler deploy",
+ "types": "wrangler types",
+ "db:migrate:local": "wrangler d1 migrations apply ai-radar-db --local",
+ "db:migrate:remote": "wrangler d1 migrations apply ai-radar-db --remote"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "latest",
+ "typescript": "^5.9.2",
+ "wrangler": "^4.0.0"
+ }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..33ddc10
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1 @@
+const root=document.querySelector('#items');const kindSelect=document.querySelector('#kind');let all=[];const labels={free_credit:'免费额度',limited_offer:'限时优惠',price_drop:'降价',price_change:'价格变化',new_plan:'新套餐',other:'其他'};function esc(v=''){return String(v).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}function render(){const selected=kindSelect.value;const items=selected?all.filter(x=>x.kind===selected):all;if(!items.length){root.innerHTML='
暂无符合条件的信息。
';return;}root.innerHTML=items.map(x=>`${esc(labels[x.kind]||'其他')} ${esc(x.source_name||'')}
${esc(x.title)} ${x.summary?`${esc(x.summary.slice(0,420))}
`:''} `).join('');}async function load(){try{const res=await fetch('/api/items?limit=100');if(!res.ok)throw new Error(`HTTP ${res.status}`);all=await res.json();render();}catch(e){root.innerHTML=`加载失败:${esc(e.message)}
`;}}kindSelect.addEventListener('change',render);load();
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..7ee0b5e
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1 @@
+AI-Radar 最近发现 全部类型 免费额度 限时优惠 降价 价格变化 新套餐
信息仅作价格与活动线索整理,请以原始来源的实时规则为准。
\ No newline at end of file
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..219e0a9
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1 @@
+:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#151515;background:#f5f6f8;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-height:100vh}a{color:inherit}.hero{max-width:1000px;margin:0 auto;padding:58px 24px 34px;display:flex;align-items:end;justify-content:space-between;gap:20px}.eyebrow{font-size:12px;letter-spacing:.18em;color:#737373;margin:0 0 8px}.hero h1{font-size:clamp(42px,8vw,76px);line-height:.95;letter-spacing:-.055em;margin:0}.lead{max-width:640px;color:#5d5d5d;line-height:1.65;margin:18px 0 0}.report{background:#171717;color:#fff;text-decoration:none;padding:11px 16px;border-radius:10px;white-space:nowrap}main{max-width:1000px;margin:0 auto;padding:0 24px 60px}.toolbar{display:flex;justify-content:space-between;align-items:center;border-top:1px solid #ddd;padding:18px 0}.toolbar select{border:1px solid #d8d8d8;background:white;border-radius:9px;padding:8px 10px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.card{background:#fff;border:1px solid #e4e4e4;border-radius:14px;padding:18px;min-height:190px;display:flex;flex-direction:column}.card h2{font-size:19px;line-height:1.35;margin:14px 0 10px}.card p{color:#555;line-height:1.65;margin:0 0 20px}.meta,.foot{display:flex;justify-content:space-between;align-items:center;gap:12px;color:#777;font-size:12px}.tag{border:1px solid #ddd;border-radius:999px;padding:4px 8px;color:#333}.foot{margin-top:auto}.foot a{font-weight:600;color:#111}.empty,.loading{padding:30px 0;color:#777}footer{max-width:1000px;margin:0 auto;padding:22px 24px 40px;border-top:1px solid #ddd;color:#777;font-size:12px}@media(max-width:700px){.hero{padding-top:36px;align-items:flex-start;flex-direction:column}.grid{grid-template-columns:1fr}.report{align-self:flex-start}}
\ No newline at end of file
diff --git a/src/collectors.ts b/src/collectors.ts
new file mode 100644
index 0000000..56d228b
--- /dev/null
+++ b/src/collectors.ts
@@ -0,0 +1,70 @@
+import type { Candidate, CollectResult, SourceRow } from './types';
+import { parseJson, sha256, stripHtml, textExcerpt } from './utils';
+
+interface SourceConfig { userAgent?: string; selectorHint?: string; githubMode?: 'commits' | 'releases'; githubOwner?: string; githubRepo?: string; githubBranch?: string; }
+
+function conditionalHeaders(source: SourceRow): Headers {
+ const headers = new Headers({ 'user-agent': 'AI-Radar/0.1 (+https://github.com/Felix8686/TokenRadar)', accept: '*/*' });
+ if (source.etag) headers.set('if-none-match', source.etag);
+ if (source.last_modified) headers.set('if-modified-since', source.last_modified);
+ const config = parseJson(source.config_json, {});
+ if (config.userAgent) headers.set('user-agent', config.userAgent);
+ return headers;
+}
+
+function decodeXml(value: string): string { return value.replace(//g, '$1').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").trim(); }
+function firstTag(block: string, names: string[]): string | undefined { for (const name of names) { const m = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, 'i')); if (m?.[1]) return decodeXml(m[1]); } return undefined; }
+function safeIso(value?: string): string | undefined { if (!value) return undefined; const date = new Date(value); return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); }
+
+function parseFeed(xml: string): Candidate[] {
+ const blocks = [...xml.matchAll(/<(item|entry)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi)].map((m) => m[2]);
+ return blocks.slice(0, 30).map((block) => {
+ const title = firstTag(block, ['title']) || 'Untitled update';
+ const id = firstTag(block, ['guid', 'id']);
+ const summary = firstTag(block, ['description', 'summary', 'content', 'content:encoded']);
+ const publishedAt = firstTag(block, ['pubDate', 'published', 'updated']);
+ const hrefMatch = block.match(/ ]+href=["']([^"']+)["'][^>]*>/i);
+ const url = hrefMatch?.[1] || firstTag(block, ['link']);
+ return { externalId: id || url || `${title}:${publishedAt || ''}`, title: textExcerpt(title, 300), summary: summary ? textExcerpt(summary, 1400) : undefined, rawExcerpt: summary ? textExcerpt(summary, 1800) : textExcerpt(title, 1800), url, publishedAt: safeIso(publishedAt) };
+ });
+}
+
+async function fetchText(source: SourceRow, url: string, accept: string): Promise<{ response: Response; text?: string }> {
+ const headers = conditionalHeaders(source); headers.set('accept', accept);
+ const response = await fetch(url, { headers, redirect: 'follow' });
+ if (response.status === 304) return { response };
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
+ return { response, text: await response.text() };
+}
+
+async function collectFeed(source: SourceRow, feedUrl = source.url): Promise {
+ const { response, text } = await fetchText(source, feedUrl, 'application/atom+xml, application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8');
+ if (response.status === 304) return { statusCode: 304, notModified: true, candidates: [] };
+ const body = text || '';
+ return { statusCode: response.status, notModified: false, etag: response.headers.get('etag') || undefined, lastModified: response.headers.get('last-modified') || undefined, contentHash: await sha256(body), candidates: parseFeed(body) };
+}
+
+async function collectWeb(source: SourceRow): Promise {
+ const { response, text } = await fetchText(source, source.url, 'text/html, text/plain;q=0.9, */*;q=0.8');
+ if (response.status === 304) return { statusCode: 304, notModified: true, candidates: [] };
+ const body = text || ''; const normalized = stripHtml(body); const excerpt = normalized.length <= 1800 ? normalized : `${normalized.slice(0, 1800)}…`;
+ return { statusCode: response.status, notModified: false, etag: response.headers.get('etag') || undefined, lastModified: response.headers.get('last-modified') || undefined, contentHash: await sha256(normalized), candidates: [{ externalId: await sha256(`${source.url}:${excerpt}`), title: `${source.name} changed`, summary: excerpt, rawExcerpt: excerpt, url: source.url }] };
+}
+
+async function collectGitHub(source: SourceRow): Promise {
+ const config = parseJson(source.config_json, {});
+ if (!config.githubOwner || !config.githubRepo) return collectFeed(source);
+ const mode = config.githubMode || 'releases';
+ const feedUrl = mode === 'commits' ? `https://github.com/${config.githubOwner}/${config.githubRepo}/commits/${config.githubBranch || 'main'}.atom` : `https://github.com/${config.githubOwner}/${config.githubRepo}/releases.atom`;
+ return collectFeed(source, feedUrl);
+}
+
+export async function collectSource(source: SourceRow): Promise {
+ switch (source.type) {
+ case 'rss': return collectFeed(source);
+ case 'web': return collectWeb(source);
+ case 'github': return collectGitHub(source);
+ case 'x': throw new Error('X collector adapter is reserved but not implemented in the zero-cost MVP');
+ default: throw new Error(`Unsupported source type: ${source.type}`);
+ }
+}
diff --git a/src/daily.ts b/src/daily.ts
new file mode 100644
index 0000000..532aad5
--- /dev/null
+++ b/src/daily.ts
@@ -0,0 +1,11 @@
+import type { Env, ItemRow } from './types';
+import { escapeHtml } from './utils';
+import { getReportItems } from './db';
+import { pushDailyReport } from './telegram';
+function beijingWindow(now=new Date()):{reportDate:string;start:string;end:string}{const shifted=new Date(now.getTime()+8*60*60*1000);const y=shifted.getUTCFullYear(),m=shifted.getUTCMonth(),d=shifted.getUTCDate();const endUtc=new Date(Date.UTC(y,m,d,4,0,0));const startUtc=new Date(endUtc.getTime()-24*60*60*1000);const reportDate=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;return{reportDate,start:startUtc.toISOString(),end:endUtc.toISOString()};}
+function kindLabel(kind:string):string{return({free_credit:'免费额度',limited_offer:'限时优惠',price_drop:'降价',price_change:'价格变化',new_plan:'新套餐',other:'其他'}as Record)[kind]||'其他';}
+function itemCard(item:ItemRow&{source_name:string}):string{const status=item.verification_status==='official_confirmed'?'官方确认':item.verification_status==='cross_verified'?'交叉验证':'未核实';return `${escapeHtml(kindLabel(item.kind))} ${escapeHtml(status)}
${escapeHtml(item.title)} ${item.summary?`${escapeHtml(item.summary.slice(0,900))}
`:''} `;}
+function renderReport(reportDate:string,items:(ItemRow&{source_name:string})[]):string{const p2=items.filter(x=>x.priority==='P2'),p3=items.filter(x=>x.priority==='P3');return `AI-Radar 日报 ${escapeHtml(reportDate)} AI-Radar 日报 ${escapeHtml(reportDate)} · 每日 12:30(北京时间)推送
值得关注 ${p2.length?p2.map(itemCard).join(''):'今天没有值得关注的信息。
'}其他线索 ${p3.length?p3.map(itemCard).join(''):'今天没有其他线索。
'} `;}
+export async function generateDailyReport(env:Env,now=new Date()):Promise<{reportDate:string;itemCount:number;pushed:boolean}>{const w=beijingWindow(now);const items=await getReportItems(env.DB,w.start,w.end);const html=renderReport(w.reportDate,items);await env.DB.prepare(`INSERT INTO daily_reports(report_date,window_start,window_end,item_count,html,generated_at) VALUES(?1,?2,?3,?4,?5,CURRENT_TIMESTAMP) ON CONFLICT(report_date) DO UPDATE SET window_start=excluded.window_start,window_end=excluded.window_end,item_count=excluded.item_count,html=excluded.html,generated_at=CURRENT_TIMESTAMP`).bind(w.reportDate,w.start,w.end,items.length,html).run();const counts={p2:items.filter(x=>x.priority==='P2').length,p3:items.filter(x=>x.priority==='P3').length};const pushed=await pushDailyReport(env,w.reportDate,counts);if(pushed)await env.DB.prepare('UPDATE daily_reports SET telegram_pushed_at=CURRENT_TIMESTAMP WHERE report_date=?1').bind(w.reportDate).run();return{reportDate:w.reportDate,itemCount:items.length,pushed};}
+export async function getDailyReport(env:Env,date:string):Promise{const row=await env.DB.prepare('SELECT html FROM daily_reports WHERE report_date=?1').bind(date).first<{html:string}>();if(!row)return new Response('Report not found',{status:404});return new Response(row.html,{headers:{'content-type':'text/html; charset=utf-8','cache-control':'public, max-age=300'}});}
+export async function getLatestReport(env:Env):Promise{const row=await env.DB.prepare('SELECT report_date FROM daily_reports ORDER BY report_date DESC LIMIT 1').first<{report_date:string}>();if(!row)return new Response('No report yet',{status:404});return new Response(null,{status:302,headers:{location:`/daily/${row.report_date}`}});}
diff --git a/src/db.ts b/src/db.ts
new file mode 100644
index 0000000..f9eb41a
--- /dev/null
+++ b/src/db.ts
@@ -0,0 +1,35 @@
+import type { Candidate, Classification, ItemRow, SourceRow } from './types';
+import { addMinutesIso, isoNow, sha256 } from './utils';
+
+export async function getDueSources(db: D1Database, limit: number): Promise {
+ const result = await db.prepare(`SELECT * FROM sources WHERE enabled = 1 AND (next_fetch_at IS NULL OR next_fetch_at <= ?1) ORDER BY COALESCE(next_fetch_at, created_at) ASC LIMIT ?2`).bind(isoNow(), limit).all();
+ return result.results || [];
+}
+
+export async function saveFetchSuccess(db: D1Database, source: SourceRow, state: { etag?: string; lastModified?: string; contentHash?: string; statusCode: number; changed: boolean; durationMs: number }): Promise {
+ const now = new Date();
+ await db.batch([
+ db.prepare(`UPDATE sources SET etag=COALESCE(?1,etag),last_modified=COALESCE(?2,last_modified),content_hash=COALESCE(?3,content_hash),next_fetch_at=?4,last_fetch_at=?5,last_success_at=?5,failure_count=0,status='ok',updated_at=?5 WHERE id=?6`).bind(state.etag || null,state.lastModified || null,state.contentHash || null,addMinutesIso(now,source.interval_minutes),now.toISOString(),source.id),
+ db.prepare(`INSERT INTO fetch_logs(source_id,status_code,changed,duration_ms) VALUES(?1,?2,?3,?4)`).bind(source.id,state.statusCode,state.changed ? 1 : 0,state.durationMs),
+ ]);
+}
+
+export async function saveFetchFailure(db: D1Database, source: SourceRow, error: unknown, durationMs: number): Promise {
+ const now = new Date(); const failureCount = source.failure_count + 1; const backoff = Math.min(source.interval_minutes * Math.max(2, 2 ** Math.min(failureCount, 5)), 360); const message = error instanceof Error ? error.message : String(error);
+ await db.batch([
+ db.prepare(`UPDATE sources SET next_fetch_at=?1,last_fetch_at=?2,failure_count=?3,status='error',updated_at=?2 WHERE id=?4`).bind(addMinutesIso(now,backoff),now.toISOString(),failureCount,source.id),
+ db.prepare(`INSERT INTO fetch_logs(source_id,duration_ms,error) VALUES(?1,?2,?3)`).bind(source.id,durationMs,message.slice(0,800)),
+ ]);
+}
+
+function canonicalUrl(value: string): string { try { const url = new URL(value); for (const key of [...url.searchParams.keys()]) { if (key.toLowerCase().startsWith('utm_') || ['ref','source','campaign'].includes(key.toLowerCase())) url.searchParams.delete(key); } url.hash=''; return url.toString(); } catch { return value.trim(); } }
+export async function buildFingerprint(_source: SourceRow, candidate: Candidate): Promise { if (candidate.url) return sha256(`url:${canonicalUrl(candidate.url)}`); const title=candidate.title.toLowerCase().replace(/\s+/g,' ').trim(); const summary=(candidate.summary||'').toLowerCase().replace(/\s+/g,' ').trim().slice(0,240); return sha256(`text:${title}|${summary}`); }
+
+export async function insertItem(db: D1Database, source: SourceRow, candidate: Candidate, c: Classification): Promise<{ inserted: boolean; id?: number }> {
+ const fingerprint = await buildFingerprint(source,candidate);
+ const result = await db.prepare(`INSERT OR IGNORE INTO items(source_id,external_id,fingerprint,title,summary,url,published_at,kind,priority,score,source_confidence,verification_status,vendor,product,previous_price,current_price,currency,expires_at,raw_excerpt,ai_enriched) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)`).bind(source.id,candidate.externalId||null,fingerprint,candidate.title,candidate.summary||null,candidate.url||null,candidate.publishedAt||null,c.kind,c.priority,c.score,c.sourceConfidence,c.verificationStatus,c.vendor||null,c.product||null,c.previousPrice??null,c.currentPrice??null,c.currency||null,c.expiresAt||null,candidate.rawExcerpt||null,c.aiEnriched?1:0).run();
+ if (!result.meta.changes) return { inserted:false }; const row = await db.prepare('SELECT id FROM items WHERE fingerprint=?1').bind(fingerprint).first<{id:number}>(); return { inserted:true,id:row?.id };
+}
+export async function markPushed(db: D1Database,id:number):Promise{await db.prepare('UPDATE items SET pushed_at=?1 WHERE id=?2').bind(isoNow(),id).run();}
+export async function getRecentItems(db:D1Database,limit=100):Promise<(ItemRow&{source_name:string})[]>{const r=await db.prepare(`SELECT i.*,s.name AS source_name FROM items i JOIN sources s ON s.id=i.source_id ORDER BY i.discovered_at DESC LIMIT ?1`).bind(limit).all();return r.results||[];}
+export async function getReportItems(db:D1Database,start:string,end:string):Promise<(ItemRow&{source_name:string})[]>{const r=await db.prepare(`SELECT i.*,s.name AS source_name FROM items i JOIN sources s ON s.id=i.source_id WHERE i.discovered_at>=?1 AND i.discovered_at2 AND i.priority IN ('P2','P3') ORDER BY CASE i.priority WHEN 'P2' THEN 1 ELSE 2 END,i.score DESC,i.discovered_at DESC`).bind(start,end).all();return r.results||[];}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..de28592
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,15 @@
+import type { Env, ItemRow, SourceRow } from './types';
+import { collectSource } from './collectors';
+import { classifyDeterministically, maybeEnrichWithAi } from './rules';
+import { getDueSources, getRecentItems, insertItem, markPushed, saveFetchFailure, saveFetchSuccess } from './db';
+import { generateDailyReport, getDailyReport, getLatestReport } from './daily';
+import { pushP1 } from './telegram';
+import { jsonResponse } from './utils';
+
+async function processSource(env:Env,source:SourceRow):Promise{const started=Date.now();try{const result=await collectSource(source);const changed=!result.notModified&&Boolean(result.contentHash&&result.contentHash!==source.content_hash);const isBootstrap=!source.content_hash;if(!result.notModified&&changed&&!isBootstrap){for(const candidate of result.candidates){const deterministic=classifyDeterministically(source,candidate);const c=await maybeEnrichWithAi(env,source,candidate,deterministic);const saved=await insertItem(env.DB,source,candidate,c);if(!saved.inserted||!saved.id)continue;if(c.priority==='P1'){const item:ItemRow&{source_name:string}={id:saved.id,source_id:source.id,title:candidate.title,summary:candidate.summary||null,url:candidate.url||null,kind:c.kind,priority:c.priority,score:c.score,source_confidence:c.sourceConfidence,verification_status:c.verificationStatus,vendor:c.vendor||null,product:c.product||null,expires_at:c.expiresAt||null,discovered_at:new Date().toISOString(),published_at:candidate.publishedAt||null,pushed_at:null,source_name:source.name};if(await pushP1(env,item))await markPushed(env.DB,saved.id);}}}await saveFetchSuccess(env.DB,source,{etag:result.etag,lastModified:result.lastModified,contentHash:result.contentHash||source.content_hash||undefined,statusCode:result.statusCode,changed,durationMs:Date.now()-started});}catch(error){await saveFetchFailure(env.DB,source,error,Date.now()-started);}}
+async function harvest(env:Env):Promise<{processed:number}>{const limit=Math.max(1,Math.min(50,Number(env.SOURCE_BATCH_SIZE||10)));const sources=await getDueSources(env.DB,limit);for(const source of sources)await processSource(env,source);return{processed:sources.length};}
+function isAdmin(request:Request,env:Env):boolean{if(!env.ADMIN_TOKEN)return false;return request.headers.get('authorization')===`Bearer ${env.ADMIN_TOKEN}`;}
+async function listSources(env:Env):Promise{const rows=await env.DB.prepare(`SELECT id,name,url,type,trust_level,enabled,interval_minutes,next_fetch_at,last_fetch_at,last_success_at,failure_count,status FROM sources ORDER BY id DESC`).all();return jsonResponse(rows.results||[]);}
+async function createSource(request:Request,env:Env):Promise{const body=(await request.json())as Record;const name=String(body.name||'').trim(),url=String(body.url||'').trim(),type=String(body.type||'web'),trust=String(body.trust_level||'C'),interval=Math.max(5,Math.min(1440,Number(body.interval_minutes||15)));if(!name||!url||!['rss','web','github','x'].includes(type)||!['A','B','C','D'].includes(trust))return jsonResponse({error:'invalid source payload'},400);const config=body.config&&typeof body.config==='object'?JSON.stringify(body.config):null;const result=await env.DB.prepare(`INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at) VALUES(?1,?2,?3,?4,?5,?6,CURRENT_TIMESTAMP)`).bind(name,url,type,trust,interval,config).run();return jsonResponse({ok:true,id:result.meta.last_row_id},201);}
+async function handleApi(request:Request,env:Env,url:URL):Promise{if(url.pathname==='/api/health')return jsonResponse({ok:true,app:env.APP_NAME||'AI-Radar',time:new Date().toISOString()});if(url.pathname==='/api/items'&&request.method==='GET'){const limit=Math.max(1,Math.min(200,Number(url.searchParams.get('limit')||100)));const rows=await getRecentItems(env.DB,limit);return jsonResponse(rows.map(({priority:_priority,score:_score,...publicItem})=>publicItem));}if(url.pathname==='/api/admin/sources'){if(!isAdmin(request,env))return jsonResponse({error:'unauthorized'},401);if(request.method==='GET')return listSources(env);if(request.method==='POST')return createSource(request,env);return jsonResponse({error:'method not allowed'},405);}if(url.pathname==='/api/admin/run-harvest'&&request.method==='POST'){if(!isAdmin(request,env))return jsonResponse({error:'unauthorized'},401);return jsonResponse(await harvest(env));}if(url.pathname==='/api/admin/run-daily'&&request.method==='POST'){if(!isAdmin(request,env))return jsonResponse({error:'unauthorized'},401);return jsonResponse(await generateDailyReport(env));}return jsonResponse({error:'not found'},404);}
+export default{async fetch(request:Request,env:Env):Promise{const url=new URL(request.url);if(url.pathname.startsWith('/api/'))return handleApi(request,env,url);if(url.pathname.startsWith('/daily/')){const date=url.pathname.split('/').filter(Boolean)[1];if(!/^\d{4}-\d{2}-\d{2}$/.test(date||''))return new Response('Bad date',{status:400});return getDailyReport(env,date);}if(url.pathname==='/latest')return getLatestReport(env);return env.ASSETS.fetch(request);},async scheduled(controller:ScheduledController,env:Env,ctx:ExecutionContext):Promise{if(controller.cron==='30 4 * * *'){ctx.waitUntil(generateDailyReport(env));return;}ctx.waitUntil(harvest(env));}} satisfies ExportedHandler;
diff --git a/src/rules.ts b/src/rules.ts
new file mode 100644
index 0000000..ce70bf0
--- /dev/null
+++ b/src/rules.ts
@@ -0,0 +1,40 @@
+import type { Candidate, Classification, Env, SourceRow } from './types';
+
+const FREE_PATTERNS = [/free\s*(credit|quota|token|api)/i,/免费.{0,8}(额度|token|api|调用|试用)/i,/赠送.{0,8}(额度|token|代金券)/i,/注册送/i,/兑换码/i];
+const LIMITED_PATTERNS = [/限时/i,/首月/i,/折扣/i,/优惠/i,/coupon|promo|promotion|discount|limited[-\s]?time/i,/充值返/i];
+const DROP_PATTERNS = [/降价|下调.{0,8}(价格|定价)|价格.{0,8}下降/i,/price.{0,12}(drop|cut|reduc)/i,/cheaper/i];
+const PRICE_CHANGE_PATTERNS = [/涨价|提价|价格调整|定价调整|pricing update|price change/i];
+const NEW_PLAN_PATTERNS = [/coding\s*plan|token\s*plan|新套餐|新计划|套餐上线|plan launch/i];
+const HIGH_VALUE_NAMES = ['deepseek','glm','智谱','minimax','kimi','moonshot','火山','豆包','百炼','通义','腾讯','hunyuan','opencode','cursor','claude','codex'];
+function matchesAny(text: string, patterns: RegExp[]): boolean { return patterns.some((pattern) => pattern.test(text)); }
+function confidenceForTrust(trust: SourceRow['trust_level']): Classification['sourceConfidence'] { if (trust === 'A') return 'high'; if (trust === 'B') return 'medium'; return 'low'; }
+function verificationForTrust(trust: SourceRow['trust_level']): Classification['verificationStatus'] { return trust === 'A' ? 'official_confirmed' : 'unverified'; }
+
+export function classifyDeterministically(source: SourceRow, candidate: Candidate): Classification {
+ const text = `${candidate.title}\n${candidate.summary || ''}\n${candidate.rawExcerpt || ''}`.toLowerCase();
+ let kind: Classification['kind'] = 'other'; let score = 0;
+ if (matchesAny(text, FREE_PATTERNS)) { kind = 'free_credit'; score += 65; }
+ else if (matchesAny(text, LIMITED_PATTERNS)) { kind = 'limited_offer'; score += 50; }
+ else if (matchesAny(text, DROP_PATTERNS)) { kind = 'price_drop'; score += 45; }
+ else if (matchesAny(text, NEW_PLAN_PATTERNS)) { kind = 'new_plan'; score += 35; }
+ else if (matchesAny(text, PRICE_CHANGE_PATTERNS)) { kind = 'price_change'; score += 30; }
+ if (source.trust_level === 'A') score += 20; else if (source.trust_level === 'B') score += 10; else if (source.trust_level === 'D') score -= 10;
+ if (HIGH_VALUE_NAMES.some((name) => text.includes(name))) score += 10;
+ if (/绑卡|credit card required|付费后赠|充值后赠/i.test(text)) score -= 15;
+ if (/仅限.{0,12}(美国|us|新加坡|日本|地区)|region[-\s]?locked/i.test(text)) score -= 10;
+ score = Math.max(0, Math.min(100, score));
+ return { kind, priority: score >= 75 ? 'P1' : score >= 40 ? 'P2' : 'P3', score, sourceConfidence: confidenceForTrust(source.trust_level), verificationStatus: verificationForTrust(source.trust_level), aiEnriched: false };
+}
+
+interface AiJson { kind?: Classification['kind']; score?: number; vendor?: string; product?: string; expiresAt?: string; previousPrice?: number; currentPrice?: number; currency?: string; }
+export async function maybeEnrichWithAi(env: Env, source: SourceRow, candidate: Candidate, base: Classification): Promise {
+ if (env.AI_ENABLED !== 'true' || !env.AI) return base;
+ if (base.priority === 'P1' || base.score < 25) return base;
+ const prompt = ['You are a strict classifier for AI developer pricing/deal intelligence.','Return JSON only. Do not invent missing facts.','Allowed kind: free_credit, limited_offer, price_drop, price_change, new_plan, other.','score is 0-100 and reflects practical value/urgency, not source popularity.',`Source trust: ${source.trust_level}`,`Title: ${candidate.title}`,`Text: ${(candidate.summary || candidate.rawExcerpt || '').slice(0, 2500)}`,'JSON keys: kind, score, vendor, product, expiresAt, previousPrice, currentPrice, currency.'].join('\n');
+ try {
+ const result = await env.AI.run(env.AI_MODEL, { prompt });
+ const raw = typeof result === 'string' ? result : JSON.stringify(result); const match = raw.match(/\{[\s\S]*\}/); if (!match) return base;
+ const parsed = JSON.parse(match[0]) as AiJson; const score = Number.isFinite(parsed.score) ? Math.max(0, Math.min(100, Number(parsed.score))) : base.score;
+ return { ...base, kind: parsed.kind || base.kind, score, priority: score >= 75 ? 'P1' : score >= 40 ? 'P2' : 'P3', vendor: parsed.vendor || base.vendor, product: parsed.product || base.product, expiresAt: parsed.expiresAt || base.expiresAt, previousPrice: parsed.previousPrice ?? base.previousPrice, currentPrice: parsed.currentPrice ?? base.currentPrice, currency: parsed.currency || base.currency, aiEnriched: true };
+ } catch { return base; }
+}
diff --git a/src/telegram.ts b/src/telegram.ts
new file mode 100644
index 0000000..ec7119a
--- /dev/null
+++ b/src/telegram.ts
@@ -0,0 +1,6 @@
+import type { Env, ItemRow } from './types';
+function configured(env:Env):env is Env&{TELEGRAM_BOT_TOKEN:string;TELEGRAM_CHAT_ID:string}{return Boolean(env.TELEGRAM_BOT_TOKEN&&env.TELEGRAM_CHAT_ID);}
+async function send(env:Env,text:string,topicId?:string):Promise{if(!configured(env))return false;const body:Record={chat_id:env.TELEGRAM_CHAT_ID,text,parse_mode:'HTML',disable_web_page_preview:true};if(topicId)body.message_thread_id=Number(topicId);const r=await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});return r.ok;}
+function h(value:string):string{return value.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"');}
+export async function pushP1(env:Env,item:ItemRow&{source_name?:string}):Promise{const labels:Record={free_credit:'免费额度',limited_offer:'限时优惠',price_drop:'降价',price_change:'价格变化',new_plan:'新套餐',other:'重要信息'};const lines=[`🔥 ${h(labels[item.kind]||'重要优惠')} `,'',`${h(item.title)} `];if(item.summary)lines.push(h(item.summary.slice(0,700)));lines.push('',`来源:${h(item.source_name||'unknown')}`);if(item.verification_status==='official_confirmed')lines.push('核验:官方确认');if(item.expires_at)lines.push(`有效期:${h(item.expires_at)}`);if(item.url)lines.push('',`查看原文 `);return send(env,lines.join('\n'),env.TELEGRAM_TOPIC_P1);}
+export async function pushDailyReport(env:Env,reportDate:string,counts:{p2:number;p3:number}):Promise{const base=env.PUBLIC_BASE_URL.replace(/\/$/,'');const url=base?`${base}/daily/${reportDate}`:'';const text=[`📰 AI-Radar 日报 · ${h(reportDate)} `,'',`值得关注:${counts.p2} 条`,`其他线索:${counts.p3} 条`,url?'':undefined,url?`打开今日完整日报 `:undefined].filter(Boolean).join('\n');return send(env,text,env.TELEGRAM_TOPIC_DAILY);}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..a1275b4
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,51 @@
+export interface Env {
+ DB: D1Database;
+ AI?: { run(model: string, input: Record): Promise };
+ ASSETS: Fetcher;
+ APP_NAME: string;
+ APP_TIMEZONE: string;
+ AI_ENABLED: string;
+ AI_MODEL: string;
+ SOURCE_BATCH_SIZE: string;
+ PUBLIC_BASE_URL: string;
+ TELEGRAM_BOT_TOKEN?: string;
+ TELEGRAM_CHAT_ID?: string;
+ TELEGRAM_TOPIC_P1?: string;
+ TELEGRAM_TOPIC_DAILY?: string;
+ ADMIN_TOKEN?: string;
+}
+
+export type SourceType = 'rss' | 'web' | 'github' | 'x';
+export type TrustLevel = 'A' | 'B' | 'C' | 'D';
+export type Priority = 'P1' | 'P2' | 'P3';
+export type ItemKind = 'free_credit' | 'limited_offer' | 'price_drop' | 'price_change' | 'new_plan' | 'other';
+
+export interface SourceRow {
+ id: number; name: string; url: string; type: SourceType; trust_level: TrustLevel;
+ enabled: number; interval_minutes: number; config_json: string | null; etag: string | null;
+ last_modified: string | null; content_hash: string | null; next_fetch_at: string | null;
+ last_fetch_at: string | null; last_success_at: string | null; failure_count: number; status: string;
+}
+
+export interface Candidate {
+ externalId?: string; title: string; summary?: string; url?: string; publishedAt?: string; rawExcerpt?: string;
+}
+
+export interface CollectResult {
+ statusCode: number; notModified: boolean; etag?: string; lastModified?: string; contentHash?: string; candidates: Candidate[];
+}
+
+export interface Classification {
+ kind: ItemKind; priority: Priority; score: number; vendor?: string; product?: string; expiresAt?: string;
+ previousPrice?: number; currentPrice?: number; currency?: string;
+ sourceConfidence: 'high' | 'medium' | 'low';
+ verificationStatus: 'official_confirmed' | 'cross_verified' | 'unverified' | 'disputed';
+ aiEnriched: boolean;
+}
+
+export interface ItemRow {
+ id: number; source_id: number; title: string; summary: string | null; url: string | null;
+ kind: ItemKind; priority: Priority; score: number; source_confidence: string; verification_status: string;
+ vendor: string | null; product: string | null; expires_at: string | null; discovered_at: string;
+ published_at: string | null; pushed_at: string | null;
+}
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..481f1e6
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,22 @@
+export async function sha256(input: string): Promise {
+ const data = new TextEncoder().encode(input);
+ const digest = await crypto.subtle.digest('SHA-256', data);
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+export function stripHtml(input: string): string {
+ return input.replace(/