github-daily-pick.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import type { DataSource, CollectResult } from "../types.js";
  2. import { extractItems } from "../types.js";
  3. import { registerCollector } from "../registry.js";
  4. import { coveredOn, isCovered } from "@pipeline/shared/node";
  5. import { isoDateString, formatChineseDate } from "@pipeline/shared";
  6. import { getTimezone } from "@pipeline/shared/node";
  7. import { fetchRepoDetail } from "./repo-detail.js";
  8. /** Candidate from the three boards' union, with per-board gain signals. */
  9. interface Candidate {
  10. owner: string;
  11. name: string;
  12. fullName: string;
  13. /** daily-board gain (undefined when absent from today's board). */
  14. todayStars?: number;
  15. /** weekly-board gain. */
  16. weekStars?: number;
  17. /** monthly-board gain. */
  18. monthStars?: number;
  19. /** Combined recommendation score (see scoreOf). */
  20. score: number;
  21. }
  22. /** Recommendation score — favors sustained momentum over one-day spikes:
  23. * monthly gain anchors "consistently hot", weekly adds mid-term momentum,
  24. * daily only tips the scale among equally-sustained candidates. A repo
  25. * spiking today but absent from weekly/monthly loses to a steady climber. */
  26. function scoreOf(c: Pick<Candidate, "todayStars" | "weekStars" | "monthStars">): number {
  27. return (c.monthStars ?? 0) * 0.5 + (c.weekStars ?? 0) * 0.3 + (c.todayStars ?? 0) * 0.2;
  28. }
  29. /**
  30. * github-daily-pick — the daily open-source project RECOMMENDATION's picker.
  31. *
  32. * This is NOT a trending-board recap (that's github-trending's job). A
  33. * recommendation should surface projects with sustained momentum, so the
  34. * candidate pool is the UNION of the daily / weekly / monthly boards, scored
  35. * by a weighted blend (monthly 0.5 + weekly 0.3 + daily 0.2) and screened
  36. * against the covered store's rolling window. Steady climbers beat one-day
  37. * spikes; a project recapped on the daily board can still be recommended
  38. * later once its momentum persists across boards.
  39. *
  40. * 1. Same-day idempotency: today's successful record is re-picked — a
  41. * re-run (LLM/render failure retry) never changes the day's topic.
  42. * 2. Otherwise union + score + dedup-screen, take the top.
  43. * 3. Empty pool -> throw (job fails, Feishu alerts) — never silently
  44. * re-feature a covered repo.
  45. *
  46. * The covered store lives outside the output dir (30-day TTL would eat it);
  47. * see @pipeline/shared/node's covered-store for the storage contract.
  48. */
  49. class GitHubDailyPickCollector implements DataSource {
  50. readonly name = "github-daily-pick";
  51. private url: string;
  52. private repoUrlTemplate: string;
  53. private windowDays: number;
  54. private readmeLimit: number;
  55. constructor(config?: Record<string, any>) {
  56. this.url = config?.url ?? "";
  57. this.repoUrlTemplate = config?.repoUrl ?? "";
  58. this.windowDays = config?.windowDays ?? 90;
  59. this.readmeLimit = config?.readmeLimit ?? 4000;
  60. }
  61. async collect(): Promise<CollectResult> {
  62. if (!this.url) {
  63. throw new Error("github-daily-pick collector requires 'url' in config");
  64. }
  65. if (!this.repoUrlTemplate) {
  66. throw new Error("github-daily-pick collector requires 'repoUrl' in config");
  67. }
  68. const tz = getTimezone();
  69. const today = isoDateString(new Date(), tz);
  70. let owner: string;
  71. let name: string;
  72. let rankNote: string;
  73. // 1) Same-day idempotency — re-run re-picks today's already-featured repo.
  74. const done = coveredOn(today);
  75. if (done.length > 0) {
  76. const r = done[done.length - 1];
  77. owner = r.repo.owner;
  78. name = r.repo.name;
  79. rankNote = "今日已完成过一版,重新生成同一项目";
  80. } else {
  81. // 2) Union of the three boards, screened against the rolling window,
  82. // ranked by the blended momentum score.
  83. const candidates = await this.freshCandidates();
  84. if (candidates.length === 0) {
  85. throw new Error(
  86. `github-daily-pick: ${this.windowDays} 天窗口内日榜、周榜与月榜候选均已讲完,无未讲项目可选。` +
  87. `可增大 collect.github-daily-pick.windowDays,或等待窗口滚动后重试。`,
  88. );
  89. }
  90. const pick = candidates[0];
  91. owner = pick.owner;
  92. name = pick.name;
  93. const bits = [`日 +${pick.todayStars ?? 0}`];
  94. if (pick.weekStars != null) bits.push(`周 +${pick.weekStars}`);
  95. if (pick.monthStars != null) bits.push(`月 +${pick.monthStars}`);
  96. rankNote = `综合热度第一的未讲项目(${bits.join(",")})`;
  97. }
  98. // Deep detail + a longer README than the roundup collectors use.
  99. const detail = await fetchRepoDetail(this.repoUrlTemplate, owner, name, this.readmeLimit);
  100. if (!detail?.meta) {
  101. throw new Error(`github-daily-pick: 仓库详情获取失败 ${owner}/${name}`);
  102. }
  103. const lines = [
  104. `# 今日推荐选题:${detail.meta.fullName}`,
  105. ``,
  106. `选题说明:${rankNote}。讲述日:${formatChineseDate(new Date(), tz)}。`,
  107. ``,
  108. `---`,
  109. ``,
  110. detail.text,
  111. ];
  112. return { type: "text", content: lines.join("\n"), repos: [detail.meta] };
  113. }
  114. /** Union of the daily / weekly / monthly boards with per-board gains merged
  115. * per repo, covered-windowed, then scored and ranked (desc). Individual
  116. * board fetch failures degrade to the remaining boards. */
  117. private async freshCandidates(): Promise<Candidate[]> {
  118. const [daily, weekly, monthly] = await Promise.all([
  119. this.board("").catch(() => [] as any[]),
  120. this.board("?since=weekly").catch(() => [] as any[]),
  121. this.board("?since=monthly").catch(() => [] as any[]),
  122. ]);
  123. const byKey = new Map<string, Candidate>();
  124. const add = (repo: any, kind: "todayStars" | "weekStars" | "monthStars") => {
  125. const owner = repo.author ?? "";
  126. const name = repo.name ?? "";
  127. const fullName = repo.fullName || `${owner}/${name}`;
  128. if (!owner || !name || isCovered(fullName, this.windowDays)) return;
  129. const key = fullName.toLowerCase();
  130. const c: Candidate = byKey.get(key) ?? { owner, name, fullName, score: 0 };
  131. c[kind] = repo.currentPeriodStars ?? undefined;
  132. byKey.set(key, c);
  133. };
  134. for (const item of daily) add(item, "todayStars");
  135. for (const item of weekly) add(item, "weekStars");
  136. for (const item of monthly) add(item, "monthStars");
  137. return [...byKey.values()]
  138. .map((c) => ({ ...c, score: scoreOf(c) }))
  139. .sort((a, b) => b.score - a.score);
  140. }
  141. /** One board's raw items (unsorted, unscreened). */
  142. private async board(query: string): Promise<any[]> {
  143. const response = await fetch(this.url + query);
  144. if (!response.ok) {
  145. throw new Error(`GitHub trending API error: ${response.status} ${await response.text()}`);
  146. }
  147. return extractItems(await response.json()) as any[];
  148. }
  149. }
  150. registerCollector("github-daily-pick", (config) => new GitHubDailyPickCollector(config));