From cf353fe6866d86c2485f962c2cf1204cf6699287 Mon Sep 17 00:00:00 2001 From: Conrad <23010262@muc.edu.cn> Date: Wed, 3 Jun 2026 17:48:54 +0800 Subject: [PATCH 1/2] docs: document source quality iteration --- CLAUDE.md | 7 +++++-- README.md | 26 +++++++++++++++++++++++--- docs/configuration.zh.md | 27 +++++++++++++++++++++++++++ docs/getting-started.zh.md | 16 ++++++++++------ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ee68b7..f59c7ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ The pipeline runs in four sequential phases, each implemented as a named async f | `src/web.ts` | Sitemap-based web content fetching; state persisted to `digests/web-state.json` | | `src/trending.ts` | GitHub Trending HTML scraper + Search API topic queries | | `src/hn.ts` | Hacker News top AI stories via Algolia HN Search API | -| `src/generate-manifest.ts` | Generates `manifest.json` (sidebar data for Web UI) and `feed.xml` (RSS 2.0 feed) | +| `src/generate-manifest.ts` | Generates `manifest.json` (sidebar data for Web UI), `digests/search-index.json` (topic search data), and `feed.xml` (RSS 2.0 feed) | | `src/topic-radar.ts` | Normalizes source data into an editorial topic pool, scores topics, and writes `ai-topic-radar.md` / `topic-pool.json` | | `src/kr36.ts` | 36kr AI news articles fetched via RSS feed | | `src/infoq-cn.ts` | InfoQ China AI articles fetched via internal API | @@ -124,12 +124,15 @@ Files written to `digests/YYYY-MM-DD/`: - GitHub issue label colors are defined in `LABEL_COLORS` in `src/github.ts`. Add new labels there. - `sampleNote(total, sampled)` in `src/prompts.ts` formats the "(共 N 条,展示前 M 条)" note. Reuse it — do not inline the same string format. - Web state (`digests/web-state.json`) is committed to git on every run. It is the source of truth for which URLs have been seen. +- Topic search state (`digests/search-index.json`) is generated from daily `topic-pool.json` files. It reads the current `candidates` field first and falls back to legacy `topics`. +- Topic candidates are URL-deduplicated. GitHub + Gitee repository candidates are capped at 20 items, and GitHub repository heat uses logarithmic scoring to avoid star-only dominance. ## Web UI & RSS Feed - Web UI: `index.html` reads `manifest.json` to build the sidebar, then fetches `digests/YYYY-MM-DD/report.md` on demand. +- Search UI: `index.html` reads `digests/search-index.json`, which is generated in the same `pnpm manifest` step. - RSS Feed: `feed.xml` at the repo root. Generated by `src/generate-manifest.ts` in the same `pnpm manifest` step. Contains the latest 30 items (newest first) across all report types. Item links use hash routing: `https://conradgui.github.io/AI-TREND-RADAR/#YYYY-MM-DD/report`. -- Both `manifest.json` and `feed.xml` are committed together in the "Commit manifest and feed" GHA step. +- `manifest.json`, `digests/search-index.json`, and `feed.xml` are committed together in the "Commit manifest and feed" GHA step. - The `REPORT_LABELS` map in `src/i18n.ts` must be kept in sync with the `LABELS` object in `index.html` when adding new report types. ## Adding a new report type diff --git a/README.md b/README.md index 8099a1b..ebe054c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ | `digests/YYYY-MM-DD/ai-topic-radar.md` | Markdown 版本 | | `digests/YYYY-MM-DD/topic-pool.json` | 结构化选题池(分数、分类、动作、理由、证据) | | `digests/YYYY-MM-DD/ai-china-tech.md` | 中文科技社区 AI 动态日报(36kr + InfoQ + Gitee + OSChina + 掘金) | -| `manifest.json` | 历史 Web UI 索引 | +| `manifest.json` | 历史 Web UI 侧边栏索引 | +| `digests/search-index.json` | 历史选题搜索索引 | | `feed.xml` | RSS 订阅源 | ### 适合谁? @@ -86,6 +87,15 @@ pnpm digest 某个来源失败不会中断日报,主报告会在"数据源状态与修复提示"里说明原因。 +状态语义: + +- `ok`:来源成功返回并产出内容。 +- `empty`:来源成功访问,但本轮没有符合条件的新内容;不等同于故障。 +- `skipped`:缺少可选 token 或配置,主动跳过。 +- `error`:网络、接口结构或上游返回异常;HTTP 200 但返回拦截页、HTML 或异常 XML 也会标记为失败。 + +Gitee 是尽力而为来源,空结果或部分关键词失败不会阻断日报。 + ## 功能模块总览 | 模块 | 能力 | 默认 | @@ -95,6 +105,7 @@ pnpm digest | 选题评分 | 商业影响 40、热度 30、新鲜度 20、可写性 10 | 是 | | 内容分类 | 政策监管、模型突破、AI 产品、行业落地、标杆企业与商业格局 | 是 | | 中文科技社区报告 | `ai-china-tech.md`(36kr + InfoQ + Gitee + OSChina + 掘金) | 是 | +| 搜索索引 | `digests/search-index.json`,从每日 `topic-pool.json` 生成 | 是 | | 源级报告 | `ai-web.md`、`ai-hn.md`、`ai-arxiv.md` 等 | 默认关闭 | | 英文报告 | `*-en.md` | 默认关闭 | | 历史 Web UI | `index.html` + `manifest.json` | 是 | @@ -122,6 +133,15 @@ pnpm digest - 企业落地与行业应用 - 标杆企业动向、商业格局与投融资 +选题池还有几条约束: + +- `topic-pool.json` 的主数据字段是 `candidates`;历史版本的 `topics` 会继续被搜索索引兼容读取。 +- 候选池会按 URL 去重。 +- GitHub + Gitee 仓库类候选最多保留 20 条,避免开源仓库长期挤满 60 个候选位。 +- GitHub 仓库热度使用对数计分,高 star 仍有优势,但不会仅凭总 star 挤掉其他来源。 +- Dev.to 和 Lobsters 社区内容会进入选题池,不只生成社区报告。 +- 50-64 分是"观察项"。如果当天没有观察项,报告会显示说明文案;这不代表采集失败。 + ## 三种使用方式 ### 方式一:本地使用 @@ -163,14 +183,14 @@ pnpm digest | `pnpm serve` | 本地查看历史 Web UI | | `pnpm weekly` | 生成周报 | | `pnpm monthly` | 生成月报 | -| `pnpm test` | 单元测试(224 个) | +| `pnpm test` | 单元测试(272 个) | | `pnpm typecheck` | TypeScript 类型检查 | ## 验证状态 - TypeScript typecheck: 通过 - ESLint: 通过 -- 单元测试: 224/224 通过 +- 单元测试: 272/272 通过 ## 作品集边界 diff --git a/docs/configuration.zh.md b/docs/configuration.zh.md index ef2df1d..2b1321d 100644 --- a/docs/configuration.zh.md +++ b/docs/configuration.zh.md @@ -99,6 +99,33 @@ GitHub Actions Secret 名称: PRODUCTHUNT_TOKEN ``` +## 数据源状态与选题规则 + +日报不会因为单个公开来源失败而中断。主报告里的"数据源状态与修复提示"使用以下语义: + +| 状态 | 含义 | +| --- | --- | +| `ok` | 成功获取并产出内容 | +| `empty` | 成功访问,但本轮没有符合条件的新内容 | +| `skipped` | 缺少可选 token 或配置,主动跳过 | +| `error` | 网络、接口结构或上游返回异常 | + +注意: + +- `empty` 不是故障。官方内容源、Gitee 等来源在首次运行后没有新增内容是正常情况。 +- ArXiv、36kr、OSChina 和官网 sitemap 会校验最小 XML 结构;HTTP 200 但返回 HTML 拦截页或异常 XML 时会标记为 `error`。 +- Gitee 是尽力而为来源;空结果、部分关键词失败或接口短暂不可用不会阻断日报。 + +选题池规则: + +- 每日结构化选题池写入 `digests/YYYY-MM-DD/topic-pool.json`,主字段为 `candidates`。 +- 搜索索引 `digests/search-index.json` 由 `pnpm manifest` 生成,优先读取 `candidates`,并兼容旧版 `topics`。 +- 候选会按 URL 去重。 +- GitHub + Gitee 仓库类候选最多保留 20 条,避免仓库信号长期挤满候选池。 +- GitHub 仓库热度使用对数计分,高 star 项目仍可入池,但不会仅凭总 star 压过其他来源。 +- Dev.to 和 Lobsters 社区内容会进入选题池,同时也可用于社区报告。 +- 50-64 分是"观察项";如果观察项为空,报告会显示解释文案,这不代表采集失败。 + ## Telegram 需要两个值: diff --git a/docs/getting-started.zh.md b/docs/getting-started.zh.md index d5c7a59..adab582 100644 --- a/docs/getting-started.zh.md +++ b/docs/getting-started.zh.md @@ -57,6 +57,7 @@ pnpm digest - 终端最后看到 `Done!` - 出现 `digests/YYYY-MM-DD/ai-topic-radar.html` - 出现 `manifest.json` +- 出现 `digests/search-index.json` - 出现 `feed.xml` 打开报告: @@ -70,6 +71,7 @@ digests/YYYY-MM-DD/ai-topic-radar.html - `Missing DEEPSEEK_API_KEY`:当前终端没有成功 export key,重新执行上面的 `read -r -s` 命令。 - GitHub API 403:说明匿名 GitHub API 限流。可以先忽略,也可以配置 `GITHUB_TOKEN`。 - Product Hunt skipped:没有配置 `PRODUCTHUNT_TOKEN`,不影响主报告。 +- 数据源显示 `empty`:表示来源成功访问但本轮没有新内容,不等同于失败。 ## 第 2 步:启用 GitHub Actions 自动日报 @@ -105,6 +107,7 @@ Actions -> Daily AI Topic Radar -> Run workflow - workflow 绿色通过 - 仓库出现新的 `digests/YYYY-MM-DD/` - `manifest.json` 和 `feed.xml` 被更新 +- `digests/search-index.json` 被更新,并从每日 `topic-pool.json` 的 `candidates` 生成历史搜索数据 - 如果配置了通知 token,Telegram / 飞书收到消息 常见失败: @@ -124,9 +127,7 @@ Settings -> Pages 推荐配置: ```text -Source: Deploy from a branch -Branch: main -Folder: / (root) +Source: GitHub Actions ``` 访问历史 Web UI: @@ -157,6 +158,8 @@ https://conradgui.github.io/AI-TREND-RADAR/feed.xml - 404:Pages 还没部署完成,等 1-3 分钟;或仓库路径大小写写错。 - Web UI 空白:确认 `manifest.json` 已提交,且 `digests/YYYY-MM-DD/ai-topic-radar.md` 存在。 +- 搜索没有结果:确认 `digests/search-index.json` 已提交,且对应日期的 `topic-pool.json` 里有 `candidates`。 +- 观察项暂无条目:表示当天没有 50-64 分候选;不代表数据源采集失败。 - 通知里的链接打不开:确认 workflow 的 `PAGES_URL` 使用 `https://conradgui.github.io/AI-TREND-RADAR`。 ## 第 4 步:配置 Telegram 和飞书通知 @@ -234,9 +237,10 @@ REPORT_LANGS=zh,en SAVE_SOURCE_REPORTS=1 pnpm digest 3. `pnpm manifest` 4. 打开 `digests/YYYY-MM-DD/ai-topic-radar.html` 5. 检查 `manifest.json` -6. 检查 `feed.xml` -7. 检查 GitHub Actions Secrets -8. 检查 GitHub Pages URL 大小写 +6. 检查 `digests/search-index.json` +7. 检查 `feed.xml` +8. 检查 GitHub Actions Secrets +9. 检查 GitHub Pages URL 大小写 ## 安全提醒 From 708dea57d2824e29af366c8be6e674b396b54edc Mon Sep 17 00:00:00 2001 From: Conrad <23010262@muc.edu.cn> Date: Wed, 3 Jun 2026 18:21:23 +0800 Subject: [PATCH 2/2] fix: prioritize official topic sources --- README.md | 12 ++- docs/configuration.zh.md | 6 +- src/__tests__/topic-radar.test.ts | 131 +++++++++++++++++++++++++++++- src/topic-radar.ts | 58 +++++++++---- 4 files changed, 186 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ebe054c..e08dd0a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ pnpm digest Gitee 是尽力而为来源,空结果或部分关键词失败不会阻断日报。 +选题排序优先级默认按一手程度处理:OpenAI / Anthropic / Google DeepMind 官网优先,其次是产品、模型、开源和国际社区信号;国内媒体和国内开发者社区作为补充观察源。 + ## 功能模块总览 | 模块 | 能力 | 默认 | @@ -137,8 +139,12 @@ Gitee 是尽力而为来源,空结果或部分关键词失败不会阻断日 - `topic-pool.json` 的主数据字段是 `candidates`;历史版本的 `topics` 会继续被搜索索引兼容读取。 - 候选池会按 URL 去重。 -- GitHub + Gitee 仓库类候选最多保留 20 条,避免开源仓库长期挤满 60 个候选位。 +- 官网一手内容会获得更高排序权重;HTTP 200 但结构异常的官网响应仍会标记为失败。 +- GitHub + Gitee 仓库类候选最多保留 16 条,避免开源仓库长期挤满 60 个候选位。 +- Gitee 单源最多 3 条。 - GitHub 仓库热度使用对数计分,高 star 仍有优势,但不会仅凭总 star 挤掉其他来源。 +- 国内源拆分为国内媒体(InfoQ 中国、36kr)和国内开发者社区(开源中国、掘金);国内源合计最多 6 条。 +- 掘金单源最多 2 条,定位为补充社区观察,不作为 Top 深挖主来源。 - Dev.to 和 Lobsters 社区内容会进入选题池,不只生成社区报告。 - 50-64 分是"观察项"。如果当天没有观察项,报告会显示说明文案;这不代表采集失败。 @@ -183,14 +189,14 @@ pnpm digest | `pnpm serve` | 本地查看历史 Web UI | | `pnpm weekly` | 生成周报 | | `pnpm monthly` | 生成月报 | -| `pnpm test` | 单元测试(272 个) | +| `pnpm test` | 单元测试(274 个) | | `pnpm typecheck` | TypeScript 类型检查 | ## 验证状态 - TypeScript typecheck: 通过 - ESLint: 通过 -- 单元测试: 272/272 通过 +- 单元测试: 274/274 通过 ## 作品集边界 diff --git a/docs/configuration.zh.md b/docs/configuration.zh.md index 2b1321d..b97f2fb 100644 --- a/docs/configuration.zh.md +++ b/docs/configuration.zh.md @@ -121,8 +121,12 @@ PRODUCTHUNT_TOKEN - 每日结构化选题池写入 `digests/YYYY-MM-DD/topic-pool.json`,主字段为 `candidates`。 - 搜索索引 `digests/search-index.json` 由 `pnpm manifest` 生成,优先读取 `candidates`,并兼容旧版 `topics`。 - 候选会按 URL 去重。 -- GitHub + Gitee 仓库类候选最多保留 20 条,避免仓库信号长期挤满候选池。 +- OpenAI / Anthropic / Google DeepMind 官网一手内容拥有最高排序优先级。 +- GitHub + Gitee 仓库类候选最多保留 16 条,避免仓库信号长期挤满候选池。 +- Gitee 单源最多 3 条。 - GitHub 仓库热度使用对数计分,高 star 项目仍可入池,但不会仅凭总 star 压过其他来源。 +- 国内源拆分为国内媒体(InfoQ 中国、36kr)和国内开发者社区(开源中国、掘金);国内源合计最多 6 条。 +- 掘金单源最多 2 条,定位为补充社区观察,不作为 Top 深挖主来源。 - Dev.to 和 Lobsters 社区内容会进入选题池,同时也可用于社区报告。 - 50-64 分是"观察项";如果观察项为空,报告会显示解释文案,这不代表采集失败。 diff --git a/src/__tests__/topic-radar.test.ts b/src/__tests__/topic-radar.test.ts index bc1dacb..6160cec 100644 --- a/src/__tests__/topic-radar.test.ts +++ b/src/__tests__/topic-radar.test.ts @@ -176,8 +176,137 @@ describe("buildTopicRadar", () => { const repoCount = candidates.filter( (candidate) => candidate.source.startsWith("GitHub Search") || candidate.source === "Gitee", ).length; + const giteeCount = candidates.filter((candidate) => candidate.source === "Gitee").length; - expect(repoCount).toBeLessThanOrEqual(20); + expect(repoCount).toBeLessThanOrEqual(16); + expect(giteeCount).toBeLessThanOrEqual(3); + }); + + it("prioritizes fresh official sources over high-heat Juejin articles", () => { + const input = baseInput(); + input.webResults = [ + { + site: "openai", + siteName: "OpenAI", + isFirstRun: false, + totalDiscovered: 3, + status: status("web-openai", "ok"), + newItems: Array.from({ length: 3 }, (_, index) => ({ + url: `https://openai.com/index/official-${index}`, + title: `OpenAI enterprise product launch ${index}`, + lastmod: "2026-05-20", + content: + "OpenAI launches a new enterprise AI product with pricing, customer rollout, and platform availability.", + site: "openai", + category: "product", + })), + }, + ]; + input.chinaSourcesData = { + kr36: { articles: [], fetchSuccess: true, status: status("kr36") }, + infoqCn: { articles: [], fetchSuccess: true, status: status("infoq-cn") }, + gitee: { projects: [], fetchSuccess: true, status: status("gitee") }, + oschina: { news: [], fetchSuccess: true, status: status("oschina") }, + juejin: { + articles: Array.from({ length: 8 }, (_, index) => ({ + id: `juejin-${index}`, + title: `掘金 AI 编程爆款文章 ${index}`, + url: `https://juejin.cn/post/${index}`, + brief: "AI 编程、企业用户和产品体验相关的高热度社区文章,适合观察但不是一手官方信号。", + author: "掘金作者", + diggCount: 10_000 - index, + viewCount: 100_000 - index, + tags: ["AI编程", "前端"], + publishTime: "2026-05-20T00:00:00Z", + })), + fetchSuccess: true, + status: status("juejin"), + }, + }; + + const candidates = buildTopicRadar(input).candidates; + const officialScores = candidates + .filter((candidate) => candidate.source === "OpenAI") + .map((candidate) => candidate.score); + const juejinScores = candidates + .filter((candidate) => candidate.source === "掘金") + .map((candidate) => candidate.score); + + expect(candidates.slice(0, 3).every((candidate) => candidate.source === "OpenAI")).toBe(true); + expect(Math.min(...officialScores)).toBeGreaterThan(Math.max(...juejinScores)); + }); + + it("caps domestic media and domestic community candidates separately", () => { + const input = baseInput(); + input.chinaSourcesData = { + kr36: { + articles: Array.from({ length: 6 }, (_, index) => ({ + id: `kr36-${index}`, + title: `36kr AI 企业落地新闻 ${index}`, + url: `https://36kr.com/p/${index}`, + summary: "AI 企业落地、商业化和用户增长相关报道,来自国内媒体。", + publishedAt: "2026-05-20T00:00:00Z", + author: "36kr", + })), + fetchSuccess: true, + status: status("kr36"), + }, + infoqCn: { + articles: Array.from({ length: 6 }, (_, index) => ({ + id: `infoq-${index}`, + title: `InfoQ 中国 AI 架构实践 ${index}`, + url: `https://www.infoq.cn/article/${index}`, + summary: "企业 AI 架构、模型应用和工程落地经验。", + author: "InfoQ", + publishTime: "2026-05-20T00:00:00Z", + topics: ["AI", "架构"], + })), + fetchSuccess: true, + status: status("infoq-cn"), + }, + gitee: { projects: [], fetchSuccess: true, status: status("gitee") }, + oschina: { + news: Array.from({ length: 6 }, (_, index) => ({ + id: `oschina-${index}`, + title: `开源中国 AI 产品动态 ${index}`, + url: `https://www.oschina.net/news/${index}`, + body: "AI 产品、用户入口和开发者生态相关内容。", + author: "开源中国", + pubDate: "2026-05-20T00:00:00Z", + })), + fetchSuccess: true, + status: status("oschina"), + }, + juejin: { + articles: Array.from({ length: 8 }, (_, index) => ({ + id: `juejin-${index}`, + title: `掘金 AI 编程文章 ${index}`, + url: `https://juejin.cn/post/${index}`, + brief: "AI 编程、前端开发和开发者工具相关社区内容。", + author: "掘金作者", + diggCount: 8_000 - index, + viewCount: 80_000 - index, + tags: ["AI编程", "前端"], + publishTime: "2026-05-20T00:00:00Z", + })), + fetchSuccess: true, + status: status("juejin"), + }, + }; + + const candidates = buildTopicRadar(input).candidates; + const domesticMediaCount = candidates.filter((candidate) => + ["36kr", "InfoQ 中国"].includes(candidate.source), + ).length; + const domesticCommunityCount = candidates.filter((candidate) => + ["开源中国", "掘金"].includes(candidate.source), + ).length; + const juejinCount = candidates.filter((candidate) => candidate.source === "掘金").length; + + expect(domesticMediaCount + domesticCommunityCount).toBeLessThanOrEqual(6); + expect(domesticMediaCount).toBeLessThanOrEqual(4); + expect(domesticCommunityCount).toBeLessThanOrEqual(2); + expect(juejinCount).toBeLessThanOrEqual(2); }); it("keeps source warnings without blocking topic pool generation", () => { diff --git a/src/topic-radar.ts b/src/topic-radar.ts index 9c5ed2b..96acba1 100644 --- a/src/topic-radar.ts +++ b/src/topic-radar.ts @@ -173,13 +173,15 @@ function heatScore(topic: RawTopic): number { case "product": return clamp(strongestSignal / 8, 30); case "community": + if (topic.source === "InfoQ 中国" || topic.source === "36kr") return clamp(strongestSignal / 30, 18); + if (topic.source === "开源中国" || topic.source === "掘金") return clamp(strongestSignal / 80, 12); return clamp(strongestSignal / 12, 30); case "github": - return clamp(Math.log10(strongestSignal + 1) * 5, 24); + return clamp(Math.log10(strongestSignal + 1) * 5, topic.source === "Gitee" ? 20 : 24); case "model": return clamp(strongestSignal / 20, 30); case "official": - return 24; + return 30; case "research": return 16; } @@ -199,7 +201,7 @@ function freshnessScore(topic: RawTopic, now: Date): number { function commercialImpactScore(topic: RawTopic, category: TopicCategory): number { const text = textOf(topic); let score = 8; - if (topic.sourceType === "official") score += 16; + if (topic.sourceType === "official") score += 22; if (topic.sourceType === "product") score += 12; if (topic.sourceType === "community") score += 7; if (category === "标杆企业动向、商业格局与投融资") score += 9; @@ -220,6 +222,11 @@ function commercialImpactScore(topic: RawTopic, category: TopicCategory): number ) score += 7; if (hasAny(text, ["openai", "anthropic", "google", "meta", "microsoft", "nvidia", "apple"])) score += 5; + if (topic.source === "InfoQ 中国") score -= 4; + if (topic.source === "36kr") score -= 6; + if (topic.source === "开源中国") score -= 10; + if (topic.source === "掘金") score -= 12; + if (topic.source === "Gitee") score -= 3; return clamp(score, 40); } @@ -332,9 +339,19 @@ function dedupeCandidates(candidates: TopicCandidate[]): TopicCandidate[] { return [...byUrl.values()]; } -type SourceGroup = "repo" | "domestic" | "research" | "product" | "official" | "community"; +type SourceGroup = + | "official" + | "product" + | "research" + | "repo" + | "internationalCommunity" + | "domesticMedia" + | "domesticCommunity"; function sourceGroup(candidate: TopicCandidate): SourceGroup { + if (["OpenAI", "Anthropic (Claude)", "Google DeepMind"].includes(candidate.source)) return "official"; + if (candidate.source === "Product Hunt") return "product"; + if (candidate.source === "ArXiv" || candidate.source === "Hugging Face") return "research"; if ( candidate.source === "GitHub Trending" || candidate.source.startsWith("GitHub Search") || @@ -342,11 +359,15 @@ function sourceGroup(candidate: TopicCandidate): SourceGroup { ) { return "repo"; } - if (["36kr", "InfoQ 中国", "开源中国", "掘金"].includes(candidate.source)) return "domestic"; - if (candidate.source === "ArXiv" || candidate.source === "Hugging Face") return "research"; - if (candidate.source === "Product Hunt") return "product"; - if (["OpenAI", "Anthropic (Claude)", "Google DeepMind"].includes(candidate.source)) return "official"; - return "community"; + if (["InfoQ 中国", "36kr"].includes(candidate.source)) return "domesticMedia"; + if (["开源中国", "掘金"].includes(candidate.source)) return "domesticCommunity"; + return "internationalCommunity"; +} + +function sourceMax(candidate: TopicCandidate): number | undefined { + if (candidate.source === "Gitee") return 3; + if (candidate.source === "掘金") return 2; + return undefined; } function selectCandidates(scored: TopicCandidate[]): TopicCandidate[] { @@ -356,13 +377,15 @@ function selectCandidates(scored: TopicCandidate[]): TopicCandidate[] { const selected: TopicCandidate[] = []; const used = new Set(); const counts = new Map(); + const sourceCounts = new Map(); const groups: Array<{ id: SourceGroup; base: number; max: number }> = [ - { id: "repo", base: 15, max: 20 }, - { id: "domestic", base: 8, max: 12 }, - { id: "research", base: 8, max: 10 }, - { id: "product", base: 6, max: 10 }, - { id: "official", base: 5, max: 8 }, - { id: "community", base: 5, max: 8 }, + { id: "official", base: 10, max: 15 }, + { id: "product", base: 8, max: 10 }, + { id: "research", base: 8, max: 12 }, + { id: "repo", base: 10, max: 16 }, + { id: "internationalCommunity", base: 5, max: 8 }, + { id: "domesticMedia", base: 2, max: 4 }, + { id: "domesticCommunity", base: 1, max: 2 }, ]; const add = (candidate: TopicCandidate): void => { if (selected.length >= 60) return; @@ -371,8 +394,11 @@ function selectCandidates(scored: TopicCandidate[]): TopicCandidate[] { const group = sourceGroup(candidate); const max = groups.find((item) => item.id === group)?.max ?? 60; if ((counts.get(group) ?? 0) >= max) return; + const maxForSource = sourceMax(candidate); + if (maxForSource !== undefined && (sourceCounts.get(candidate.source) ?? 0) >= maxForSource) return; used.add(key); counts.set(group, (counts.get(group) ?? 0) + 1); + sourceCounts.set(candidate.source, (sourceCounts.get(candidate.source) ?? 0) + 1); selected.push(candidate); }; @@ -384,7 +410,7 @@ function selectCandidates(scored: TopicCandidate[]): TopicCandidate[] { } } for (const candidate of eligible) add(candidate); - return selected; + return selected.sort((a, b) => b.score - a.score); } function collectRawTopics(input: TopicRadarInput): RawTopic[] {