| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- import type { DataSource, CollectResult } from "../types.js";
- import { extractItems } from "../types.js";
- import { registerCollector } from "../registry.js";
- import { coveredOn, isCovered } from "@pipeline/shared/node";
- import { isoDateString, formatChineseDate } from "@pipeline/shared";
- import { getTimezone } from "@pipeline/shared/node";
- import { fetchRepoDetail } from "./repo-detail.js";
- /** Candidate from the three boards' union, with per-board gain signals. */
- interface Candidate {
- owner: string;
- name: string;
- fullName: string;
- /** daily-board gain (undefined when absent from today's board). */
- todayStars?: number;
- /** weekly-board gain. */
- weekStars?: number;
- /** monthly-board gain. */
- monthStars?: number;
- /** Combined recommendation score (see scoreOf). */
- score: number;
- }
- /** Recommendation score — favors sustained momentum over one-day spikes:
- * monthly gain anchors "consistently hot", weekly adds mid-term momentum,
- * daily only tips the scale among equally-sustained candidates. A repo
- * spiking today but absent from weekly/monthly loses to a steady climber. */
- function scoreOf(c: Pick<Candidate, "todayStars" | "weekStars" | "monthStars">): number {
- return (c.monthStars ?? 0) * 0.5 + (c.weekStars ?? 0) * 0.3 + (c.todayStars ?? 0) * 0.2;
- }
- /**
- * github-daily-pick — the daily open-source project RECOMMENDATION's picker.
- *
- * This is NOT a trending-board recap (that's github-trending's job). A
- * recommendation should surface projects with sustained momentum, so the
- * candidate pool is the UNION of the daily / weekly / monthly boards, scored
- * by a weighted blend (monthly 0.5 + weekly 0.3 + daily 0.2) and screened
- * against the covered store's rolling window. Steady climbers beat one-day
- * spikes; a project recapped on the daily board can still be recommended
- * later once its momentum persists across boards.
- *
- * 1. Same-day idempotency: today's successful record is re-picked — a
- * re-run (LLM/render failure retry) never changes the day's topic.
- * 2. Otherwise union + score + dedup-screen, take the top.
- * 3. Empty pool -> throw (job fails, Feishu alerts) — never silently
- * re-feature a covered repo.
- *
- * The covered store lives outside the output dir (30-day TTL would eat it);
- * see @pipeline/shared/node's covered-store for the storage contract.
- */
- class GitHubDailyPickCollector implements DataSource {
- readonly name = "github-daily-pick";
- private url: string;
- private repoUrlTemplate: string;
- private windowDays: number;
- private readmeLimit: number;
- constructor(config?: Record<string, any>) {
- this.url = config?.url ?? "";
- this.repoUrlTemplate = config?.repoUrl ?? "";
- this.windowDays = config?.windowDays ?? 90;
- this.readmeLimit = config?.readmeLimit ?? 4000;
- }
- async collect(): Promise<CollectResult> {
- if (!this.url) {
- throw new Error("github-daily-pick collector requires 'url' in config");
- }
- if (!this.repoUrlTemplate) {
- throw new Error("github-daily-pick collector requires 'repoUrl' in config");
- }
- const tz = getTimezone();
- const today = isoDateString(new Date(), tz);
- let owner: string;
- let name: string;
- let rankNote: string;
- // 1) Same-day idempotency — re-run re-picks today's already-featured repo.
- const done = coveredOn(today);
- if (done.length > 0) {
- const r = done[done.length - 1];
- owner = r.repo.owner;
- name = r.repo.name;
- rankNote = "今日已完成过一版,重新生成同一项目";
- } else {
- // 2) Union of the three boards, screened against the rolling window,
- // ranked by the blended momentum score.
- const candidates = await this.freshCandidates();
- if (candidates.length === 0) {
- throw new Error(
- `github-daily-pick: ${this.windowDays} 天窗口内日榜、周榜与月榜候选均已讲完,无未讲项目可选。` +
- `可增大 collect.github-daily-pick.windowDays,或等待窗口滚动后重试。`,
- );
- }
- const pick = candidates[0];
- owner = pick.owner;
- name = pick.name;
- const bits = [`日 +${pick.todayStars ?? 0}`];
- if (pick.weekStars != null) bits.push(`周 +${pick.weekStars}`);
- if (pick.monthStars != null) bits.push(`月 +${pick.monthStars}`);
- rankNote = `综合热度第一的未讲项目(${bits.join(",")})`;
- }
- // Deep detail + a longer README than the roundup collectors use.
- const detail = await fetchRepoDetail(this.repoUrlTemplate, owner, name, this.readmeLimit);
- if (!detail?.meta) {
- throw new Error(`github-daily-pick: 仓库详情获取失败 ${owner}/${name}`);
- }
- const lines = [
- `# 今日推荐选题:${detail.meta.fullName}`,
- ``,
- `选题说明:${rankNote}。讲述日:${formatChineseDate(new Date(), tz)}。`,
- ``,
- `---`,
- ``,
- detail.text,
- ];
- return { type: "text", content: lines.join("\n"), repos: [detail.meta] };
- }
- /** Union of the daily / weekly / monthly boards with per-board gains merged
- * per repo, covered-windowed, then scored and ranked (desc). Individual
- * board fetch failures degrade to the remaining boards. */
- private async freshCandidates(): Promise<Candidate[]> {
- const [daily, weekly, monthly] = await Promise.all([
- this.board("").catch(() => [] as any[]),
- this.board("?since=weekly").catch(() => [] as any[]),
- this.board("?since=monthly").catch(() => [] as any[]),
- ]);
- const byKey = new Map<string, Candidate>();
- const add = (repo: any, kind: "todayStars" | "weekStars" | "monthStars") => {
- const owner = repo.author ?? "";
- const name = repo.name ?? "";
- const fullName = repo.fullName || `${owner}/${name}`;
- if (!owner || !name || isCovered(fullName, this.windowDays)) return;
- const key = fullName.toLowerCase();
- const c: Candidate = byKey.get(key) ?? { owner, name, fullName, score: 0 };
- c[kind] = repo.currentPeriodStars ?? undefined;
- byKey.set(key, c);
- };
- for (const item of daily) add(item, "todayStars");
- for (const item of weekly) add(item, "weekStars");
- for (const item of monthly) add(item, "monthStars");
- return [...byKey.values()]
- .map((c) => ({ ...c, score: scoreOf(c) }))
- .sort((a, b) => b.score - a.score);
- }
- /** One board's raw items (unsorted, unscreened). */
- private async board(query: string): Promise<any[]> {
- const response = await fetch(this.url + query);
- if (!response.ok) {
- throw new Error(`GitHub trending API error: ${response.status} ${await response.text()}`);
- }
- return extractItems(await response.json()) as any[];
- }
- }
- registerCollector("github-daily-pick", (config) => new GitHubDailyPickCollector(config));
|