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..231b85f 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,105 @@ # 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 model launches, API availability, Coding Plan, 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 +## Discovery Radar v2 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 +Known sources (RSS / Web / GitHub) + │ + ├──────────────┐ + │ │ + ↓ ↓ + normal monitoring discovery feeds + (OpenRouter / Hugging Face / + Artificial Analysis) + │ │ + └──────┬───────┘ + ↓ + change detection + source-entry dedup + ↓ + deterministic event classifier + ↓ + optional Workers AI enrichment + ↓ + Cloudflare D1 + ↙ ↓ ↘ + P1 Telegram dynamic source pool P2/P3 daily + immediate temporary → candidate 12:30 Asia/Shanghai + → core ``` -## Status +The discovery layer is designed to find previously unknown models/vendors instead of relying only on a manually maintained seed list. A newly discovered model page can be added automatically as a temporary source for 30 days. If it produces meaningful follow-up signals it is promoted to candidate, then to core after repeated useful hits. The active temporary/candidate pool is capped to avoid uncontrolled source growth. + +## Event types + +AI-Radar now treats model intelligence as first-class data rather than requiring a discount or free-credit signal: + +- `new_model` +- `model_api_available` +- `model_open_source` +- `model_benchmark` +- `free_credit` +- `limited_offer` +- `price_drop` +- `price_change` +- `new_plan` + +A clear new-model event has a deterministic P2 floor. New models with API availability, coding/agent relevance, or other high-value signals can be promoted to P1. + +## Zero-cost rule + +The default system does not require paid APIs or paid infrastructure. Discovery uses public web/API endpoints. Workers AI is enabled only after change detection, source-entry/global deduplication, and deterministic filtering. It is hard-capped at 50 calls per UTC day with at most 256 output tokens per call; quota exhaustion or any AI error falls back to the deterministic result. + +## Current collectors + +- RSS / Atom +- Ordinary HTML pages with conditional requests and content hashing +- GitHub repositories through public Atom feeds +- OpenRouter public model catalog discovery +- Hugging Face text-generation discovery set +- Artificial Analysis model-catalog discovery +- X adapter reserved; the zero-cost discovery layer is the current fallback for X-only announcements + +## Source tiers + +- `core`: stable long-term monitored source +- `discovery`: aggregator/catalog used to find unknown models and vendors +- `temporary`: auto-added page with a 30-day observation window +- `candidate`: temporary source that produced a meaningful update; observation window extends to 90 days +- candidate sources become `core` after three meaningful hits + +## Cloudflare bindings + +The Worker expects `DB` (D1), `AI` (Workers AI), and `ASSETS` (Workers Static Assets). Secrets: `ADMIN_TOKEN`, the dedicated AI-Radar `TELEGRAM_BOT_TOKEN`, and the dedicated group `TELEGRAM_CHAT_ID`. AI-Radar has no Telegram webhook or inbound command handler: the bot is outbound-only. Non-secret safety controls include `AI_DAILY_CALL_LIMIT` and `SOURCE_BATCH_SIZE`. Never commit secret values. + +## Initial setup / upgrade + +1. Create D1 database `ai-radar-db` if this is a fresh install. +2. Replace `REPLACE_WITH_D1_DATABASE_ID` in `wrangler.jsonc` if needed. +3. `npm install` +4. `npm run db:migrate:remote` +5. Configure secrets with `wrangler secret put ...`. +6. `npm run check` +7. `npm run deploy` + +Migration `0006_discovery_radar.sql` adds source lifecycle fields and seeds OpenRouter, Hugging Face, Artificial Analysis, plus Multiverse Computing's official resources page. + +Cloudflare Cron runs in UTC. `30 4 * * *` equals 12:30 Asia/Shanghai. The report window is the preceding 24 hours ending at 12:30 Beijing time. + +## 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. +- A new model does not get discarded merely because no discount/free-credit information is present. + +## Branch status -Initial implementation in progress. +Discovery Radar v2 is implemented on `feature/discovery-radar-v2`, based on `ai-radar-mvp`. `main` is unchanged. diff --git a/docs/free-tier-capacity.md b/docs/free-tier-capacity.md new file mode 100644 index 0000000..062dcad --- /dev/null +++ b/docs/free-tier-capacity.md @@ -0,0 +1,39 @@ +# AI-Radar 免费层容量评估(2026-08-31) + +## 官方上限 + +- Workers Free:100,000 个动态请求/日;每次 HTTP/Cron 调用 10 ms CPU;每次调用 50 个外部子请求;每账号最多 5 个 Cron Trigger。静态资源请求免费且不限量。[1][2] +- D1 Free:5,000,000 rows read/日、100,000 rows written/日、5 GB 账号总存储;单库 500 MB;单次 Worker 调用最多 50 条 D1 查询。[3][4] +- Workers AI:每天 10,000 Neurons 免费额度;AI-Radar 另设 50 次/UTC 日、每次最多 256 输出 token 的应用层硬上限。[5] +- 当前静态页面采用 Workers Static Assets 的 `assets.directory` + `ASSETS` binding + `run_worker_first`,符合官方当前全栈 Worker 推荐方式。[6] +- `ADMIN_TOKEN`、AI-Radar 专用 Telegram Bot Token 与独立群 Chat ID 只使用加密 Secret;不使用 Topic,且不与 Hermes 共享 Bot 凭据。官方推荐 `wrangler secret put`,Secret 值设置后不在 Wrangler 或 Dashboard 回显。[7] + +## 估算假设 + +- 每个信源平均每 60 分钟抓取一次;Cron 每 5 分钟运行,另有 1 次日报 Cron,即 289 次 Worker 调用/日。 +- 外部抓取 `fetch()` 是子请求,不计为入站 Worker 请求;每轮最多处理 10 个源,低于 50 子请求上限。 +- D1 保守按每次源抓取 3 rows read、4 rows written 估算;另加 1,000 次/日公开 API 访问,每次最多读取 100 条。 +- Workers AI 最坏按 50 次/日、约 4,300 Neurons/日估算,仍低于 10,000 免费额。 +- 页面 HTML/CSS/JS 由 Static Assets 直接提供,不计动态请求;`/api/items`、`/latest`、`/daily/*` 才计动态请求。 + +## 结果 + +| 信源数 | 源抓取/日 | Worker 基础调用/日 | D1 read/日(含 1,000 次 API 浏览) | D1 write/日 | AI 上限 | 结论 | +|---:|---:|---:|---:|---:|---:|---| +| 20 | 480 | 289 + 动态页面/API 访问 | 101,729 | 2,020 | 4,300 Neurons | **SAFE** | +| 50 | 1,200 | 289 + 动态页面/API 访问 | 103,889 | 4,900 | 4,300 Neurons | **SAFE** | +| 100 | 2,400 | 289 + 动态页面/API 访问 | 107,489 | 9,700 | 4,300 Neurons | **WARNING** | + +100 个源的 D1 与请求额度仍远低于免费上限,但平均每个 5 分钟周期会有约 8.3 个到期源,接近当前 `SOURCE_BATCH_SIZE=10`,并且复杂 HTML 的解析可能触碰 Free Cron 10 ms CPU 限制。因此 100 源不作为默认配置;必须先按源类型拆分频率、监控 `exceededCpu`,再决定是否扩容。 + +当前 9 个源按实际 60/120/180 分钟混合频率约 160 次抓取/日,明显低于 20 源模型,属于 **SAFE**。默认配置不会自动开通付费服务;额度耗尽时应失败或降级,而不是产生额外调用。 + +## Sources + +[1] https://developers.cloudflare.com/workers/platform/pricing +[2] https://developers.cloudflare.com/workers/platform/limits +[3] https://developers.cloudflare.com/d1/platform/pricing +[4] https://developers.cloudflare.com/d1/platform/limits +[5] https://developers.cloudflare.com/workers-ai/platform/pricing +[6] https://developers.cloudflare.com/workers/static-assets +[7] https://developers.cloudflare.com/workers/configuration/secrets diff --git a/docs/x-zero-cost-options.md b/docs/x-zero-cost-options.md new file mode 100644 index 0000000..2df2785 --- /dev/null +++ b/docs/x-zero-cost-options.md @@ -0,0 +1,37 @@ +# X 零成本抓取候选方案(2026-08-31) + +## 结论 + +本阶段不正式上线 X Collector。2026 年官方 X API 已转为按资源计费:读取 Post 为每条 $0.005,需购买 credits;“免费 xAI credits”是购买 X API credits 后的返利,不是免费读取额度。[8] 官方 changelog 仅说明经批准的 Public Utility Apps 可继续免费规模化访问、近期 Legacy Free 用户有一次性 $10 voucher,这两类都不是 AI-Radar 可长期默认依赖的公开免费层。[9] + +因此继续保留 `XCollector` 接口,首选未来出现的官方免费 read-only 计划;当前不购买 X API、不使用付费代理、不部署自动登录脚本。 + +## 候选矩阵 + +| 方案 | 免费 | 需要登录 | 稳定性 | Cloudflare 可直接运行 | 主要风险 | 推荐级别 | +|---|---|---|---|---|---|---| +| 官方 X API Pay-per-use | 否 | 开发者账号 | 高 | 是 | 明确按读取资源收费;违反长期 0 元目标 | **不采用** | +| Public Utility Apps 特批 | 可能 | 需要申请与审核 | 高 | 是 | 非通用免费层,资格不确定 | **C:仅在官方批准后重评** | +| RSSHub 自托管 Twitter 路由 | 软件免费 | 通常需要 X Cookie/账号 | 低至中 | 不能直接作为纯 Worker 路由运行 | Cookie 失效、接口字段轮换、账号风控;2026 仍有时间线空结果问题。[10] | **D** | +| Nitter/公共实例 RSS | 表面免费 | 否 | 低 | 技术上可抓公开实例 | 上游 guest access、实例存活、法律与封禁风险;不能作为长期 SLA。[11] | **D** | +| X Syndication 单 Tweet endpoint | 免费且免登录 | 否 | 中 | 是 | 只能在已知 Tweet ID 后读取,不能发现新内容 | **C:只作详情补全** | +| 搜索引擎 `site:x.com` 线索 | 取决于搜索服务免费额度 | 否 | 低至中 | 取决于供应商 | 索引延迟、漏检、搜索 API 配额,不是完整时间线 | **C:只作发现补充** | +| OpenCLI/浏览器登录态 | 工具本身免费 | 是 | 中 | 否(依赖本机浏览器会话) | 电脑关闭即停止,不符合脱离本机要求 | **不用于生产** | +| 用户手工提交 X 链接 | 免费 | Worker 不需要登录 | 高 | 是 | 不是自动发现源 | **B:可作为补充入口** | + +## 后续触发条件 + +仅在以下任一条件满足时重新评估并实现: + +1. X 官方提供明确、可长期使用的免费只读/search 配额; +2. 获得 Public Utility Apps 免费资格且条款允许该监控场景; +3. 出现无需账户 Cookie、可在 Cloudflare 运行、连续验证至少 30 天的合法稳定公共源。 + +在此之前,AI-Radar 的 RSS/Web/GitHub 主链独立运行,X 不影响上线与日报。 + +## Sources + +[8] https://docs.x.com/x-api/getting-started/pricing +[9] https://docs.x.com/changelog +[10] https://github.com/DIYgod/RSSHub/issues/22938 +[11] https://github.com/zedeus/nitter 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/migrations/0003_track_source_entries.sql b/migrations/0003_track_source_entries.sql new file mode 100644 index 0000000..84a9eca --- /dev/null +++ b/migrations/0003_track_source_entries.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS source_entries ( + source_id INTEGER NOT NULL, + external_id TEXT NOT NULL, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (source_id, external_id), + FOREIGN KEY(source_id) REFERENCES sources(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_source_entries_seen ON source_entries(first_seen_at); + +UPDATE sources +SET enabled = 0, + status = 'disabled', + updated_at = CURRENT_TIMESTAMP +WHERE url = 'https://github.com/cheahjs/free-llm-api-resources'; diff --git a/migrations/0004_production_sources.sql b/migrations/0004_production_sources.sql new file mode 100644 index 0000000..2772cb3 --- /dev/null +++ b/migrations/0004_production_sources.sql @@ -0,0 +1,33 @@ +UPDATE sources +SET interval_minutes = 60, + updated_at = CURRENT_TIMESTAMP +WHERE url IN ( + 'https://github.com/codertesla/ai-coding-deals', + 'https://github.com/llerandi/llm-price-tracker', + 'https://github.com/xiaotiewinner/coding-plan' +); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at) +SELECT 'DeepSeek API Pricing','https://api-docs.deepseek.com/quick_start/pricing/','web','A',120,NULL,CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://api-docs.deepseek.com/quick_start/pricing/'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at) +SELECT 'Zhipu BigModel Pricing','https://open.bigmodel.cn/pricing','web','A',120,NULL,CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://open.bigmodel.cn/pricing'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at) +SELECT 'MiniMax API Pricing','https://platform.minimaxi.com/docs/guides/pricing-paygo','web','A',180,NULL,CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://platform.minimaxi.com/docs/guides/pricing-paygo'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at) +SELECT 'Kimi API Pricing','https://platform.kimi.ai/docs/pricing/chat','web','A',180,NULL,CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://platform.kimi.ai/docs/pricing/chat'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at) +SELECT 'OpenCode Releases','https://github.com/opencode-ai/opencode','github','A',60, + '{"githubOwner":"opencode-ai","githubRepo":"opencode","githubMode":"releases","githubBranch":"main"}',CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://github.com/opencode-ai/opencode'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at) +SELECT 'HN AI Free Credits','https://hnrss.org/newest?q=AI%20API%20free%20credits','rss','C',60,NULL,CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://hnrss.org/newest?q=AI%20API%20free%20credits'); diff --git a/migrations/0005_ai_daily_limit.sql b/migrations/0005_ai_daily_limit.sql new file mode 100644 index 0000000..d6fe671 --- /dev/null +++ b/migrations/0005_ai_daily_limit.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS ai_daily_usage ( + usage_date TEXT PRIMARY KEY, + calls INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/0006_discovery_radar.sql b/migrations/0006_discovery_radar.sql new file mode 100644 index 0000000..9d6483e --- /dev/null +++ b/migrations/0006_discovery_radar.sql @@ -0,0 +1,26 @@ +ALTER TABLE sources ADD COLUMN source_tier TEXT NOT NULL DEFAULT 'core'; +ALTER TABLE sources ADD COLUMN discovered_from_source_id INTEGER; +ALTER TABLE sources ADD COLUMN expires_at TEXT; +ALTER TABLE sources ADD COLUMN hit_count INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_sources_tier_expiry ON sources(source_tier, enabled, expires_at); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at,source_tier) +SELECT 'OpenRouter Model Discovery','https://openrouter.ai/api/v1/models','web','B',60, + '{"sourceTier":"discovery","discoveryProvider":"openrouter_models"}',CURRENT_TIMESTAMP,'discovery' +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://openrouter.ai/api/v1/models'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at,source_tier) +SELECT 'Hugging Face Trending LLM Discovery','https://huggingface.co/api/models?pipeline_tag=text-generation&sort=trendingScore&direction=-1&limit=30','web','B',120, + '{"sourceTier":"discovery","discoveryProvider":"huggingface_models"}',CURRENT_TIMESTAMP,'discovery' +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://huggingface.co/api/models?pipeline_tag=text-generation&sort=trendingScore&direction=-1&limit=30'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at,source_tier) +SELECT 'Artificial Analysis Model Discovery','https://artificialanalysis.ai/models','web','B',120, + '{"sourceTier":"discovery","discoveryProvider":"artificial_analysis_models"}',CURRENT_TIMESTAMP,'discovery' +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://artificialanalysis.ai/models'); + +INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at,source_tier) +SELECT 'Multiverse Computing Resources','https://multiversecomputing.com/resources','web','A',120, + '{"sourceTier":"core"}',CURRENT_TIMESTAMP,'core' +WHERE NOT EXISTS (SELECT 1 FROM sources WHERE url='https://multiversecomputing.com/resources'); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6cb1984 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1630 @@ +{ + "name": "ai-radar", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-radar", + "version": "0.1.0", + "devDependencies": { + "@cloudflare/workers-types": "latest", + "tsx": "^4.23.13", + "typescript": "^5.9.2", + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260828.1.tgz", + "integrity": "sha512-CVd+xPhqUESg8Xhq09TZx0wl4FSirfJGOzvbPz2yHhBIvmNHFFQkSN3rkd7wEwnhQQk37Xi0/aD6ykPLJbmGiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260828.1.tgz", + "integrity": "sha512-5HDPXRM152vU5JveByGFk34X57TVyIsfp4cabepAf45DC0MKvm52ucJqAjW1h8bvW4X+zRw9GU35OHF9FEC9Ww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260828.1.tgz", + "integrity": "sha512-MQ1Ll9P7F72HHUKizbb7BlDfbY8fRoNMpbIpZoU6uKsSkneFICWSKv6UlgU9EQZ+w0i7TMa12iUgJ8l29eRI9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260828.1.tgz", + "integrity": "sha512-FBTaUQ1xcU9jcp4OyBPcH8x0QiFvc1iuZL2GkD8zp2q1WyTVHYOptRDQUU+cuHjt0rQ2EIKVPBjahPxfa0joBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260828.1.tgz", + "integrity": "sha512-yvr77hC7dUbvK5K+SCg062kkPq3sx+drV1PcgHslzHDYcJBtT0V3X80qLE49LW1vq2svaeNmsVQS+vHsqWu8cQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260831.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260831.1.tgz", + "integrity": "sha512-yXg4pwfYjhsDH9rYc3qZ3K+z62DCSvO/aj7GiZo6AyDeWGZpyFRpPMYcQ6LF/zfaf1x0Ngw2gSqL8JjuUtMGlA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20260828.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260828.0-alpha.tgz", + "integrity": "sha512-6nbxhZEcz/UET3Y1OnYPsrAUjUmuFoib3ynUqteRdn1YnDxsLg8cwgZJZCk9QmtOmGzXwzXzgE/d/C0dJAPtVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260828.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260828.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260828.1.tgz", + "integrity": "sha512-pB9yvt0kkwZDAGZHmpY59r0o3hM0DzdW6BJERqwZOhunZ3ssOyDSgQxOQer2cSZW4YCFeOTIQYN1qwhK5wv/Cw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260828.1", + "@cloudflare/workerd-darwin-arm64": "1.20260828.1", + "@cloudflare/workerd-linux-64": "1.20260828.1", + "@cloudflare/workerd-linux-arm64": "1.20260828.1", + "@cloudflare/workerd-windows-64": "1.20260828.1" + } + }, + "node_modules/wrangler": { + "version": "4.127.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.127.1.tgz", + "integrity": "sha512-OzsiNgaI8i681L/+KnAKc+uEZ5D57xK5JuNvCOpRKICF4/5Q3Cu1oTGuUiT/f3GDUqQb3gzXNT0tfOHGMEtknw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260828.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260828.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260828.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aebefde --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "ai-radar", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev --test-scheduled", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "tsx --test tests/*.test.ts", + "check": "npm run typecheck && npm test && wrangler deploy --dry-run", + "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", + "tsx": "^4.23.13", + "typescript": "^5.9.2", + "wrangler": "^4.0.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..da9991f --- /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:'新套餐',new_model:'新模型',model_api_available:'API 可用',model_open_source:'模型开源',model_benchmark:'模型评测',discovered_model:'模型发现',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))}

`:''}
${new Date(x.discovered_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}${x.url?`原文`:''}
`).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..47a8050 --- /dev/null +++ b/public/index.html @@ -0,0 +1 @@ +AI-Radar

AI MODEL & DEAL INTELLIGENCE

AI-Radar

追踪新模型、API 可用性、Coding Plan、Token、免费额度、限时优惠与价格变化。

最新日报
最近发现
正在加载…
\ 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..dac7725 --- /dev/null +++ b/src/collectors.ts @@ -0,0 +1,285 @@ +import type { Candidate, CollectResult, SourceConfig, SourceRow } from './types'; +import { parseJson, sha256, stripHtml, textExcerpt } from './utils'; + +function conditionalHeaders(source: SourceRow): Headers { + const headers = new Headers({ 'user-agent': 'AI-Radar/0.2 (+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 safeUnixIso(value: unknown): string | undefined { + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return new Date(seconds * 1000).toISOString(); +} + +function sanitizeExcerpt(raw?: string): string | undefined { + if (!raw) return undefined; + const stripped = stripHtml(raw) + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`[^`]*`/g, ' ') + .replace(/<(?:system|assistant|user|im_start|im_end)>[\s\S]*?<\/(?:system|assistant|user|im_start|im_end)>/gi, ' ') + .replace(/^(?:system|assistant|user|human|prompt|instruction):\s*.*$/gim, ' ') + .replace(/\b(?:please\s+roleplay|you\s+are\s+a|act\s+as|system\s*prompt|chat\s*template)\b[\s\S]*?(?:\n\n|$)/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); + return stripped ? textExcerpt(stripped, 800) : undefined; +} + +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']); + const cleanedExcerpt = sanitizeExcerpt(summary); + return { + externalId: id || url || `${title}:${publishedAt || ''}`, + title: textExcerpt(title, 300), + summary: summary ? textExcerpt(summary, 1400) : undefined, + rawExcerpt: cleanedExcerpt || textExcerpt(title, 800), + 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 collectOpenRouterModels(source: SourceRow): Promise { + const { response, text } = await fetchText(source, source.url, 'application/json, */*;q=0.8'); + if (response.status === 304) return { statusCode: 304, notModified: true, candidates: [] }; + const body = text || '{}'; + const parsed = JSON.parse(body) as { data?: Array> }; + const candidates: Candidate[] = (parsed.data || []).slice(0, 300).flatMap((row) => { + const id = typeof row.id === 'string' ? row.id : ''; + if (!id) return []; + const name = typeof row.name === 'string' && row.name.trim() ? row.name.trim() : id; + const vendor = id.includes('/') ? id.split('/')[0] : undefined; + const context = Number(row.context_length); + const pricing = row.pricing && typeof row.pricing === 'object' ? (row.pricing as Record) : {}; + const promptPrice = typeof pricing.prompt === 'string' ? pricing.prompt : undefined; + const completionPrice = typeof pricing.completion === 'string' ? pricing.completion : undefined; + const createdIso = safeUnixIso(row.created); + const details = [ + `Model ID: ${id}`, + vendor ? `Vendor: ${vendor}` : '', + `Display name: ${name}`, + createdIso ? `Catalog created: ${createdIso.slice(0, 10)}` : '', + Number.isFinite(context) ? `Context length: ${context}` : '', + promptPrice ? `Prompt price: ${promptPrice}` : '', + completionPrice ? `Completion price: ${completionPrice}` : '', + 'Status: API available on OpenRouter', + ].filter(Boolean).join(' | '); + return [{ + externalId: `openrouter:${id}`, + title: `${name} available via API on OpenRouter`, + summary: details, + rawExcerpt: details, + url: `https://openrouter.ai/${id}`, + publishedAt: createdIso, + signalKind: 'model_api_available' as const, + vendorHint: vendor, + productHint: name, + }]; + }); + return { + statusCode: response.status, + notModified: false, + etag: response.headers.get('etag') || undefined, + lastModified: response.headers.get('last-modified') || undefined, + contentHash: await sha256(body), + candidates, + }; +} + +async function collectHuggingFaceModels(source: SourceRow): Promise { + const { response, text } = await fetchText(source, source.url, 'application/json, */*;q=0.8'); + if (response.status === 304) return { statusCode: 304, notModified: true, candidates: [] }; + const body = text || '[]'; + const rows = JSON.parse(body) as Array>; + const candidates: Candidate[] = rows.slice(0, 50).flatMap((row) => { + const id = typeof row.id === 'string' ? row.id : typeof row.modelId === 'string' ? row.modelId : ''; + if (!id) return []; + const vendor = id.includes('/') ? id.split('/')[0] : undefined; + const tags = Array.isArray(row.tags) ? row.tags.filter((value): value is string => typeof value === 'string').slice(0, 12) : []; + const pipeline = typeof row.pipeline_tag === 'string' ? row.pipeline_tag : undefined; + const library = typeof row.library_name === 'string' ? row.library_name : undefined; + const createdIso = safeIso(typeof row.createdAt === 'string' ? row.createdAt : undefined); + const modifiedIso = safeIso(typeof row.lastModified === 'string' ? row.lastModified : undefined); + const details = [ + `Model ID: ${id}`, + vendor ? `Vendor: ${vendor}` : '', + createdIso ? `Created: ${createdIso.slice(0, 10)}` : '', + modifiedIso ? `Last modified: ${modifiedIso.slice(0, 10)}` : '', + pipeline ? `Pipeline: ${pipeline}` : '', + library ? `Library: ${library}` : '', + tags.length ? `Tags: ${tags.join(', ')}` : '', + 'Status: Observed in Hugging Face trending discovery set', + ].filter(Boolean).join(' | '); + return [{ + externalId: `huggingface:${id}`, + title: `Model discovery: ${id}`, + summary: details, + rawExcerpt: details, + url: `https://huggingface.co/${id}`, + publishedAt: createdIso, + signalKind: 'discovered_model' as const, + vendorHint: vendor, + productHint: id, + }]; + }); + return { + statusCode: response.status, + notModified: false, + etag: response.headers.get('etag') || undefined, + lastModified: response.headers.get('last-modified') || undefined, + contentHash: await sha256(body), + candidates, + }; +} + +function artificialAnalysisCandidates(html: string): Candidate[] { + const seen = new Set(); + const candidates: Candidate[] = []; + const regex = /]+href=["'](\/models\/[^"'?#]+)["'][^>]*>([\s\S]*?)<\/a>/gi; + for (const match of html.matchAll(regex)) { + const path = match[1].replace(/\/$/, ''); + if (!path || path === '/models' || seen.has(path)) continue; + const label = stripHtml(match[2]).replace(/\s+/g, ' ').trim(); + if (!label || label.length > 180) continue; + seen.add(path); + const slug = path.split('/').filter(Boolean).pop() || label; + const details = [ + `Model: ${label}`, + `Slug: ${slug}`, + 'Indexed by: Artificial Analysis catalog', + 'Note: Discovery signal for model index / benchmarks', + ].join(' | '); + candidates.push({ + externalId: `artificial-analysis:${slug}`, + title: `Model indexed by Artificial Analysis: ${label}`, + summary: details, + rawExcerpt: details, + url: `https://artificialanalysis.ai${path}`, + signalKind: 'discovered_model', + productHint: label, + }); + if (candidates.length >= 300) break; + } + return candidates; +} + +async function collectArtificialAnalysisModels(source: SourceRow): Promise { + const { response, text } = await fetchText(source, source.url, 'text/html, */*;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: artificialAnalysisCandidates(body), + }; +} + +async function collectWeb(source: SourceRow): Promise { + const config = parseJson(source.config_json, {}); + if (config.discoveryProvider === 'openrouter_models') return collectOpenRouterModels(source); + if (config.discoveryProvider === 'huggingface_models') return collectHuggingFaceModels(source); + if (config.discoveryProvider === 'artificial_analysis_models') return collectArtificialAnalysisModels(source); + + 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 = sanitizeExcerpt(body) || stripHtml(body).replace(/\s+/g, ' ').trim(); + const contentHash = await sha256(normalized); + const excerpt = normalized.length <= 1200 ? normalized : `${normalized.slice(0, 1200)}…`; + return { + statusCode: response.status, + notModified: false, + etag: response.headers.get('etag') || undefined, + lastModified: response.headers.get('last-modified') || undefined, + contentHash, + candidates: [{ externalId: contentHash, 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; discovery feeds are used as the zero-cost coverage layer'); + 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..e1a223b --- /dev/null +++ b/src/daily.ts @@ -0,0 +1,108 @@ +import type { Env, ItemRow } from './types'; +import { escapeHtml } from './utils'; +import { getReportItems } from './db'; +import { pushDailyReport } from './telegram'; + +export 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(); + const m = shifted.getUTCMonth(); + const d = shifted.getUTCDate(); + const endUtc = new Date(Date.UTC(y, m, d, 4, 30, 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: '新套餐', + new_model: '新模型', + model_api_available: 'API 可用', + model_open_source: '模型开源', + model_benchmark: '模型评测', + discovered_model: '模型发现', + other: '其他', + } as Record)[kind] || '其他' + ); +} + +function formatBeijing(value: string | null): string { + return value ? new Date(value).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) : '—'; +} + +function itemCard(item: ItemRow & { source_name: string; last_verified_at: string | null }): string { + const status = item.verification_status === 'official_confirmed' ? '官方确认' : item.verification_status === 'cross_verified' ? '已交叉验证' : '未核实'; + const identity = [item.vendor, item.product].filter(Boolean).join(' · '); + const price = + item.current_price != null + ? `${item.currency || ''} ${item.current_price}${item.previous_price != null ? `(原价 ${item.currency || ''} ${item.previous_price})` : ''}` + : ''; + const details = [ + identity ? `厂商 / 产品:${identity}` : '', + price ? `价格:${price}` : '', + item.expires_at ? `有效期:${item.expires_at}` : '', + item.published_at ? `发布/收录:${formatBeijing(item.published_at)}` : '', + `发现:${formatBeijing(item.discovered_at)}`, + `最后核验:${formatBeijing(item.last_verified_at)}`, + ].filter(Boolean); + return `
${escapeHtml(kindLabel(item.kind))}${escapeHtml(status)}

${escapeHtml(item.title)}

${item.summary ? `

${escapeHtml(item.summary.slice(0, 900))}

` : ''}
    ${details.map((value) => `
  • ${escapeHtml(value)}
  • `).join('')}
${escapeHtml(item.source_name)}${item.url ? `查看原文` : ''}
`; +} + +function section(title: string, items: (ItemRow & { source_name: string; last_verified_at: string | null })[], empty: string): string { + return `

${escapeHtml(title)}

${items.length ? items.map(itemCard).join('') : `
${escapeHtml(empty)}
`}
`; +} + +export function renderReport(reportDate: string, items: (ItemRow & { source_name: string; last_verified_at: string | null })[]): string { + const free = items.filter((x) => x.kind === 'free_credit'); + const offers = items.filter((x) => x.kind === 'limited_offer'); + const drops = items.filter((x) => x.kind === 'price_drop' || x.kind === 'price_change'); + const models = items.filter((x) => ['new_model', 'model_api_available', 'model_open_source', 'model_benchmark', 'discovered_model'].includes(x.kind)); + const highlights = items.filter((x) => x.priority === 'P2' && x.kind === 'new_plan'); + const other = items.filter((x) => x.priority === 'P2' && x.kind === 'other'); + const categorized = new Set([...free, ...offers, ...drops, ...models, ...highlights, ...other].map((x) => x.id)); + const leads = items.filter((x) => x.priority === 'P3' && !categorized.has(x.id)); + return `AI-Radar 日报 ${escapeHtml(reportDate)}

AI-Radar 日报

${escapeHtml(reportDate)} · 统计窗口截至北京时间 12:30
${section('新模型与 API', models, '今天没有新增模型情报。')}${section('今日重点', highlights, '今天没有新增重点。')}${section('免费额度', free, '今天没有新增免费额度。')}${section('限时优惠', offers, '今天没有新增限时优惠。')}${section('降价与价格变化', drops, '今天没有新增价格变化。')}${section('其他值得关注', other, '今天没有其他值得关注的信息。')}${section('未核实线索', leads, '今天没有未核实线索。')}`; +} + +export async function generateDailyReport(env: Env, now = new Date()): Promise<{ reportDate: string; itemCount: number; pushed: boolean }> { + const w = beijingWindow(now); + const existing = await env.DB.prepare('SELECT telegram_pushed_at FROM daily_reports WHERE report_date=?1').bind(w.reportDate).first<{ telegram_pushed_at: string | null }>(); + 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 = existing?.telegram_pushed_at ? false : 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) { + const fallback = renderReport(date, []); + return new Response(fallback, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'public, max-age=60' } }); + } + 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) { + const today = new Intl.DateTimeFormat('en-CA', { timeZone: env.APP_TIMEZONE || 'Asia/Shanghai' }).format(new Date()); + return new Response(null, { status: 302, headers: { location: `/daily/${today}` } }); + } + 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..0a6b4a6 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,95 @@ +import type { Candidate, Classification, ItemRow, SourceRow, SourceTier } from './types'; +import { addMinutesIso, isoNow, sha256 } from './utils'; + +const DISCOVERY_KINDS = new Set(['new_model','model_api_available','model_open_source','model_benchmark']); + +export async function expireTemporarySources(db:D1Database):Promise{ + await db.prepare(`UPDATE sources SET enabled=0,status='expired',updated_at=?1 WHERE enabled=1 AND source_tier IN ('temporary','candidate') AND expires_at IS NOT NULL AND expires_at<=?1`).bind(isoNow()).run(); +} + +export async function getDueSources(db: D1Database, limit: number): Promise { + const now=isoNow(); + const result = await db.prepare(`SELECT * FROM sources WHERE enabled = 1 AND (source_tier NOT IN ('temporary','candidate') OR expires_at IS NULL OR expires_at > ?1) AND (next_fetch_at IS NULL OR next_fetch_at <= ?1) ORDER BY COALESCE(next_fetch_at, created_at) ASC LIMIT ?2`).bind(now, 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(); } } +function normalizedModelKey(value:string|undefined):string{return (value||'').toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g,' ').trim();} +export async function sourceEntryId(candidate: Candidate): Promise { return candidate.externalId || candidate.url || sha256(`${candidate.title}|${candidate.summary || candidate.rawExcerpt || ''}`); } +export async function buildFingerprint(source: SourceRow, candidate: Candidate): Promise { + if(candidate.signalKind&&candidate.productHint){const vendor=normalizedModelKey(candidate.vendorHint);const product=normalizedModelKey(candidate.productHint);return sha256(`model:${candidate.signalKind}:${vendor}:${product}`);} + if (source.type === 'web' && candidate.externalId) return sha256(`web:${source.id}:${candidate.externalId}`); + 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 rememberSourceEntry(db:D1Database,source:SourceRow,candidate:Candidate):Promise{const externalId=await sourceEntryId(candidate);await db.prepare('INSERT OR IGNORE INTO source_entries(source_id,external_id) VALUES(?1,?2)').bind(source.id,externalId).run();} +export async function rememberSourceEntries(db:D1Database,source:SourceRow,candidates:Candidate[]):Promise{if(!candidates.length)return;const statements=[];for(const candidate of candidates){const externalId=await sourceEntryId(candidate);statements.push(db.prepare('INSERT OR IGNORE INTO source_entries(source_id,external_id) VALUES(?1,?2)').bind(source.id,externalId));}await db.batch(statements);} +export async function filterNewCandidates(db:D1Database,source:SourceRow,candidates:Candidate[]):Promise{ + if(!candidates.length)return[]; + const entryIds=await Promise.all(candidates.map(sourceEntryId)); + const entryPlaceholders=entryIds.map((_,index)=>`?${index+2}`).join(','); + const seenRows=await db.prepare(`SELECT external_id FROM source_entries WHERE source_id=?1 AND external_id IN (${entryPlaceholders})`).bind(source.id,...entryIds).all<{external_id:string}>(); + const seen=new Set((seenRows.results||[]).map(row=>row.external_id)); + const unseen=candidates.filter((_,index)=>!seen.has(entryIds[index])); + if(!unseen.length)return[]; + const fingerprints=await Promise.all(unseen.map(candidate=>buildFingerprint(source,candidate))); + const fingerprintPlaceholders=fingerprints.map((_,index)=>`?${index+1}`).join(','); + const duplicateRows=await db.prepare(`SELECT fingerprint FROM items WHERE fingerprint IN (${fingerprintPlaceholders})`).bind(...fingerprints).all<{fingerprint:string}>(); + const duplicates=new Set((duplicateRows.results||[]).map(row=>row.fingerprint)); + const crossSourceDuplicates=unseen.filter((_,index)=>duplicates.has(fingerprints[index])); + await rememberSourceEntries(db,source,crossSourceDuplicates); + return unseen.filter((_,index)=>!duplicates.has(fingerprints[index])); +} + +export async function registerDiscoveredSource(db:D1Database,source:SourceRow,candidate:Candidate,c:Classification):Promise{ + if(source.source_tier!=='discovery'||!DISCOVERY_KINDS.has(c.kind)||c.priority==='P3'||!candidate.url)return false; + let url:string; + try{const parsed=new URL(candidate.url);if(!['http:','https:'].includes(parsed.protocol))return false;parsed.hash='';url=parsed.toString();}catch{return false;} + if(canonicalUrl(url)===canonicalUrl(source.url))return false; + const existing=await db.prepare(`SELECT id,source_tier FROM sources WHERE url=?1 LIMIT 1`).bind(url).first<{id:number;source_tier:SourceTier}>(); + const expiresAt=new Date(Date.now()+30*24*60*60*1000).toISOString(); + if(existing){ + if(existing.source_tier==='temporary'||existing.source_tier==='candidate')await db.prepare(`UPDATE sources SET enabled=1,expires_at=CASE WHEN expires_at IS NULL OR expires_at(); + if((count?.count||0)>=100)return false; + const label=(c.product||candidate.productHint||candidate.title).replace(/\s+/g,' ').trim().slice(0,110); + const trust=source.trust_level==='A'?'B':source.trust_level==='B'?'B':'C'; + const config=JSON.stringify({sourceTier:'temporary',discoveredFrom:source.id,discoverySignal:c.kind}); + await db.prepare(`INSERT INTO sources(name,url,type,trust_level,enabled,interval_minutes,config_json,next_fetch_at,source_tier,discovered_from_source_id,expires_at,hit_count) VALUES(?1,?2,'web',?3,1,360,?4,CURRENT_TIMESTAMP,'temporary',?5,?6,0)`).bind(`${label} watch`,url,trust,config,source.id,expiresAt).run(); + return true; +} + +export async function noteSourceValue(db:D1Database,source:SourceRow,c:Classification):Promise{ + if(!['temporary','candidate'].includes(source.source_tier||'')||c.priority==='P3'||c.kind==='other')return; + const nextHit=(source.hit_count||0)+1; + const nextTier:SourceTier=nextHit>=3?'core':source.source_tier==='temporary'?'candidate':'candidate'; + const expiresAt=nextTier==='core'?null:new Date(Date.now()+90*24*60*60*1000).toISOString(); + await db.prepare(`UPDATE sources SET hit_count=hit_count+1,source_tier=?1,expires_at=?2,updated_at=?3 WHERE id=?4`).bind(nextTier,expiresAt,isoNow(),source.id).run(); +} + +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;last_verified_at:string|null})[]>{const r=await db.prepare(`SELECT i.*,s.name AS source_name,s.last_success_at AS last_verified_at 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;last_verified_at:string|null})[]>{const r=await db.prepare(`SELECT i.*,s.name AS source_name,s.last_success_at AS last_verified_at FROM items i JOIN sources s ON s.id=i.source_id WHERE i.discovered_at>=?1 AND i.discovered_at();return r.results||[];} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ca44bb7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,47 @@ +import type { Env, ItemRow, SourceRow, SourceTier } from './types'; +import { collectSource } from './collectors'; +import { classifyDeterministically, maybeEnrichWithAi } from './rules'; +import { expireTemporarySources, filterNewCandidates, getDueSources, getRecentItems, insertItem, markPushed, noteSourceValue, registerDiscoveredSource, rememberSourceEntries, rememberSourceEntry, 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){ + if(isBootstrap){ + await rememberSourceEntries(env.DB,source,result.candidates); + }else{ + const candidates=await filterNewCandidates(env.DB,source,result.candidates); + for(const candidate of candidates){ + const deterministic=classifyDeterministically(source,candidate); + const c=await maybeEnrichWithAi(env,source,candidate,deterministic); + const saved=await insertItem(env.DB,source,candidate,c); + await rememberSourceEntry(env.DB,source,candidate); + if(!saved.inserted||!saved.id)continue; + await registerDiscoveredSource(env.DB,source,candidate,c); + await noteSourceValue(env.DB,source,c); + 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,previous_price:c.previousPrice??null,current_price:c.currentPrice??null,currency:c.currency||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,c.summaryZh))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}>{await expireTemporarySources(env.DB);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}`;} +export function toPublicItem(row:ItemRow&{source_name:string;last_verified_at:string|null}){return{id:row.id,title:row.title,summary:row.summary,url:row.url,kind:row.kind,vendor:row.vendor,product:row.product,previous_price:row.previous_price,current_price:row.current_price,currency:row.currency,expires_at:row.expires_at,discovered_at:row.discovered_at,published_at:row.published_at,source_name:row.source_name,verification_status:row.verification_status,last_verified_at:row.last_verified_at};} +async function listSources(env:Env):Promise{const rows=await env.DB.prepare(`SELECT id,name,url,type,trust_level,enabled,interval_minutes,source_tier,discovered_from_source_id,expires_at,hit_count,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))),tier=String(body.source_tier||'core') as SourceTier;if(!name||!url||!['rss','web','github','x'].includes(type)||!['A','B','C','D'].includes(trust)||!['core','discovery','temporary','candidate'].includes(tier))return jsonResponse({error:'invalid source payload'},400);const config=body.config&&typeof body.config==='object'?JSON.stringify(body.config):null;const expiresAt=typeof body.expires_at==='string'?body.expires_at:null;const result=await env.DB.prepare(`INSERT INTO sources(name,url,type,trust_level,interval_minutes,config_json,next_fetch_at,source_tier,expires_at) VALUES(?1,?2,?3,?4,?5,?6,CURRENT_TIMESTAMP,?7,?8)`).bind(name,url,type,trust,interval,config,tier,expiresAt).run();return jsonResponse({ok:true,id:result.meta.last_row_id},201);} +async function testTelegramP1(env:Env):Promise{const now=new Date().toISOString();const item:ItemRow&{source_name:string}={id:0,source_id:0,title:'AI-Radar P1 端到端测试',summary:'这是一条由 Cloudflare Worker 发出的上线验收消息。',url:env.PUBLIC_BASE_URL||null,kind:'limited_offer',priority:'P1',score:100,source_confidence:'high',verification_status:'official_confirmed',vendor:'AI-Radar',product:'Telegram 通知链路',previous_price:null,current_price:null,currency:null,expires_at:null,discovered_at:now,published_at:now,pushed_at:null,source_name:'AI-Radar 系统验收'};const sent=await pushP1(env,item,'这是一条 AI-Radar 中文摘要链路测试,用于确认专用 Bot 可以正常发送结构化中文情报。');return jsonResponse({ok:sent,channel:'telegram_single_group'},sent?200:502);} +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(toPublicItem));}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/test-telegram-p1'&&request.method==='POST'){if(!isAdmin(request,env))return jsonResponse({error:'unauthorized'},401);return testTelegramP1(env);}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..7db1fac --- /dev/null +++ b/src/rules.ts @@ -0,0 +1,301 @@ +import type { Candidate, Classification, Env, SourceRow } from './types'; +import { buildChineseSummary, isHighQualityChineseSummary } from './telegram'; + +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]; + +// Official / High-trust release semantics +const OFFICIAL_RELEASE_PATTERNS = [ + /\b(?:introducing|announcing|we\s+release|we\s+are\s+releasing|we're\s+releasing|launching|unveiling)\b.{0,60}\b(?:model|weights?|llm|moe|checkpoint|architecture)\b/i, + /\b(?:model|weights?|llm|moe)\b.{0,40}\b(?:is\s+now\s+available|now\s+available|has\s+been\s+released|officially\s+released|launched\s+today)\b/i, + /新模型.{0,10}(发布|推出|上线)|发布.{0,10}(大模型|开源模型|全新模型)|正式推出.{0,10}模型/i, +]; + +const GENERIC_MODEL_PATTERNS = [ + /new\s+(ai\s+)?model/i, + /new model discovery/i, + /model\s+(launch|release|released|debut)/i, + /introduc(?:e|ing).{0,40}model/i, + /新模型|模型.{0,10}(发布|推出|上线)|发布.{0,10}(大模型|模型)/i, +]; + +const API_AVAILABLE_PATTERNS = [/available.{0,24}(via|through|on).{0,24}api/i, /api.{0,24}(available|access|endpoint|上线|开放|可用)/i, /开放.{0,10}api/i, /api\s+model observed/i]; +const OPEN_SOURCE_PATTERNS = [/open[-\s]?source/i, /open\s+weights?/i, /weights?.{0,20}(released|available)/i, /开源.{0,12}(模型|权重)|开放权重/i]; +const BENCHMARK_PATTERNS = [/benchmark|leaderboard|artificial analysis|evaluation|基准测试|评测|榜单/i]; +const CODING_AGENT_PATTERNS = [/\bcoding\b|code generation|software engineering|\bagent(s|ic)?\b|智能体|编程|代码/i]; +const HIGH_VALUE_NAMES = ['deepseek', 'glm', '智谱', 'minimax', 'kimi', 'moonshot', '火山', '豆包', '百炼', '通义', '腾讯', 'hunyuan', 'opencode', 'cursor', 'claude', 'codex', 'openai', 'anthropic', 'gemini', 'qwen']; +const MODEL_KINDS = new Set(['new_model', 'model_api_available', 'model_open_source', 'model_benchmark', 'discovered_model']); + +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 isRecentRelease(publishedAt?: string, now = new Date()): boolean { + if (!publishedAt) return false; + const pubTime = new Date(publishedAt).getTime(); + if (Number.isNaN(pubTime)) return false; + const ageMs = now.getTime() - pubTime; + // <= 14 days (and not absurdly in future > 2 days) + return ageMs >= -2 * 24 * 60 * 60 * 1000 && ageMs <= 14 * 24 * 60 * 60 * 1000; +} + +export function classifyDeterministically(source: SourceRow, candidate: Candidate, now = new Date()): Classification { + const text = `${candidate.title}\n${candidate.summary || ''}\n${candidate.rawExcerpt || ''}`.toLowerCase(); + let kind: Classification['kind'] = candidate.signalKind || 'other'; + let score = + candidate.signalKind === 'model_api_available' + ? 60 + : candidate.signalKind === 'model_open_source' + ? 55 + : candidate.signalKind === 'new_model' + ? 50 + : candidate.signalKind === 'discovered_model' + ? 35 + : candidate.signalKind === 'model_benchmark' + ? 45 + : 0; + + if (!candidate.signalKind) { + 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; + } else if (source.trust_level === 'A' && matchesAny(text, OFFICIAL_RELEASE_PATTERNS)) { + kind = 'new_model'; + score += 55; + } else if (matchesAny(text, API_AVAILABLE_PATTERNS)) { + kind = 'model_api_available'; + score += 50; + } else if (matchesAny(text, OPEN_SOURCE_PATTERNS)) { + kind = 'model_open_source'; + score += 45; + } else if (matchesAny(text, BENCHMARK_PATTERNS)) { + kind = 'model_benchmark'; + score += 35; + } else if (matchesAny(text, GENERIC_MODEL_PATTERNS)) { + kind = isRecentRelease(candidate.publishedAt, now) ? 'new_model' : 'discovered_model'; + score += kind === 'new_model' ? 50 : 35; + } + } + + // Strict check on new_model classification + if (kind === 'new_model') { + const hasRecentDate = isRecentRelease(candidate.publishedAt, now); + const hasOfficialReleaseWording = source.trust_level === 'A' && matchesAny(text, OFFICIAL_RELEASE_PATTERNS); + if (!hasRecentDate && !hasOfficialReleaseWording) { + kind = 'discovered_model'; + score = Math.min(score, 45); + } + } + + const isModelEvent = kind === 'new_model' || kind === 'discovered_model'; + const hasApi = candidate.signalKind === 'model_api_available' || matchesAny(text, API_AVAILABLE_PATTERNS); + const hasOpenSource = candidate.signalKind === 'model_open_source' || matchesAny(text, OPEN_SOURCE_PATTERNS); + const hasBenchmark = candidate.signalKind === 'model_benchmark' || matchesAny(text, BENCHMARK_PATTERNS); + + if (isModelEvent && hasApi) score += 25; + if (isModelEvent && hasOpenSource) score += 15; + if (isModelEvent && hasBenchmark) score += 5; + if (isModelEvent && matchesAny(text, CODING_AGENT_PATTERNS)) score += 20; + + 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; + + // Floor rules: verified new_model / API availability gets at least P2 (40). + // discovered_model (without verified recent release) has no forced high floor. + if (kind === 'new_model' || kind === 'model_api_available' || kind === 'model_open_source') { + score = Math.max(score, 40); + } + + score = Math.max(0, Math.min(100, score)); + + return { + kind, + priority: score >= 75 ? 'P1' : score >= 40 ? 'P2' : 'P3', + score, + vendor: candidate.vendorHint, + product: candidate.productHint, + 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; + summaryZh?: string; +} + +const AI_KINDS = new Set([ + 'free_credit', + 'limited_offer', + 'price_drop', + 'price_change', + 'new_plan', + 'new_model', + 'model_api_available', + 'model_open_source', + 'model_benchmark', + 'discovered_model', + 'other', +]); + +async function reserveAiCall(env: Env): Promise { + const limit = Math.max(0, Math.min(50, Number(env.AI_DAILY_CALL_LIMIT || 50))); + if (limit === 0) return false; + const usageDate = new Date().toISOString().slice(0, 10); + const row = await env.DB.prepare( + `INSERT INTO ai_daily_usage(usage_date,calls,updated_at) VALUES(?1,1,CURRENT_TIMESTAMP) ON CONFLICT(usage_date) DO UPDATE SET calls=calls+1,updated_at=CURRENT_TIMESTAMP WHERE calls(); + return Boolean(row); +} + +export async function maybeEnrichWithAi(env: Env, source: SourceRow, candidate: Candidate, base: Classification): Promise { + if (env.AI_ENABLED !== 'true' || !env.AI) return base; + if (base.score < 25) return base; + try { + if (!(await reserveAiCall(env))) return base; + } catch { + return base; + } + + const prompt = [ + 'You are a strict classifier for AI model, API, developer pricing and deal intelligence.', + 'Return JSON only. Do not invent missing facts.', + 'Allowed kind: free_credit, limited_offer, price_drop, price_change, new_plan, new_model, model_api_available, model_open_source, model_benchmark, discovered_model, other.', + 'CRITICAL RULES:', + '1. first_seen != newly_released. If a model was created in the past or has no explicit release announcement, DO NOT classify as new_model. Use discovered_model instead.', + '2. expiresAt ONLY represents an explicit expiration or deadline for a limited-time deal, trial offer, discount, or promotion.', + ' NEVER use model creation date, publishedAt, release date, last modified date, benchmark observation date, or current date as expiresAt. Leave expiresAt null if there is no explicit expiration deadline.', + '3. summaryZh MUST be 1-2 fluent, factual Simplified Chinese sentences (under 100 Chinese characters) explaining WHAT happened.', + ' DO NOT translate code blocks, roleplay instructions, chat templates, or prompt examples.', + ' If no clear event happened, state clearly that the model was observed in the catalog/discovery feed.', + `Source trust: ${source.trust_level}`, + `Rule kind: ${base.kind}`, + `Title: ${candidate.title}`, + `Published/Created Date: ${candidate.publishedAt || 'unknown'}`, + `Structured Details: ${(candidate.summary || candidate.rawExcerpt || '').slice(0, 1500)}`, + 'JSON keys: kind, score, vendor, product, expiresAt, previousPrice, currentPrice, currency, summaryZh.', + ].join('\n'); + + try { + const result = await env.AI.run(env.AI_MODEL, { prompt, max_tokens: 256 }); + const raw = + typeof result === 'string' + ? result + : result && typeof result === 'object' && 'response' in result && typeof result.response === 'string' + ? result.response + : JSON.stringify(result); + const match = raw.match(/\{[\s\S]*\}/); + if (!match) return base; + + const parsed = JSON.parse(match[0]) as AiJson; + const aiScore = Number.isFinite(parsed.score) ? Math.max(0, Math.min(100, Number(parsed.score))) : base.score; + const protectedFloor = base.kind === 'new_model' || base.kind === 'model_api_available' || base.kind === 'model_open_source' ? 40 : 0; + const score = base.priority === 'P1' ? Math.max(base.score, aiScore) : Math.max(protectedFloor, aiScore); + const parsedKind = parsed.kind && AI_KINDS.has(parsed.kind) ? parsed.kind : base.kind; + + // Guard against AI hallucinating new_model on old/discovered models + let kind = parsedKind; + if (base.kind === 'discovered_model' && parsedKind === 'new_model') { + kind = 'discovered_model'; + } else if (MODEL_KINDS.has(base.kind) && parsedKind === 'other') { + kind = base.kind; + } + + // Strict expiresAt guard: if candidate is model-related or matches publishedAt, strip expiresAt + let expiresAt: string | undefined = undefined; + if (parsed.expiresAt && typeof parsed.expiresAt === 'string') { + const isModelDiscovery = kind === 'new_model' || kind === 'discovered_model' || kind === 'model_api_available' || kind === 'model_open_source' || kind === 'model_benchmark'; + const isSameAsPublished = candidate.publishedAt && parsed.expiresAt.startsWith(candidate.publishedAt.slice(0, 10)); + if (!isModelDiscovery && !isSameAsPublished) { + expiresAt = parsed.expiresAt; + } + } + + // Summary quality gate + let summaryZh = base.summaryZh; + if (typeof parsed.summaryZh === 'string' && isHighQualityChineseSummary(parsed.summaryZh)) { + summaryZh = parsed.summaryZh.replace(/\s+/g, ' ').trim().slice(0, 180); + } else { + // Deterministic fallback summary + const dummyItem = { + id: 0, + source_id: source.id, + title: candidate.title, + summary: candidate.summary || null, + url: candidate.url || null, + kind, + priority: base.priority, + score, + source_confidence: base.sourceConfidence, + verification_status: base.verificationStatus, + vendor: parsed.vendor || base.vendor || null, + product: parsed.product || base.product || null, + previous_price: parsed.previousPrice ?? base.previousPrice ?? null, + current_price: parsed.currentPrice ?? base.currentPrice ?? null, + currency: parsed.currency || base.currency || null, + expires_at: expiresAt || null, + discovered_at: new Date().toISOString(), + published_at: candidate.publishedAt || null, + pushed_at: null, + }; + summaryZh = buildChineseSummary(dummyItem); + } + + return { + ...base, + kind, + score, + priority: base.priority === 'P1' ? 'P1' : score >= 75 ? 'P1' : score >= 40 ? 'P2' : 'P3', + vendor: parsed.vendor || base.vendor, + product: parsed.product || base.product, + expiresAt, + previousPrice: parsed.previousPrice ?? base.previousPrice, + currentPrice: parsed.currentPrice ?? base.currentPrice, + currency: parsed.currency || base.currency, + summaryZh, + aiEnriched: true, + }; + } catch { + return base; + } +} diff --git a/src/telegram.ts b/src/telegram.ts new file mode 100644 index 0000000..f8a117c --- /dev/null +++ b/src/telegram.ts @@ -0,0 +1,138 @@ +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 sendTelegramMessage(env: Env, text: 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, + }; + 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('"', '"'); +} + +function subject(item: ItemRow): string { + return [item.vendor, item.product].filter(Boolean).join(' ') || item.title; +} + +function price(value: number | null, currency: string | null): string { + return `${currency ? `${currency} ` : ''}${value ?? ''}`.trim(); +} + +export function isHighQualityChineseSummary(text?: string): boolean { + if (!text) return false; + const clean = text.replace(/\s+/g, ' ').trim(); + if (clean.length < 8 || clean.length > 200) return false; + + // Must contain at least some Chinese characters + const chineseCharCount = (clean.match(/[\u4e00-\u9fff]/g) || []).length; + if (chineseCharCount < 6) return false; + + // Reject suspicious instructions, prompt leakage, garbled tokens, or roleplays + const forbiddenPatterns = [ + /请你|请给我|给我一个|不得偷|扮演|忽略之前|system\s*prompt|assistant|user:|human:/i, + /作为一个|作为一个AI|作为AI语言模型/i, + /国度学习模式|不得偷的学习/i, + /```|||\[INST\]|\[\/INST\]/i, + /translation:|translated:/i, + ]; + + if (forbiddenPatterns.some((pattern) => pattern.test(clean))) { + return false; + } + + // Reject nonsensical repetitive characters + if (/(.)\1{4,}/.test(clean)) return false; + + return true; +} + +export function buildChineseSummary(item: ItemRow, aiSummary?: string): string { + if (aiSummary && isHighQualityChineseSummary(aiSummary)) { + return aiSummary.replace(/\s+/g, ' ').trim().slice(0, 180); + } + + const name = subject(item); + const createdDate = item.published_at ? item.published_at.slice(0, 10) : undefined; + + if (item.kind === 'free_credit') return `检测到「${name}」相关免费额度信息。建议查看原文确认可领取额度、适用对象和有效期。`; + if (item.kind === 'limited_offer') return `检测到「${name}」限时优惠。建议查看原文确认优惠幅度、领取条件和截止时间。`; + if (item.kind === 'price_drop') { + if (item.previous_price != null && item.current_price != null) + return `「${name}」价格由 ${price(item.previous_price, item.currency)} 降至 ${price(item.current_price, item.currency)},具体适用模型和计费条件请以原文为准。`; + return `检测到「${name}」价格下调信息,具体降幅、适用模型和生效时间请以原文为准。`; + } + if (item.kind === 'price_change') return `检测到「${name}」定价或计费页面发生变化,建议查看原文确认具体价格和生效时间。`; + if (item.kind === 'new_plan') return `检测到「${name}」推出新套餐或新计划,具体价格、额度和使用限制请查看原文。`; + if (item.kind === 'new_model') return `发现新发布模型「${name}」${createdDate ? `(发布于 ${createdDate})` : ''},已进入 AI-Radar 关注队列。`; + if (item.kind === 'model_api_available') return `检测到「${name}」已出现 API 可用信号,建议查看原文确认提供方、价格、上下文和调用限制。`; + if (item.kind === 'model_open_source') return `检测到「${name}」开源或开放权重信号,建议查看原文确认许可证、权重和使用限制。`; + if (item.kind === 'model_benchmark') return `检测到「${name}」新增评测或榜单信号,建议结合官方资料和其他评测交叉核验。`; + if (item.kind === 'discovered_model') { + return `在信源中观测到模型「${name}」${createdDate ? `(创建/历史时间:${createdDate})` : ''},已作为信源发现记录收录,非近期新发布模型。`; + } + return `检测到「${name}」重要更新,已进入 AI-Radar 高优先级队列,详情请查看原文。`; +} + +export async function pushP1(env: Env, item: ItemRow & { source_name?: string }, summaryZh?: string): Promise { + const labels: Record = { + free_credit: '免费额度', + limited_offer: '限时优惠', + price_drop: '降价', + price_change: '价格变化', + new_plan: '新套餐', + new_model: '新模型', + model_api_available: 'API 可用', + model_open_source: '模型开源', + model_benchmark: '模型评测', + discovered_model: '模型发现', + other: '重要信息', + }; + const lines = [ + '🔥 高价值情报', + `类型:${h(labels[item.kind] || '重要信息')}`, + '', + `${h(item.title)}`, + '', + '📝 中文摘要', + h(buildChineseSummary(item, summaryZh)), + ]; + 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 sendTelegramMessage(env, lines.join('\n')); +} + +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 sendTelegramMessage(env, text); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..401d070 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,79 @@ +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; + AI_DAILY_CALL_LIMIT: string; + SOURCE_BATCH_SIZE: string; + PUBLIC_BASE_URL: string; + TELEGRAM_BOT_TOKEN?: string; + TELEGRAM_CHAT_ID?: string; + ADMIN_TOKEN?: string; +} + +export type SourceType = 'rss' | 'web' | 'github' | 'x'; +export type SourceTier = 'core' | 'discovery' | 'temporary' | 'candidate'; +export type DiscoveryProvider = 'openrouter_models' | 'huggingface_models' | 'artificial_analysis_models'; +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' + | 'new_model' + | 'model_api_available' + | 'model_open_source' + | 'model_benchmark' + | 'discovered_model' + | 'other'; + +export interface SourceConfig { + userAgent?: string; + selectorHint?: string; + githubMode?: 'commits' | 'releases'; + githubOwner?: string; + githubRepo?: string; + githubBranch?: string; + discoveryProvider?: DiscoveryProvider; + sourceTier?: SourceTier; + discoveredFrom?: number; + discoverySignal?: ItemKind; +} + +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; + source_tier?: SourceTier; discovered_from_source_id?: number | null; expires_at?: string | null; hit_count?: number; +} + +export interface Candidate { + externalId?: string; title: string; summary?: string; url?: string; publishedAt?: string; rawExcerpt?: string; + signalKind?: Extract; + vendorHint?: string; productHint?: 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; summaryZh?: 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; previous_price: number | null; current_price: number | null; currency: 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(//gi, ' ').replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ').replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<') + .replace(/>/gi, '>').replace(/'/gi, "'").replace(/"/gi, '"').replace(/\s+/g, ' ').trim(); +} + +export function textExcerpt(input: string, max = 1600): string { + const text = stripHtml(input); + return text.length <= max ? text : `${text.slice(0, max)}…`; +} + +export function isoNow(): string { return new Date().toISOString(); } +export function addMinutesIso(from: Date, minutes: number): string { return new Date(from.getTime() + minutes * 60_000).toISOString(); } +export function escapeHtml(value: string): string { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } +export function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data, null, 2), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } }); } +export function parseJson(value: string | null, fallback: T): T { if (!value) return fallback; try { return JSON.parse(value) as T; } catch { return fallback; } } diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..8b4cd24 --- /dev/null +++ b/tests/core.test.ts @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { collectSource } from '../src/collectors'; +import { beijingWindow, renderReport } from '../src/daily'; +import { buildFingerprint } from '../src/db'; +import { toPublicItem } from '../src/index'; +import { classifyDeterministically, isRecentRelease, maybeEnrichWithAi } from '../src/rules'; +import { buildChineseSummary, isHighQualityChineseSummary, pushDailyReport, pushP1 } from '../src/telegram'; +import type { Env, ItemRow, SourceRow } from '../src/types'; + +const source = (type: SourceRow['type']): SourceRow => ({ + id: 7, + name: 'Example', + url: 'https://example.com', + type, + trust_level: 'A', + enabled: 1, + interval_minutes: 60, + config_json: null, + etag: null, + last_modified: null, + content_hash: null, + next_fetch_at: null, + last_fetch_at: null, + last_success_at: null, + failure_count: 0, + status: 'ok', +}); + +const item: ItemRow & { source_name: string; last_verified_at: string | null } = { + id: 1, + source_id: 7, + title: 'Example free credit', + summary: 'Free API credit for developers.', + url: 'https://example.com/deal', + kind: 'free_credit', + priority: 'P2', + score: 65, + source_confidence: 'high', + verification_status: 'official_confirmed', + vendor: 'Example Vendor', + product: 'Example API', + previous_price: 10, + current_price: 0, + currency: 'USD', + expires_at: '2026-09-30', + discovered_at: '2026-08-31T03:00:00.000Z', + published_at: '2026-08-31T02:00:00.000Z', + pushed_at: null, + source_name: 'Official pricing', + last_verified_at: '2026-08-31T04:00:00.000Z', +}; + +test('Beijing daily window ends exactly at 12:30 Asia/Shanghai', () => { + const window = beijingWindow(new Date('2026-08-31T04:30:00.000Z')); + assert.deepEqual(window, { + reportDate: '2026-08-31', + start: '2026-08-30T04:30:00.000Z', + end: '2026-08-31T04:30:00.000Z', + }); +}); + +test('web fingerprints distinguish content changes at the same URL', async () => { + const first = await buildFingerprint(source('web'), { title: 'Changed', url: source('web').url, externalId: 'hash-a' }); + const second = await buildFingerprint(source('web'), { title: 'Changed', url: source('web').url, externalId: 'hash-b' }); + assert.notEqual(first, second); +}); + +test('feed fingerprints still deduplicate the same canonical URL', async () => { + const first = await buildFingerprint(source('rss'), { title: 'Deal', url: 'https://example.com/deal?utm_source=a' }); + const second = await buildFingerprint(source('rss'), { title: 'Deal updated', url: 'https://example.com/deal?utm_source=b' }); + assert.equal(first, second); +}); + +test('model discovery fingerprints are event-aware', async () => { + const newModel = await buildFingerprint(source('web'), { title: 'Quasar', signalKind: 'new_model', vendorHint: 'Multiverse', productHint: 'Quasar 438B' }); + const api = await buildFingerprint(source('web'), { title: 'Quasar API', signalKind: 'model_api_available', vendorHint: 'Multiverse', productHint: 'Quasar 438B' }); + assert.notEqual(newModel, api); +}); + +test('public item projection exposes only user-facing fields', () => { + const publicItem = toPublicItem(Object.assign({}, item, { raw_excerpt: 'internal', ai_enriched: 1 })); + assert.equal(publicItem.title, item.title); + for (const key of ['priority', 'score', 'source_confidence', 'source_id', 'raw_excerpt', 'ai_enriched', 'pushed_at']) { + assert.equal(key in publicItem, false, `${key} must not be public`); + } +}); + +test('daily HTML uses public categories and never renders internal priorities', () => { + const modelItem = Object.assign({}, item, { id: 2, kind: 'new_model' as const, title: 'Quasar 438B', priority: 'P2' as const }); + const html = renderReport('2026-08-31', [item, modelItem]); + assert.match(html, /免费额度/); + assert.match(html, /新模型与 API/); + assert.match(html, /Quasar 438B/); + assert.match(html, /Example Vendor/); + assert.match(html, /最后核验/); + assert.doesNotMatch(html, /\bP[123]\b/); +}); + +test('RSS, GitHub Atom, web, and model discovery collectors recognize new content', async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => + new Response( + `deal-2New free creditshttps://example.com/deal-2New AI API creditsMon, 31 Aug 2026 03:00:00 GMT`, + { status: 200, headers: { etag: '"rss-v2"' } } + ); + const rss = await collectSource(source('rss')); + assert.equal(rss.candidates[0]?.externalId, 'deal-2'); + assert.equal(rss.candidates[0]?.url, 'https://example.com/deal-2'); + + let githubUrl = ''; + globalThis.fetch = async (input) => { + githubUrl = String(input); + return new Response( + `tag:github.com,2008:Grit::Commit/feed123New release pricing2026-08-31T03:10:00Z`, + { status: 200 } + ); + }; + const githubSource = Object.assign(source('github'), { + config_json: JSON.stringify({ githubOwner: 'example', githubRepo: 'repo', githubMode: 'commits', githubBranch: 'main' }), + }); + const github = await collectSource(githubSource); + assert.equal(githubUrl, 'https://github.com/example/repo/commits/main.atom'); + assert.equal(github.candidates[0]?.externalId, 'tag:github.com,2008:Grit::Commit/feed123'); + + globalThis.fetch = async () => new Response('Price is now $1', { status: 200 }); + const webBefore = await collectSource(source('web')); + globalThis.fetch = async () => new Response('Price is now free', { status: 200 }); + const webAfter = await collectSource(source('web')); + assert.notEqual(webBefore.contentHash, webAfter.contentHash); + assert.notEqual(webBefore.candidates[0]?.externalId, webAfter.candidates[0]?.externalId); + + const openRouterSource = Object.assign(source('web'), { + url: 'https://openrouter.ai/api/v1/models', + config_json: JSON.stringify({ discoveryProvider: 'openrouter_models' }), + }); + globalThis.fetch = async () => + new Response( + JSON.stringify({ + data: [{ id: 'multiverse/quasar-438b', name: 'Quasar 438B', created: 1788360000, context_length: 131072, pricing: { prompt: '0.000001', completion: '0.000003' } }], + }), + { status: 200 } + ); + const openRouter = await collectSource(openRouterSource); + assert.equal(openRouter.candidates[0]?.signalKind, 'model_api_available'); + assert.equal(openRouter.candidates[0]?.productHint, 'Quasar 438B'); + + const aaSource = Object.assign(source('web'), { + url: 'https://artificialanalysis.ai/models', + config_json: JSON.stringify({ discoveryProvider: 'artificial_analysis_models' }), + }); + globalThis.fetch = async () => + new Response('Quasar 438B', { status: 200 }); + const aa = await collectSource(aaSource); + assert.equal(aa.candidates[0]?.externalId, 'artificial-analysis:quasar-438b'); + assert.equal(aa.candidates[0]?.signalKind, 'discovered_model'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +// Regression Test 1: Hugging Face model created in 2024 (age > 14 days) +test('Regression 1: Hugging Face model from 2024 is discovered_model, never new_model or P1', () => { + const hfSource = Object.assign(source('web'), { trust_level: 'B' as const }); + const fixedNow = new Date('2026-09-03T00:00:00.000Z'); + const candidate = { + title: 'Model discovery: OBLITERATUS/Ornith-1.5-9B-OBLITERATED', + summary: 'Model ID: OBLITERATUS/Ornith-1.5-9B-OBLITERATED | Created: 2024-09-23 | Status: Observed in Hugging Face trending discovery set', + publishedAt: '2024-09-23T00:00:00.000Z', + signalKind: 'discovered_model' as const, + vendorHint: 'OBLITERATUS', + productHint: 'OBLITERATUS/Ornith-1.5-9B-OBLITERATED', + }; + const result = classifyDeterministically(hfSource, candidate, fixedNow); + assert.equal(result.kind, 'discovered_model'); + assert.notEqual(result.kind, 'new_model'); + assert.notEqual(result.priority, 'P1'); + assert.ok(result.score < 75); +}); + +// Regression Test 2: Hugging Face model created 3 days ago +test('Regression 2: Hugging Face model created 3 days ago is new_model and at least P2', () => { + const hfSource = Object.assign(source('web'), { trust_level: 'B' as const }); + const fixedNow = new Date('2026-09-03T00:00:00.000Z'); + const candidate = { + title: 'Model discovery: deepseek-ai/DeepSeek-V3.5', + summary: 'Model ID: deepseek-ai/DeepSeek-V3.5 | Created: 2026-08-31 | Status: Observed in Hugging Face trending discovery set', + publishedAt: '2026-08-31T00:00:00.000Z', + signalKind: 'new_model' as const, + vendorHint: 'deepseek-ai', + productHint: 'DeepSeek-V3.5', + }; + const result = classifyDeterministically(hfSource, candidate, fixedNow); + assert.equal(result.kind, 'new_model'); + assert.ok(result.priority === 'P1' || result.priority === 'P2'); + assert.ok(result.score >= 40); +}); + +// Regression Test 3: Official announcement "Introducing Quasar 438B" within 7 days +test('Regression 3: Official release "Introducing Quasar 438B" is new_model and can become P1 with API/Coding signals', () => { + const officialSource = Object.assign(source('web'), { trust_level: 'A' as const }); + const fixedNow = new Date('2026-09-03T00:00:00.000Z'); + const candidate = { + title: 'Introducing Quasar 438B', + summary: 'Today we announce and release the Quasar 438B model. It is now available via API for coding and agent workflows.', + publishedAt: '2026-08-30T00:00:00.000Z', + productHint: 'Quasar 438B', + vendorHint: 'Multiverse Computing', + }; + const result = classifyDeterministically(officialSource, candidate, fixedNow); + assert.equal(result.kind, 'new_model'); + assert.equal(result.priority, 'P1'); + assert.ok(result.score >= 75); +}); + +// Regression Test 4: Garbled / hallucinated AI Chinese summary rejected by quality gate +test('Regression 4: Garbled AI Chinese summary is rejected and falls back to deterministic factual summary', async () => { + const candidate = { + title: 'Model discovery: OBLITERATUS/Ornith-1.5-9B-OBLITERATED', + summary: 'Model ID: OBLITERATUS/Ornith-1.5-9B-OBLITERATED | Created: 2024-09-23', + publishedAt: '2024-09-23T00:00:00.000Z', + signalKind: 'discovered_model' as const, + }; + const base = classifyDeterministically(source('web'), candidate); + const env = { + AI_ENABLED: 'true', + AI_MODEL: '@cf/meta/llama-3.1-8b-instruct', + AI_DAILY_CALL_LIMIT: '50', + DB: { prepare: () => ({ bind: () => ({ first: async () => ({ calls: 1 }) }) }) }, + AI: { + run: async () => ({ + response: JSON.stringify({ + kind: 'discovered_model', + score: 30, + summaryZh: '一个回家的国度学习模式。给我一个不得偷的学习。', + }), + }), + }, + } as unknown as Env; + + const enriched = await maybeEnrichWithAi(env, source('web'), candidate, base); + assert.doesNotMatch(enriched.summaryZh || '', /国度学习模式|不得偷/); + assert.match(enriched.summaryZh || '', /在信源中观测到模型|收录/); + assert.equal(isHighQualityChineseSummary('一个回家的国度学习模式。给我一个不得偷的学习。'), false); +}); + +// Regression Test 5: createdAt must never become expires_at +test('Regression 5: createdAt = 2024-09-23 preserves published_at but expires_at is null', async () => { + const candidate = { + title: 'Model discovery: OBLITERATUS/Ornith-1.5-9B-OBLITERATED', + summary: 'Model ID: OBLITERATUS/Ornith-1.5-9B-OBLITERATED | Created: 2024-09-23', + publishedAt: '2024-09-23T00:00:00.000Z', + signalKind: 'discovered_model' as const, + }; + const base = classifyDeterministically(source('web'), candidate); + const env = { + AI_ENABLED: 'true', + AI_MODEL: '@cf/meta/llama-3.1-8b-instruct', + AI_DAILY_CALL_LIMIT: '50', + DB: { prepare: () => ({ bind: () => ({ first: async () => ({ calls: 1 }) }) }) }, + AI: { + run: async () => ({ + response: JSON.stringify({ + kind: 'discovered_model', + score: 30, + expiresAt: '2024-09-23T00:00:00Z', + summaryZh: '在信源中观测到模型 Ornith-1.5-9B,创建于 2024-09-23。', + }), + }), + }, + } as unknown as Env; + + const enriched = await maybeEnrichWithAi(env, source('web'), candidate, base); + assert.equal(enriched.expiresAt, undefined); +}); + +test('P1 fallback still produces Chinese summary when Workers AI is unavailable', () => { + const summary = buildChineseSummary(Object.assign({}, item, { kind: 'free_credit' })); + assert.match(summary, /免费额度/); + assert.match(summary, /Example Vendor Example API/); +}); + +test('Telegram is outbound-only and sends P1/daily to one chat without topics', async () => { + const originalFetch = globalThis.fetch; + const payloads: Array> = []; + try { + globalThis.fetch = async (_input, init) => { + payloads.push(JSON.parse(String(init?.body)) as Record); + return new Response('{"ok":true}', { status: 200 }); + }; + const env = { + TELEGRAM_BOT_TOKEN: 'dedicated-test-token', + TELEGRAM_CHAT_ID: '-1001234567890', + PUBLIC_BASE_URL: 'https://ai-radar.example', + } as unknown as Env; + assert.equal(await pushP1(env, Object.assign({}, item, { source_name: 'Official pricing' }), '这是中文摘要测试。'), true); + assert.equal(await pushDailyReport(env, '2026-08-31', { p2: 2, p3: 3 }), true); + assert.equal(payloads.length, 2); + for (const payload of payloads) { + assert.equal(payload.chat_id, '-1001234567890'); + assert.equal('message_thread_id' in payload, false); + } + assert.match(String(payloads[0].text), /🔥 高价值情报<\/b>/); + assert.match(String(payloads[0].text), /📝 中文摘要<\/b>/); + assert.match(String(payloads[0].text), /这是中文摘要测试/); + assert.doesNotMatch(String(payloads[0].text), /Free API credit for developers/); + assert.match(String(payloads[1].text), /📋 AI-Radar 日报 · 2026-08-31<\/b>/); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b656429 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "WebWorker", "DOM.Iterable"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowJs": false + }, + "include": ["src/**/*.ts"] +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..45c0ae8 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,38 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "ai-radar", + "main": "src/index.ts", + "compatibility_date": "2026-08-30", + "workers_dev": true, + "assets": { + "directory": "./public", + "binding": "ASSETS", + "run_worker_first": ["/api/*", "/daily/*", "/latest"] + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "ai-radar-db", + "database_id": "5bab76fa-866a-49dc-839e-febb583a71df", + "migrations_dir": "migrations" + } + ], + "ai": { + "binding": "AI" + }, + "triggers": { + "crons": [ + "*/5 * * * *", + "30 4 * * *" + ] + }, + "vars": { + "APP_NAME": "AI-Radar", + "APP_TIMEZONE": "Asia/Shanghai", + "AI_ENABLED": "true", + "AI_MODEL": "@cf/meta/llama-3.1-8b-instruct-fast", + "AI_DAILY_CALL_LIMIT": "50", + "SOURCE_BATCH_SIZE": "10", + "PUBLIC_BASE_URL": "https://ai-radar.mzer8-substracker.workers.dev" + } +}