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): 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) { this.url = config?.url ?? ""; this.repoUrlTemplate = config?.repoUrl ?? ""; this.windowDays = config?.windowDays ?? 90; this.readmeLimit = config?.readmeLimit ?? 4000; } async collect(): Promise { 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 { 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(); 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 { 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));