2 Commity a5e17377bd ... 44de9bdc20

Autor SHA1 Wiadomość Data
  lkatzey 44de9bdc20 feat(llm): 模型三级解析 + github 结构缺失重试 1 miesiąc temu
  lkatzey fb95c53251 feat(github-daily-pick): 新增每日推荐+每周回顾模板——covered 去重状态、三榜选题、六幕叙事全链路 1 miesiąc temu
42 zmienionych plików z 2199 dodań i 159 usunięć
  1. 12 2
      .env.example
  2. 18 6
      CLAUDE.md
  3. 2 1
      apps/cli/src/commands/init.ts
  4. 25 3
      apps/cli/src/commands/render.ts
  5. 8 0
      apps/cli/src/commands/templates.ts
  6. 2 0
      apps/web/src/app/create/page.tsx
  7. 13 1
      apps/web/src/app/templates/page.tsx
  8. 6 2
      apps/web/src/lib/run-render.ts
  9. 54 1
      config/default.yaml
  10. 3 0
      deploy/k8s/01-configmap.yaml
  11. 3 2
      deploy/k8s/README.md
  12. 4 0
      docker-compose.yml
  13. 11 4
      docs/DEPLOYMENT.md
  14. 333 0
      docs/GitHub项目推荐_视频文案组织结构.md
  15. 207 0
      docs/文章结构范式示例.md
  16. 165 0
      packages/collect/src/collectors/github-daily-pick.ts
  17. 5 58
      packages/collect/src/collectors/github-repo.ts
  18. 65 0
      packages/collect/src/collectors/github-weekly-recap.ts
  19. 92 0
      packages/collect/src/collectors/repo-detail.ts
  20. 2 0
      packages/collect/src/index.ts
  21. 133 3
      packages/core/src/document.ts
  22. 6 2
      packages/renderer/src/compose.ts
  23. 4 2
      packages/renderer/src/github-images.ts
  24. 11 1
      packages/shared/src/constants.ts
  25. 1 1
      packages/shared/src/index.ts
  26. 97 0
      packages/shared/src/llm/prompts/parse-text.ts
  27. 15 0
      packages/shared/src/node.ts
  28. 186 0
      packages/shared/src/state/covered-store.ts
  29. 22 5
      packages/shared/src/types/document.ts
  30. 8 2
      packages/shared/src/types/render.ts
  31. 9 1
      packages/shared/src/types/scene.ts
  32. 18 0
      packages/shared/src/utils/date.ts
  33. 1 1
      packages/shared/src/utils/index.ts
  34. 45 21
      packages/templates/src/Root.tsx
  35. 20 0
      packages/templates/src/base/theme/colors.ts
  36. 350 0
      packages/templates/src/github-daily-pick/index.tsx
  37. 20 4
      packages/templates/src/github-weekly/index.tsx
  38. 65 21
      packages/text/src/assemble.ts
  39. 32 6
      packages/text/src/generate.ts
  40. 43 9
      packages/text/src/postprocess.ts
  41. 68 0
      packages/text/test/postprocess.test.ts
  42. 15 0
      packages/text/tsconfig.test.json

+ 12 - 2
.env.example

@@ -1,6 +1,7 @@
-# Environment variables — secrets and endpoint URLs only.
+# Environment variables — secrets, endpoint URLs, and deployment overrides.
 # Copy this file to .env and fill in values.
-# Model, voice, and other business settings go in config/default.yaml or CLI flags.
+# Business defaults remain in config/default.yaml; deployment-time overrides can
+# be supplied here without rebuilding or replacing the config file.
 #
 # In Docker, docker-compose.yml reads this file and substitutes the values below
 # into the container's environment — you do NOT need a .env inside the image.
@@ -8,6 +9,9 @@
 # --- LLM ---
 OPENAI_API_KEY=
 OPENAI_BASE_URL=
+# Override config/default.yaml `llm.model` for container/Kubernetes deployment.
+# CLI --llm-model takes precedence over this variable.
+# LLM_MODEL=
 
 # --- TTS: OpenAI-compatible (default provider) ---
 OPENAI_TTS_API_KEY=
@@ -38,6 +42,12 @@ ELEVENLABS_API_KEY=
 # /app/jobs) so job history survives pod restarts; otherwise it lives on
 # ephemeral container storage and is lost on restart.
 # PIPELINE_JOBS_DIR=/app/jobs
+# Where github-daily-pick keeps its dedup history (covered.json — already
+# featured repos + weekly recap delivery records). MUST be a persistent path:
+# losing it means the same repo can be featured again. Defaults to the OS temp
+# dir — docker-compose points it into the jobs bind mount
+# (/tmp/pipeline-jobs/covered); Kubernetes uses the jobs PVC (/app/jobs/covered).
+# PIPELINE_COVERED_DIR=/app/jobs/covered
 # Timezone for the github-trending cover date (首屏 + 开场口播) and the output
 # date subfolder, so the two never disagree and never roll over a day early in
 # a UTC container. Default Asia/Shanghai. Docker also sets TZ to this value.

+ 18 - 6
CLAUDE.md

@@ -31,14 +31,14 @@ pnpm workspaces + Turborepo 的 monorepo。
 ```
 apps/cli/           CLI(薄客户端:进程内调用 core 的 runDocument)
 apps/web/           Next.js 15 = 核心服务宿主(HTTP API + 调度器 + 任务存储 + health),进程内调 core
-packages/shared/    Zod schema、类型、常量、LLM client、VideoDocument 契约、工具
-packages/collect/   数据源(DataSource 注册表 + 3 个 github 采集器),与文字逻辑解耦
+packages/shared/    Zod schema、类型、常量、LLM client、VideoDocument 契约、工具、covered 状态存储(node 出口)
+packages/collect/   数据源(DataSource 注册表 + 6 个 github 采集器),与文字逻辑解耦
 packages/text/      【AI 文字模块】数据源协调 + 多步 LLM → VideoDocument
 packages/audio/     【AI 音频模块】TTS + 字幕分段 → 回填 VideoDocument
 packages/renderer/  【Remotion 调度器】资源下载/帧/渲染/导出/OSS → ExportFile[]
 packages/tts/       TTS provider 注册表 + 4 个 provider(被 audio 调用)
-packages/templates/ 5 个模板的 Remotion 组件(读 RenderProps/RenderScene)
-packages/core/      瘦编排器 runDocument:链 text→audio→renderer + 飞书通知 + 清理
+packages/templates/ 7 个模板的 Remotion 组件(读 RenderProps/RenderScene)
+packages/core/      瘦编排器 runDocument:链 text→audio→renderer + 渲染成功后写 covered 状态 + 飞书通知 + 清理
 ```
 
 ### 三模块数据流(核心)
@@ -138,14 +138,26 @@ Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 注册,
 
 ## 模板系统
 
-5 个模板(news、knowledge、opinion、marketing、github-trending),每个在 `packages/templates/src/<name>/index.tsx`。`Root.tsx` 路由:`scene.kind === "cover"` → 内置 `CoverScene`;`"outro"` → 内置 `OutroScene`;其他 → `SCENE_MAP[template]`。
+7 个模板(news、knowledge、opinion、marketing、github-trending、github-weekly、github-daily-pick、github-weekly-recap),每个在 `packages/templates/src/<name>/index.tsx`。`Root.tsx` 路由:`scene.kind === "cover"` → 内置 `CoverScene`;`"outro"` → 内置 `OutroScene`;其他 → `SCENE_MAP[template]`。
 
-- `github-trending` cover:背景图(default.png 兜底)+深色蒙版,居中深色标题卡,仅展示刊头标题 + 日期 + 主要语言标签(由 content 场景的 repo.language 聚合去重)+ trendSummary 概括,不再展示仓库列表;进度条上方 `ChapterToc`(高亮当前章节)。是唯一带 `isPortrait` 分支的模板。
+- `github-trending` / `github-daily-pick` cover:背景图(default.png 兜底)+深色蒙版,居中深色标题卡,仅展示刊头标题 + 日期 + 主要语言标签(由 content 场景的 repo.language 聚合去重)+ trendSummary 概括,不再展示仓库列表;进度条上方 `ChapterToc`(高亮当前章节)。
+- `github-weekly` / `github-weekly-recap` cover:浅色杂志风刊头;`github-weekly-recap` **复用** `github-weekly` 的场景组件(仅 tag 从"本周 +N"换成 `extension.coveredDate` 的"推荐 · M月D日")。
+- `github-daily-pick` 内容场景:一个项目的多切面章节(`scene.title` = 切面标题,右上角章节进度徽标,卡片为切面内容),与 board 模板的"每仓一场景"不同。
 - `SubtitleBar`(`base/components/subtitle-bar.tsx`)现在**读预计算的 `caption[]`**(按累计时长找当前字幕),不再运行期分词。可选 `fontSize`/`style`(竖屏传 `{bottom:380}`/`fontSize:56`)。
 - 模板字段:读 `scene.title`(原 displayText)、`scene.captionOrigin`、`scene.cardList`(原 keyframes,`card.kind`/`card.desc`)、`scene.caption`、`scene.extension`(模板专属,如 github-trending 的 repo/highlights)、`scene.images[].filename`。
 
 **新增场景字段时**:`VideoSegment`(document.ts)+ `RenderScene`(render.ts)+ 模板组件三处同步,并在 renderer 的 `composeRenderProps` 显式透传(不是 `...seg` 展开)。
 
+## 每日推荐与去重状态(covered store)
+
+`github-daily-pick` 每天推荐一个未讲过的开源项目(六幕叙事:钩子/痛点/亮相/亮点/上手/总结;选题=日/周/月三榜并集按综合热度排序,非只看当日热榜);`github-weekly-recap` 每周日回顾本周推荐。**去重历史是全管线唯一的跨运行状态**:
+
+- **存储**:`packages/shared/src/state/covered-store.ts`(仅从 `@pipeline/shared/node` 出口——根 barrel 是 client-safe,绝不能 re-export)。JSON 文件 `{PIPELINE_COVERED_DIR}/covered.json`,原子写(tmp+rename),同日同仓 upsert,写入时顺带 prune(窗口+7 天)。目录:`PIPELINE_COVERED_DIR` env > 系统 tmp;**部署时必须指向持久卷**(compose 指 jobs bind mount 的 `covered/` 子目录,k8s 指 jobs PVC 子目录)。绝不能放 outputDir(30 天 TTL 会删)。
+- **选题**(`collect/collectors/github-daily-pick.ts`):同日已有成功记录 → 幂等选回同一项目;否则日榜全量筛掉窗口内已讲(`isCovered`,窗口默认 90 天)取热度第一;日榜耗尽回退周榜;双榜全灭 → 显式 throw(飞书告警,不自动放宽)。
+- **记录时机**:core 的 `runDocument` 在**渲染成功后**(`recordPipelineState`)从最终 doc 的 extension 提取 repo + LLM 文案 + outputs 写入——选题/LLM/渲染失败都不浪费选题。best-effort:写失败记 `result.stateError` 不 fail 整个 run。周报只写 `recaps`(防同周重发),**不进 dedup**。
+- **周报素材**:`github-weekly-recap` collector 纯读 `getWeekRecords(weekStart)`(`startOfWeekIso`,TIMEZONE 口径)拼 markdown(含当日亮点/介绍/点评),repo 快照带 `coveredDate` 流经 `RepoMeta` → `extension.coveredDate` → 模板 tag。文案由 LLM 以回顾视角重写,不逐字复述。
+- **配置**:`config/default.yaml` 的 `state.coveredWindowDays`(须与 `collect.github-daily-pick.windowDays` 一致)。
+
 ## 输出目录与发布
 
 ### 统一输出目录

+ 2 - 1
apps/cli/src/commands/init.ts

@@ -5,7 +5,8 @@ import { resolve } from "node:path";
 const DEFAULT_CONFIG = `# Pipeline default configuration
 llm:
   baseURL: "https://api.openai.com/v1"
-  model: "gpt-4o"
+  # Or override per-deployment with the LLM_MODEL environment variable.
+  model: "model-stable"
 
 tts:
   provider: "openai-tts"

+ 25 - 3
apps/cli/src/commands/render.ts

@@ -48,6 +48,7 @@ export const renderCommand = new Command("render")
   .option("--alignment-language <lang>", "Language hint for whisper (e.g. zh, en)")
 
   .action(async (input, opts) => {
+   try {
     const template = opts.template as string;
     if (!TEMPLATE_TYPES.includes(template as any)) {
       console.error(`Error: Invalid template "${template}". Use: ${TEMPLATE_TYPES.join(", ")}`);
@@ -118,7 +119,9 @@ export const renderCommand = new Command("render")
       llm: {
         baseURL: opts.llmBaseUrl || config?.llm?.baseURL,
         apiKey: opts.llmApiKey || config?.llm?.apiKey,
-        model: opts.llmModel || config?.llm?.model || "gpt-4o",
+        // Model resolution (flag > LLM_MODEL env > config) lives in core's
+        // runDocument — clients only forward the pieces.
+        model: config?.llm?.model,
       },
       tts: {
         provider: providerName,
@@ -132,6 +135,7 @@ export const renderCommand = new Command("render")
       publish,
       publishMeta: config?.publishMeta,
       collect: config?.collect,
+      state: { coveredWindowDays: config?.state?.coveredWindowDays },
       assets: { root: assetsRoot, inputDir },
       templates: { entryPoint: templatesEntry },
     };
@@ -139,7 +143,15 @@ export const renderCommand = new Command("render")
     const stages = ["text", "audio", "render"];
 
     console.log(`\nPipeline: generating ${template} video for ${platforms.join(", ")}`);
-    console.log(`Input: ${(text ?? "(source)").length} chars${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
+    // When --source is used, text is undefined here — the collected content
+    // (markdown fed to the LLM) only materializes inside the text module, so
+    // there is no char count to show yet. Log the source name instead of the
+    // misleading length of a "(source)" placeholder.
+    const inputLabel =
+      text !== undefined
+        ? `${text.length} chars`
+        : `source "${opts.source}" (content collected inside text module)`;
+    console.log(`Input: ${inputLabel}${flags.length ? " | Flags: " + flags.join(", ") : ""}\n`);
 
     // Surface publish resolution so a misconfigured OSS/Feishu is obvious.
     if (noPublish) {
@@ -162,6 +174,7 @@ export const renderCommand = new Command("render")
         skipTts: noTts,
         skipLlm: opts.skipLlm,
         skipPublish: noPublish,
+        llmModel: opts.llmModel,
       },
       runConfig,
       {
@@ -183,6 +196,9 @@ export const renderCommand = new Command("render")
         console.log(`  -> ${file.filePath} (${(file.fileSizeBytes / 1024 / 1024).toFixed(1)}MB, ${file.width}x${file.height})`);
         if (file.ossUrl) console.log(`  oss: ${file.ossUrl}`);
       }
+      if (result.stateError) {
+        console.error(`\nState warning (dedup degraded): ${result.stateError}`);
+      }
       if (result.publishError) {
         console.error(`\nPublish warning: ${result.publishError}`);
       }
@@ -190,7 +206,13 @@ export const renderCommand = new Command("render")
       console.error(`\nFailed: ${result.error}`);
       process.exit(1);
     }
-  });
+  } catch (err) {
+    // Expected pipeline errors (source/template mismatch, missing config,
+    // collector failures...) — print the message cleanly, no stack trace.
+    console.error(`\nFailed: ${err instanceof Error ? err.message : String(err)}`);
+    process.exit(1);
+  }
+});
 
 function buildAlignmentConfig(
   opts: any,

+ 8 - 0
apps/cli/src/commands/templates.ts

@@ -11,12 +11,20 @@ export const templatesCommand = new Command("templates")
         knowledge: "Knowledge Explanation (知识讲解)",
         opinion: "Opinion Sharing (观点分享)",
         marketing: "Product Marketing (产品营销)",
+        "github-trending": "GitHub Trending Showcase (GitHub 每日热榜)",
+        "github-weekly": "GitHub Weekly Board (GitHub 周榜)",
+        "github-daily-pick": "GitHub Daily Pick (GitHub 每日推荐)",
+        "github-weekly-recap": "GitHub Weekly Recap (本周推荐回顾)",
       };
       const colors: Record<string, string> = {
         news: "Blue + White + Red",
         knowledge: "Teal + White + Gray",
         opinion: "Orange + Dark Gray + White",
         marketing: "Gradient Purple/Pink + White",
+        "github-trending": "Green + Navy + White",
+        "github-weekly": "Amber + Paper + Slate",
+        "github-daily-pick": "Violet + Cyan + Dark Slate",
+        "github-weekly-recap": "Sky + Bronze + Paper",
       };
       console.log(`  ${t.padEnd(12)} ${names[t]}`);
       console.log(`  ${"".padEnd(12)} Colors: ${colors[t]}`);

+ 2 - 0
apps/web/src/app/create/page.tsx

@@ -10,6 +10,8 @@ const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: strin
   marketing: { label: "产品营销", desc: "Product showcase with CTA overlays", color: "#9333ea" },
   "github-trending": { label: "GitHub 热榜", desc: "Repo cards with social preview + star history", color: "#22c55e" },
   "github-weekly": { label: "GitHub 周榜", desc: "Weekly board in a light magazine style", color: "#d97706" },
+  "github-daily-pick": { label: "GitHub 每日推荐", desc: "每日推荐一个开源项目 — 六幕叙事:钩子/痛点/亮相/亮点/上手/总结", color: "#8b5cf6" },
+  "github-weekly-recap": { label: "GitHub 推荐周报", desc: "回顾本周每日推荐的项目(读取历史,不重新爬榜)", color: "#0284c7" },
 };
 
 const PLATFORMS_INFO: Record<string, string> = {

+ 13 - 1
apps/web/src/app/templates/page.tsx

@@ -37,6 +37,18 @@ const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: strin
     color: "#d97706",
     layouts: "Journal masthead cover, repo header + tags, white section cards",
   },
+  "github-daily-pick": {
+    label: "GitHub 每日推荐",
+    desc: "One repo per day, in depth: aspect chapters (what it is / core highlights / fit / quick start / verdict) with an aspect progress badge and chapter TOC. Reads the dedup history so a repo is never featured twice in the window.",
+    color: "#8b5cf6",
+    layouts: "Aspect-chapter scenes, repo header + tags, section cards + preview image",
+  },
+  "github-weekly-recap": {
+    label: "GitHub 推荐周报",
+    desc: "Sunday look-back at the week's daily picks — one scene per featured repo with its featured date tag, rebuilt from the covered-store history (no re-crawl). The weekly recap of the daily-pick series.",
+    color: "#0284c7",
+    layouts: "Journal masthead cover, repo header + featured-date tags, white section cards",
+  },
 };
 
 export default function TemplatesPage() {
@@ -44,7 +56,7 @@ export default function TemplatesPage() {
     <div>
       <div className="page-header">
         <h1>Templates</h1>
-        <p>Choose from 6 professionally designed video templates, each with 16:9 and 9:16 variants</p>
+        <p>Choose from 8 professionally designed video templates, each with 16:9 and 9:16 variants</p>
       </div>
 
       <div className="card-grid">

+ 6 - 2
apps/web/src/lib/run-render.ts

@@ -66,7 +66,9 @@ export function startRenderJob(params: RenderJobParams): {
 
     const runConfig: DocumentRunConfig = {
       branding: { channelName: config?.branding?.channelName ?? "Pipeline" },
-      llm: { model: config?.llm?.model ?? "glm-5.1" },
+      // Model resolution (env > config) lives in core's runDocument — the
+      // client only forwards the config default.
+      llm: { model: config?.llm?.model },
       tts: {
         provider: ttsProvider,
         voiceId: voiceId || providerConfig?.defaultVoice,
@@ -82,6 +84,7 @@ export function startRenderJob(params: RenderJobParams): {
       publish,
       publishMeta: config?.publishMeta,
       collect: config?.collect,
+      state: { coveredWindowDays: config?.state?.coveredWindowDays },
       assets: { root: assetsRoot, inputDir },
       templates: { entryPoint: templatesEntry },
     };
@@ -117,7 +120,8 @@ export function startRenderJob(params: RenderJobParams): {
           outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
           ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
           publishError: result.publishError,
-          log: `job ${jobIdShort} completed (${result.files.length} file(s))`,
+          log: `job ${jobIdShort} completed (${result.files.length} file(s))` +
+            (result.stateError ? `; state write FAILED: ${result.stateError}` : ""),
         });
       } else {
         updateJob(job.id, {

+ 54 - 1
config/default.yaml

@@ -8,7 +8,7 @@ branding:
 llm:
   # baseURL: set via OPENAI_BASE_URL in .env
   # apiKey: set via OPENAI_API_KEY in .env
-  model: "glm-5.1"
+  model: "model-stable"
 
 tts:
   provider: "openai-tts"
@@ -52,6 +52,13 @@ output:
   # Override with the OUTPUT_RETENTION_DAYS env var.
   retentionDays: 30
 
+# 每日推荐的"已讲历史"去重状态(covered store,@pipeline/shared/node)。
+#   coveredWindowDays — 滚动去重窗口(天):窗口内讲过的项目不再讲,过期释放。
+# 文件位置由 PIPELINE_COVERED_DIR 指定(默认系统临时目录,重启即丢——部署时
+# 必须指向持久卷,推荐 jobs 卷的 covered/ 子目录,见 docs/DEPLOYMENT.md)。
+state:
+  coveredWindowDays: 90
+
 # Alibaba Cloud OSS — rendered videos are uploaded here and a resource URL is
 # returned. Non-sensitive values may live in this file; secrets (access keys)
 # belong in .env. Every field can be overridden by an OSS_* env var.
@@ -82,6 +89,12 @@ publishMeta:
     github-weekly:
       tid: 122
       tags: ["GitHub", "开源", "周榜"]
+    github-daily-pick:
+      tid: 122
+      tags: ["GitHub", "开源", "推荐"]
+    github-weekly-recap:
+      tid: 122
+      tags: ["GitHub", "开源", "周报"]
   douyin-long:
     github-trending:
       category: "科技"
@@ -89,6 +102,12 @@ publishMeta:
     github-weekly:
       category: "科技"
       tags: ["GitHub", "开源", "周榜"]
+    github-daily-pick:
+      category: "科技"
+      tags: ["GitHub", "开源", "推荐"]
+    github-weekly-recap:
+      category: "科技"
+      tags: ["GitHub", "开源", "周报"]
   douyin-short:
     github-trending:
       category: "科技"
@@ -96,6 +115,12 @@ publishMeta:
     github-weekly:
       category: "科技"
       tags: ["GitHub", "开源", "周榜"]
+    github-daily-pick:
+      category: "科技"
+      tags: ["GitHub", "开源", "推荐"]
+    github-weekly-recap:
+      category: "科技"
+      tags: ["GitHub", "开源", "周报"]
 
 templates:
   news:
@@ -116,6 +141,12 @@ templates:
   github-weekly:
     primaryColor: "#d97706"
     accentColor: "#1e3a5f"
+  github-daily-pick:
+    primaryColor: "#8b5cf6"
+    accentColor: "#22d3ee"
+  github-weekly-recap:
+    primaryColor: "#0284c7"
+    accentColor: "#b45309"
 
 collect:
   github-trending:
@@ -133,6 +164,16 @@ collect:
     trendingUrl: "https://github.crawler.corp.shuidi.tech/api/trending"
     repoUrl: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
     maxRepos: 5
+  # 每日推荐选题:日/周/月三榜并集,筛掉窗口内已讲项目后按综合热度取第一
+  # 周榜。windowDays 与 state.coveredWindowDays 保持一致;readmeLimit 是深度
+  # 版的 README 截断(比榜单类的 2000/3000 更长)。
+  github-daily-pick:
+    url: "https://github.crawler.corp.shuidi.tech/api/trending"
+    repoUrl: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
+    windowDays: 90
+    readmeLimit: 4000
+  # 每周推荐回顾:纯读 covered store,无网络依赖(留空对象占位注册)。
+  github-weekly-recap: {}
 
 # 定时生成(容器内进程内调度)。Next.js 服务启动时(instrumentation 钩子)注册
 # 一个进程内调度器,到点 spawn CLI 渲染——复用与 WebUI/HTTP 完全相同的链路,
@@ -159,3 +200,15 @@ schedules:
     source: "github-weekly"      # 采集本周 trending(since=weekly),再解析→渲染→发布
     publish: true
     enabled: true
+  - cron: "20 9 * * *"          # 北京时间每天 09:20(错开 07:50 榜单与周六 08:50 周榜)
+    template: "github-daily-pick"
+    platform: "bilibili"
+    source: "github-daily-pick"  # 三榜并集选题,六幕叙事推荐一个开源项目
+    publish: true
+    enabled: true
+  - cron: "20 18 * * 0"         # 北京时间每周日 18:20(覆盖当天上午的推荐,回顾完整一周)
+    template: "github-weekly-recap"
+    platform: "bilibili"
+    source: "github-weekly-recap"  # 读本周已讲记录生成回顾,不重新爬榜
+    publish: true
+    enabled: true

+ 3 - 0
deploy/k8s/01-configmap.yaml

@@ -9,6 +9,9 @@ data:
   # see README.md. Anything here can also be overridden by that Secret.
   OUTPUT_DIR: /app/output
   PIPELINE_JOBS_DIR: /app/jobs
+  # Daily-pick dedup history (covered.json) — jobs PVC subdirectory, so it
+  # survives restarts and is backed up together with the jobs store.
+  PIPELINE_COVERED_DIR: /app/jobs/covered
   OUTPUT_RETENTION_DAYS: "30"
   TIMEZONE: Asia/Shanghai
   TZ: Asia/Shanghai

+ 3 - 2
deploy/k8s/README.md

@@ -80,12 +80,13 @@ WebUI 把任务元数据存在**本地文件** `jobs.json`,渲染是 Web 进
 | --- | --- | --- |
 | `pipeline-output` | `/app/output` | 渲染产物 + 临时目录(`OUTPUT_DIR`) |
 | `pipeline-jobs` | `/app/jobs` | 任务元数据 `jobs.json`(`PIPELINE_JOBS_DIR`) |
+| 同上(子目录) | `/app/jobs/covered` | 每日推荐去重历史 `covered.json`(`PIPELINE_COVERED_DIR`,ConfigMap 已设) |
 
-两个 PVC 都通过 `fsGroup: 1000` 让非 root 的 `node` 用户可写。删除 PVC 会丢失对应数据。
+两个 PVC 都通过 `fsGroup: 1000` 让非 root 的 `node` 用户可写。删除 PVC 会丢失对应数据——删 `pipeline-jobs` 会**连同推荐去重历史一起丢**(已讲项目可能被重新选题)。去重历史不能放 `/app/output`(会被 retentionDays TTL 清理)。
 
 ### 重启与中断
 
-Pod 重启(滚动更新、OOM、节点驱逐)会打断进行中的渲染。Web 进程下次启动时会扫描 `jobs.json`,把仍处于 `running`/`pending` 的任务标记为 `failed`(`interrupted by pod restart`),避免 UI 卡在幽灵任务上。被中断的渲染需要手动重跑。`terminationGracePeriodSeconds: 60` 给在途请求留出收尾窗口,但长渲染通常仍会超时。
+Pod 重启(滚动更新、OOM、节点驱逐)会打断进行中的渲染。Web 进程下次启动时会扫描 `jobs.json`,把仍处于 `running`/`pending` 的任务标记为 `failed`(`interrupted by pod restart`),避免 UI 卡在幽灵任务上。被中断的渲染需要手动重跑;每日推荐重跑会因去重状态未记录而选回同一项目(同日幂等)。`terminationGracePeriodSeconds: 60` 给在途请求留出收尾窗口,但长渲染通常仍会超时。
 
 ### 时区
 

+ 4 - 0
docker-compose.yml

@@ -32,6 +32,10 @@ services:
       - OUTPUT_RETENTION_DAYS=${OUTPUT_RETENTION_DAYS:-30}
       - TZ=${TZ:-Asia/Shanghai}
       - TIMEZONE=${TIMEZONE:-Asia/Shanghai}
+      # Daily-pick dedup history (covered.json) — same bind mount as the jobs
+      # store (subdirectory), so no extra volume is needed and both survive
+      # restarts together.
+      - PIPELINE_COVERED_DIR=/tmp/pipeline-jobs/covered
     volumes:
       - ./output:/app/output
       # Bind mount (pre-created as uid 1000 on the host) instead of a named

+ 11 - 4
docs/DEPLOYMENT.md

@@ -73,19 +73,21 @@ docker compose up --build -d        # 改完代码后重新构建
 容器内:
 - Web 服务运行于 `/app/apps/web`,监听 `3000`。
 - 输出目录 `/app/output` 挂载到宿主机 `./output`(视频落盘可见、可清理)。
-- 任务元数据(`jobs.json`)保存在 docker volume `pipeline-jobs`(`/tmp/pipeline-jobs`)。
+- 任务元数据(`jobs.json`)保存在 bind mount `./.data/pipeline-jobs`(`/tmp/pipeline-jobs`)。
+- 每日推荐去重历史(`covered.json`)保存在同一挂载的 `covered/` 子目录(`PIPELINE_COVERED_DIR`)。
 
 ---
 
 ## 4. 配置说明
 
-配置分两层:**`.env`(密钥 / 端点)** + **`config/default.yaml`(业务参数)**。CLI 读取顺序:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`;密钥统一走环境变量。
+配置分两层:**`.env`(密钥 / 端点 / 部署期覆盖)** + **`config/default.yaml`(业务默认参数)**。CLI 读取顺序:`--config` 参数 → `pipeline.config.yaml` → `config/default.yaml`;密钥统一走环境变量。LLM 模型优先级为:CLI `--llm-model` → `LLM_MODEL` 环境变量 → 配置文件 `llm.model` → 运行时兜底值。这样容器部署可仅注入 `LLM_MODEL` 切换模型,无需修改配置文件或重建镜像。
 
 ### 4.1 环境变量(`.env`)
 
 | 变量 | 必填 | 说明 |
 | --- | --- | --- |
 | `OPENAI_API_KEY` / `OPENAI_BASE_URL` | 是* | LLM(OpenAI 兼容端点:DeepSeek / Qwen / GLM 等)。`*`用结构化 JSON + `--skip-llm` 时可省 |
+| `LLM_MODEL` | 否 | 部署期覆盖 `config/default.yaml` 的 LLM 模型;CLI `--llm-model` 优先级更高 |
 | `OPENAI_TTS_API_KEY` / `OPENAI_TTS_BASE_URL` | 是* | 默认 TTS provider。`*`用 `--no-tts` 时可省 |
 | `OPENAI_TTS_MODEL` | 否 | TTS 模型,默认 `seed-tts-1.1` |
 | `TTS_MAX_RETRIES` | 否 | TTS 请求失败重试次数(网络错误 / 429 / 5xx,指数退避),默认 3;`0` 关闭 |
@@ -263,9 +265,12 @@ curl http://localhost:13000/api/jobs/<jobId>
 | 挂载 | 容器路径 | 用途 |
 | --- | --- | --- |
 | `./output` | `/app/output` | 视频产物 + 渲染临时目录(按模板/日期归档,自动清理) |
-| docker volume `pipeline-jobs` | `/tmp/pipeline-jobs` | 任务元数据 `jobs.json`(重启保留) |
+| bind mount `./.data/pipeline-jobs` | `/tmp/pipeline-jobs` | 任务元数据 `jobs.json`(重启保留) |
+| 同上(子目录) | `/tmp/pipeline-jobs/covered` | **每日精去重历史** `covered.json`(`PIPELINE_COVERED_DIR` 指向) |
 
-> 注意:任务元数据存在内存卷中,**删除 volume 会丢失任务历史**(但不影响已生成的视频文件)。
+> 注意:**删除 `.data/pipeline-jobs` 会同时丢失任务历史和推荐去重历史**(但不影响已生成的视频文件)。去重历史丢失后,已讲过的项目可能被重新选题;窗口天数见 `config/default.yaml` 的 `state.coveredWindowDays`(默认 90 天)。
+>
+> 每日推荐的选题状态**绝不能**放进 `/app/output`——输出目录会被 `retentionDays`(默认 30 天)TTL 清理,`covered.json` 会被一并删掉。默认路径已按上表落在 jobs 卷里。
 
 ---
 
@@ -278,6 +283,8 @@ curl http://localhost:13000/api/jobs/<jobId>
 | TTS 报 502 / 503 / 网络错误 | TTS 网关上游瞬时故障(如 DNS、滚动重启);已内置重试(`TTS_MAX_RETRIES` 默认 3)。若持续失败,检查 `OPENAI_TTS_BASE_URL` 网关健康度或临时换 provider |
 | 日志里出现 `Note: … @remotion/media-parser … license` | 这是 Remotion 的**许可证提醒**(`parseMedia()` 探测音频时长时打印),不是错误、不影响渲染。若要消除,需在 `packages/tts/src/duration.ts` 的 `parseMedia()` 传入 `acknowledgeRemotionLicense: true`——注意这是对许可证的**法律确认**,公司用途请先确认是否需要 [Remotion 许可](https://remotion.dev/license) |
 | OSS 上传失败 | 检查 `OSS_REGION/BUCKET/ACCESS_KEY_*`;任务详情会展示 `Publish warning`,本地视频仍生成 |
+| 每日推荐报「日榜、周榜与月榜候选均已讲完」 | 90 天滚动窗口内三榜并集的项目全部讲过(候选池枯竭)。增大 `config/default.yaml` 的 `collect.github-daily-pick.windowDays` 与 `state.coveredWindowDays`(两处保持一致),或等待窗口滚动释放旧项目 |
+| 任务成功但日志出现 `covered-store write failed (dedup degraded)` | 去重状态写入失败(多为卷权限/磁盘问题)。视频已正常产出,但该次推荐未计入历史,重跑会重新选题。检查 `PIPELINE_COVERED_DIR` 指向的路径可写 |
 | 飞书收不到消息 | 确认 `FEISHU_WEBHOOK_URL`;若启用了签名校验需同时填 `FEISHU_WEBHOOK_SECRET` |
 | 容器内找不到输出 | 确认挂载到 `/app/output`(`OUTPUT_DIR`),`docker compose` 已默认如此 |
 | 改了 `.env` 不生效 | 重启服务:`docker compose up -d`(环境变量在容器启动时注入) |

+ 333 - 0
docs/GitHub项目推荐_视频文案组织结构.md

@@ -0,0 +1,333 @@
+# 【知势·科技洞察】视频自媒体文案组织结构
+
+---
+
+## 一、账号定位
+
+| 维度 | 说明 |
+|------|------|
+| 名称 | 知势·科技洞察 |
+| 内容方向 | 开源项目推荐、技术工具评测、开发者效率神器 |
+| 内容形态 | GitHub仓库信息整理 + 配音解说 + 字幕,无人出镜、无操作录屏 |
+| 素材来源 | GitHub仓库提供的信息(README、star数、功能特性、代码片段、项目截图等) |
+| 单期时长 | 2-6 分钟(视项目复杂度而定) |
+| 目标观众 | 开发者、技术爱好者、对工具有需求的效率党 |
+| 配音风格 | 口语化、接地气,避免学术腔,语速紧凑 |
+
+---
+
+## 二、标题/封面范式
+
+### 标题公式:`[钩子] + [核心卖点] + [情绪悬念]`
+
+四种子模式:
+
+| 模式 | 占比 | 公式 | 示例 |
+|------|------|------|------|
+| 数据驱动 | ~40% | `数字+star/量级` + `功能描述` | "GitHub 2万星!这个工具让网页秒变 AI 助手" |
+| 情绪+功能 | ~40% | `情绪词` + `一句话功能描述` | "安全圈又炸了!这个开源项目1.6万星凭什么" |
+| 故事驱动 | ~10% | `痛点/动机` + `解决方案` | "被坑9个月,他怒写了一个开源工具,8.3k星" |
+| 大厂背书 | ~10% | `公司名` + `开源了` + `功能` | "微软出手了!开源神器让 AI 学会你的一切操作" |
+
+**标题要点:**
+- 必含数字锚定(star数、下载量、MB数等量化数据)
+- 感叹号改为悬念句或反问句,更适合视频平台(B站/抖音/视频号)
+- 封面图:项目 logo 或 README 首页截图 + 大号数字(star数)+ 短标语
+
+---
+
+## 三、视频文案结构范式(核心)
+
+每期视频遵循 **六幕叙事结构**,画面素材全部来自 GitHub 仓库信息,无真人、无录屏:
+
+```
+┌─────────────────────────────────────────────────────┐
+│ 第一幕 · 黄金3秒钩子(0-15秒)                       │
+│ 画面:项目演示图/效果图高光展示(来自仓库截图)         │
+│       或 star 增长趋势、热榜排名的醒目数字动画         │
+│ 配音:一句话说清"为什么值得看"                        │
+│ 目的:3秒内抓住注意力                                  │
+├─────────────────────────────────────────────────────┤
+│ 第二幕 · 痛点共鸣(15-45秒)                          │
+│ 画面:痛点场景用文字卡片/示意图呈现                    │
+│       传统方案的缺点用对比列表排版展示                  │
+│ 配音:描述当前做这件事有多麻烦,引发共鸣                │
+│ 目的:让观众觉得"对,我也遇到过这个问题"                │
+├─────────────────────────────────────────────────────┤
+│ 第三幕 · 项目亮相(45秒-2分钟)                       │
+│ 画面:GitHub仓库页面滚动展示                          │
+│       - 项目logo + 项目名 + 一句话简介(README头部)   │
+│       - star/forks/contributors 等数据特写            │
+│       - 作者/组织信息、开源时间、协议(License)       │
+│ 配音:                                                │
+│   - 项目名 + 一句话定位                               │
+│   - 核心理念(用大白话解释技术概念)                   │
+│   - 背景信息:谁做的、什么级别的认可                    │
+│ 目的:让观众理解"这是什么"和"为什么厉害"                │
+├─────────────────────────────────────────────────────┤
+│ 第四幕 · 功能亮点解读(2-5分钟)← 全片重心             │
+│ 画面:基于仓库信息逐条呈现                             │
+│   - 功能特性列表(README Feature 段落截图/排版)       │
+│   - 每个功能的展示图/示意图(仓库内图片)               │
+│   - 关键代码片段(README 中的 Usage 示例)             │
+│   - 支持平台/环境要求表格                              │
+│ 配音:按功能点逐一展开                                │
+│   - 功能1名称 → 解释作用 → 指出现有展示图              │
+│   - 功能2名称 → 解释作用 → 指出现有展示图              │
+│   - 功能3名称 → 解释作用 → 指出现有展示图              │
+│   (与竞品对比可穿插在此处,用对比表格排版)             │
+│ 目的:让观众完整理解项目能力,用仓库提供的一手信息       │
+│        证明项目价值                                    │
+├─────────────────────────────────────────────────────┤
+│ 第五幕 · 快速上手(5-7分钟,可选)                     │
+│ 画面:README 的 Installation / Quick Start 段落       │
+│   - 安装命令代码块排版展示(从README摘取)              │
+│   - 使用步骤截图/示例输出                              │
+│   - 前置条件与依赖要求(README Prerequisites)         │
+│ 配音:                                                │
+│   - 安装命令(画面展示,配音简要说明)                   │
+│   - 3步以内核心使用流程                                │
+│   - 注意事项(权限、依赖、坑点)                        │
+│ 目的:降低试用门槛,给动手能力强的观众即时价值           │
+├─────────────────────────────────────────────────────┤
+│ 第六幕 · 总结与行动号召(最后30秒)                    │
+│ 画面:项目完整信息定格页(logo+数据+链接)              │
+│ 配音:                                                │
+│   - 一句话总结价值                                    │
+│   - 适合谁用                                          │
+│   - "项目链接我放在简介/评论区了"                       │
+│   - 引导互动:关注/点赞/评论区讨论                      │
+│   - 下期预告(可选)                                   │
+├─────────────────────────────────────────────────────┤
+│ 片尾 · 导流(5-10秒)                                 │
+│ 画面:往期推荐视频缩略图横向滚动                       │
+│ 配音/字幕:"这几个项目也值得一试"                       │
+│ 用平台自带的"关联视频"功能实现站内导流                  │
+└─────────────────────────────────────────────────────┘
+```
+
+---
+
+## 四、素材组织规范(GitHub信息 → 画面)
+
+### 4.1 素材采集清单
+
+每期制作前,从 GitHub 仓库收集以下信息:
+
+| 素材 | 来源 | 用途 |
+|------|------|------|
+| 项目 logo | 仓库首页/头像 | 亮相幕、封面 |
+| 项目名 + 一句话简介 | README 首段 | 第三幕 |
+| star / fork / 贡献者数 | 仓库头部数据 | 钩子幕、亮相幕、封面 |
+| 开源时间 / 作者组织 / License | 仓库侧栏 | 第三幕 |
+| 功能特性列表 | README Features 段落 | 第四幕主体 |
+| 功能展示图 / 演示图 | README 内嵌图片 | 第四幕逐条配图 |
+| 使用示例代码 | README Usage / Example | 第四幕、第五幕 |
+| 安装命令 | README Installation | 第五幕 |
+| 系统要求 / 前置依赖 | README Prerequisites | 第五幕 |
+| 徽章(Build/Version/License) | README 顶部 | 第三幕、画面点缀 |
+
+### 4.2 画面制作方式
+
+- 无录屏:不从本机操作系统操作画面
+- 主画面:GitHub 仓库页面的截图/元素摘取 + 信息排版卡片
+- 仓库图片直接引用:README 中的演示图、效果图、架构图
+- 文字信息转排版卡片:功能列表、对比表、数据指标用统一模板排成卡片
+- 代码块转静态排版:安装命令、使用示例转成深色底代码卡片
+- 数据可视化:star 增长曲线、趋势数据可用简单图表呈现(数据从仓库/GitHub API 获取)
+
+### 4.3 画面风格
+
+- 深色科技风(深灰/暗蓝底),信息卡片统一圆角样式
+- 关键词高亮,数字用大号字体强调
+- 转场用数字标号(①②③),避免花哨特效
+
+---
+
+## 五、各幕详细文案模板
+
+### 第一幕 · 黄金3秒钩子
+
+```
+[画面:项目演示图/效果图全屏展示,star数大字动画]
+
+【模板A - 数据型】
+"GitHub 2万星![X万star],这个[领域]项目直接封神了。"
+
+【模板B - 痛点型】
+"每次做[某事]都要[痛苦步骤]?这个开源项目让你[一句话效果]。"
+
+【模板C - 大厂型】
+"微软/谷歌/阿里又开源好东西了,这次是[功能描述]。"
+
+【模板D - 悬念型】
+"一个[数据]star的项目,凭什么让整个[领域]圈都在聊?"
+```
+
+### 第二幕 · 痛点共鸣
+
+```
+[画面:痛点场景文字卡片 / 传统方案缺点对比列表排版]
+
+"平时我们做[某事],要么得[方案A的缺点],
+ 要么依赖[方案B的缺点],整个过程特别麻烦,
+ 尤其是[某个具体痛点场景]。"
+```
+
+### 第三幕 · 项目亮相
+
+```
+[画面:GitHub仓库页面滚动展示,logo、star数、简介特写]
+
+"最近一个叫[项目名]的项目登上了GitHub热榜,
+ 已经拿到了[X万] star。
+它是一个[用大白话解释定位],
+ 核心思路就是——[一句话核心理念]。"
+```
+
+### 第四幕 · 功能亮点解读
+
+```
+[画面:功能列表排版卡片 + 仓库内对应演示图逐条呈现]
+
+"我一条一条来看它厉害在哪。"
+
+[功能1]
+"第一个,[功能名称]。它的作用是[大白话解释]。"
+(展示仓库内的演示图/示意图)
+"你可以看到,[指图说明效果]。"
+
+[功能2]
+"第二个,[功能名称]。"
+(展示对应演示图/代码示例)
+
+[对比环节 - 可选]
+"你可能会问,这和[传统方案]有什么区别?
+ 关键在于——[核心差异点]。"
+(对比表格排版展示)
+```
+
+### 第五幕 · 快速上手
+
+```
+[画面:README 安装段落的代码卡片排版]
+
+"想自己试试的话,非常简单。"
+
+"第一步,[安装命令](画面展示代码卡片)。"
+"第二步,[核心使用动作]。"
+"第三步,[看到效果]。"
+
+"注意[一个坑/前置条件]。"
+```
+
+### 第六幕 · 总结
+
+```
+[画面:项目信息定格页(logo + 数据 + 链接)]
+
+"总结一下,[项目名]的核心价值就是[一句话]。
+如果你平时有[场景]的需求,非常推荐试一试。
+
+项目链接我放在简介/评论区了。
+觉得有用的话点个赞,关注知势,持续为你发现好用工具。"
+```
+
+### 片尾 · 导流
+
+```
+[画面:往期视频缩略图横向排列]
+
+(字幕或简短配音)"这几个开源项目也值得一试:"
+[视频卡片1] [视频卡片2] [视频卡片3] [视频卡片4] [视频卡片5]
+```
+
+---
+
+## 六、视频制作规范
+
+| 维度 | 规范 |
+|------|------|
+| 画幅 | 16:9(B站/YouTube)或 9:16(抖音/视频号/小红书),建议优先16:9 |
+| 分辨率 | 1080p 起步,4K 更佳 |
+| 形态 | GitHub仓库信息排版 + 配音 + 字幕,**无人出镜、无录屏** |
+| 配音语速 | 180-220字/分钟(略快于日常对话,保持紧凑感) |
+| 背景音乐 | 科技感轻电子乐,音量控制在配音的 15-20% |
+| 字幕 | 全程字幕,关键命令/术语用高亮框标注,技术术语可附英文原文 |
+| 画面主体 | 仓库截图摘取 + 信息卡片排版 + 仓库内演示图,禁止盗用其他来源素材 |
+| 转场 | 功能点之间用简洁的数字标号转场(①②③),避免花哨特效 |
+| 配色 | 深色科技风为主(深灰/暗蓝底色),关键词用亮色标注 |
+| 钩子画面 | 选取仓库中最有冲击力的演示图/数据,首帧必须吸睛 |
+
+---
+
+## 七、单期文案配比参考
+
+以 5 分钟视频为例:
+
+| 幕 | 时长占比 | 配音字数 | 画面内容 |
+|----|---------|---------|---------|
+| 钩子 | 5%(15秒) | ~50字 | 演示图/数据动画 |
+| 痛点 | 10%(30秒) | ~100字 | 痛点文字卡片 |
+| 亮相 | 15%(45秒) | ~150字 | 仓库页面/logo/数据 |
+| 功能解读 | 45%(135秒) | ~450字 | 功能列表+演示图(核心) |
+| 上手 | 15%(45秒) | ~150字 | 安装/使用代码卡片 |
+| 总结 | 10%(30秒) | ~100字 | 项目信息定格 |
+| **合计** | **100%** | **~1000字** | — |
+
+---
+
+## 八、系列化运营建议
+
+1. **片尾关联视频**:每期放 3-5 个往期视频卡片,主题与当期相关,实现站内导流
+2. **系列合集**:按主题建播放列表(如"AI工具合集""安全工具合集""效率神器")
+3. **固定更新频率**:建议每周 2-3 期
+4. **评论区置顶**:GitHub 链接 + 安装命令文字版,方便观众复制
+5. **互动钩子**:结尾抛问题引导评论("你最想用AI自动化什么操作?评论区告诉我")
+
+---
+
+## 九、完整一期文案示例
+
+```
+标题:GitHub 2万星!这个工具让网页秒变 AI 助手
+
+【第一幕 · 钩子 - 15秒】
+(画面:仓库演示图全屏展示,"2万 star"大字动画)
+"GitHub 2万星!一个让网页直接变成AI助手的开源项目。"
+
+【第二幕 · 痛点 - 30秒】
+(画面:文字卡片排版:传统Web自动化的三大痛点
+      ①要搭Python环境 ②依赖无头浏览器 ③截图OCR识别率低)
+"平时做Web自动化,要么搭Python环境搞无头浏览器,
+要么靠截图OCR加多模态模型,特别折腾。"
+
+【第三幕 · 亮相 - 45秒】
+(画面:GitHub仓库页面滚动,logo、star数、简介特写)
+"阿里开源的PageAgent,纯前端框架,
+一句话概括:住在你网页里的AI助手。
+不用截图、不用OCR,直接操作DOM。"
+
+【第四幕 · 功能解读 - 135秒】
+(画面:README功能列表排版 + 仓库演示图逐条展示)
+"它有两个核心亮点。"
+"第一个,自然语言操控网页。
+(展示演示图)你只要说'填邮箱、填密码、点登录',它直接完成。"
+"第二个,智能数据提取。
+(展示演示图)你说'找出最便宜的商品',它自动扫描对比并高亮结果。"
+
+【第五幕 · 上手 - 45秒】
+(画面:README安装段落的代码卡片排版)
+"接入非常简单,一行CDN脚本就行。命令和链接都放评论区了。"
+
+【第六幕 · 总结 - 30秒】
+(画面:项目信息定格页:logo + 2万star + GitHub链接)
+"PageAgent的价值在于让Web自动化回归浏览器本身。
+如果你在做前端增强或者AI产品,值得关注。
+关注知势,发现更多好工具。"
+```
+
+---
+
+*文档版本:v4.0*
+*生成时间:2026年8月17日*

+ 207 - 0
docs/文章结构范式示例.md

@@ -0,0 +1,207 @@
+
+# 【开源先锋】微信公众号 — 文章结构范式分析报告
+
+> 基于对 3 篇完整文章 + 14 条推荐阅读链接的深度分析
+> 样本文章:
+> - 《2w star,网页秒变 AI 助手,厉害了!》(2026-06-26)
+> - 《又一安全圈爆火的开源项目,已经1.66万Star!》(2026-08-05)
+> - 《微软开源了一款 skill 神器,这下爽了!》(2026-08-11)
+
+---
+
+## 一、整体定位
+
+**"开源先锋"** 是一个专注于**开源项目推荐与评测**的技术类公众号,人设为"开源君"。
+每篇文章聚焦**一个开源项目**(偶尔2个),用通俗语言介绍其背景、功能、使用方法。
+
+---
+
+## 二、标题范式
+
+### 标题公式:`[钩子] + [核心卖点] + [情绪词/感叹号]`
+
+**四种子模式:**
+
+| 模式 | 占比 | 公式 | 示例 |
+|------|------|------|------|
+| 数据驱动型 | ~37% | `数字+star/量级` + `功能描述` + `!` | "2w star,网页秒变 AI 助手,厉害了!" |
+| 情绪+功能型 | ~44% | `情绪词` + `一句话功能描述` + `!` | "又一安全圈爆火的开源项目,已经1.66万Star!" |
+| 故事驱动型 | ~13% | `痛点/动机` + `解决方案` + `!` | "被坑9个月后,这位老哥愤而重写了一个" |
+| 大厂背书型 | ~6% | `公司名` + `开源了` + `功能` + `!` | "微软开源了一款 skill 神器,这下爽了!" |
+
+**标题特征统计:**
+- 含数字:12/16(75%)
+- 以感叹号结尾:15/16(94%)
+- 含 Star/星数:7/16(44%)
+- 含情绪词(厉害/爽/离谱/牛皮/害怕/真绝/神器):9/16(56%)
+
+---
+
+## 三、文章正文结构范式(核心!)
+
+每篇文章遵循**固定的7段式结构**:
+
+```
+┌─────────────────────────────────────────┐
+│ ① 开头问候                                │
+│    "各位好啊,我是开源君!"                │
+│    + 痛点/背景引入                         │
+├─────────────────────────────────────────┤
+│ ② 项目引入                                │
+│    "最近在Github上频繁刷到..."             │
+│    + 项目名 + 基本数据(star数等)         │
+├─────────────────────────────────────────┤
+│ ③ 项目简介(二级标题:项目简介)           │
+│    用加粗/列表形式介绍核心概念和定位        │
+├─────────────────────────────────────────┤
+│ ④ 功能特性/工作流                          │
+│    加粗小标题 + 列表形式展开                │
+│    (文章2:4步工作流;文章3:5大特性)      │
+├─────────────────────────────────────────┤
+│ ⑤ 对比分析/差异化(可选)                   │
+│    与竞品/传统方案对比,突出优势            │
+│    (文章2:"与RPA的区别")                │
+├─────────────────────────────────────────┤
+│ ⑥ 快速安装、使用(二级标题)               │
+│    <pre>代码块:安装命令                    │
+│    + 使用步骤(有序/无序列表)              │
+├─────────────────────────────────────────┤
+│ ⑦ 总结 + GitHub链接                       │
+│    "开源君想说":个人评价                   │
+│    "更多细节...到项目地址查看:"            │
+│    GitHub URL(<pre>代码块)               │
+├─────────────────────────────────────────┤
+│ ⑧ 推荐阅读                                │
+│    5 篇历史文章标题链接                     │
+└─────────────────────────────────────────┘
+```
+
+---
+
+## 四、排版格式规范
+
+### 4.1 编辑器
+- **统一使用 mdnice编辑器**(https://www.mdnice.com)
+- `data-tool="mdnice编辑器"` 标记
+
+### 4.2 正文排版参数
+- 字号:16px
+- 行高:1.6(line-height)
+- 字间距:0.544px(letter-spacing)
+- 对齐:左对齐(text-align: left)
+- 正文字色:#353535(color)
+- 段落间距:margin-top: 5px; margin-bottom: 5px
+
+### 4.3 标题层级
+不使用 h1-h6 标签,而是通过 `<strong>` + 行内样式实现:
+- **二级标题**(如"项目简介""快速安装、使用"):
+  `<p style="font-weight:500; color:#353535;">` 加粗段落
+- **三级标题/小标题**(如功能列表各项):
+  `<strong>` 加粗文字,通常以 `。` 结尾
+
+### 4.4 列表
+- 使用 `<ul><li>` 无序列表
+- 列表项内用 `<strong>` 标记关键词
+
+### 4.5 代码块
+- 使用 `<pre><code>` 标签
+- 用于:安装命令、GitHub URL
+- 通常 2-4 个代码块/篇
+
+### 4.6 图片
+- 每篇 6-8 张配图
+- 包括:项目截图、演示效果图、功能界面截图
+
+### 4.7 视频(可选)
+- 部分文章嵌入微信公众号视频
+- 用于项目演示(文章1有3个演示视频)
+
+---
+
+## 五、底部结构
+
+### 5.1 推荐阅读
+- 每篇文章末尾固定有 **"推荐阅读:"** 模块
+- 包含 **5 篇**历史文章链接
+- 链接格式:`<a href="...">文章标题</a>`
+- 标题可点击跳转到对应文章
+
+### 5.2 推荐阅读的选取逻辑
+从数据观察,推荐阅读的文章:
+- 主题相近(开源项目推荐)
+- 时间跨度覆盖近1-2个月
+- 标题风格一致(数据驱动+情绪词)
+
+### 5.3 导航
+- **无"目录"、"上一篇"、"下一篇"** 结构化导航
+- 仅通过 "推荐阅读" 实现**站内导流**
+
+---
+
+## 六、内容风格特征
+
+### 6.1 人设语气
+- 第一人称自称:"开源君"
+- 问候语固定格式:"各位好啊,我是开源君!"
+- 评论区落:"开源君想说" / "开源君通过...的方式"
+- 口语化、接地气,避免学术腔
+
+### 6.2 写作手法
+1. **痛点引入**:先描述现状问题,再引出项目作为解决方案
+2. **数字锚定**:频繁使用 star 数、下载量、MB 数等量化数据
+3. **对比论证**:与传统方案/竞品对比突出差异化
+4. **场景化描述**:用具体使用场景展示功能("场景1:自动填写表单")
+5. **行动号召**:每篇末尾给出 GitHub 链接,鼓励读者试用
+
+### 6.3 字数与配比
+- 每篇约 **1500-2500字**(纯正文)
+- 配图 **6-8张**
+- 代码块 **2-4个**
+- 列表 **1-5个**
+
+---
+
+## 七、可复用的写作模板
+
+如果要模仿"开源先锋"的风格写文章,遵循以下模板:
+
+```
+标题:[数字/情绪词] + [项目一句话描述] + [感叹号]
+
+正文:
+1. 各位好啊,我是开源君!
+   [描述当前领域痛点/现状]
+   最近[来源]有一个很火的项目叫[项目名]
+
+2. 项目简介
+   [项目名]是一个[定位描述]。
+   [核心特色用加粗强调]
+
+3. 功能特性 / 工作流
+   - **特性1名称。** 详细描述...
+   - **特性2名称。** 详细描述...
+   - **特性3名称。** 详细描述...
+
+4. (可选)与XX的区别
+   [对比分析,突出优势]
+
+5. 快速安装、使用
+   [安装命令代码块]
+   使用步骤:
+   - 步骤1
+   - 步骤2
+   - 步骤3
+
+6. 开源君想说
+   [个人评价/价值总结]
+   更多细节功能,感兴趣的可以到项目地址查看:
+   [GitHub URL代码块]
+
+7. 推荐阅读:
+   [5篇历史文章标题链接]
+```
+
+---
+
+*报告生成时间:2026年8月17日*
+*分析工具:CDP (Chrome DevTools Protocol) + Python*

+ 165 - 0
packages/collect/src/collectors/github-daily-pick.ts

@@ -0,0 +1,165 @@
+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));

+ 5 - 58
packages/collect/src/collectors/github-repo.ts

@@ -1,6 +1,6 @@
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import { registerCollector } from "../registry.js";
-import { formatCount, type RepoMeta } from "@pipeline/shared";
+import { fetchRepoDetail } from "./repo-detail.js";
 
 class GitHubRepoCollector implements DataSource {
   readonly name = "github-repo";
@@ -21,65 +21,12 @@ class GitHubRepoCollector implements DataSource {
       throw new Error("github-repo collector requires 'url' in config");
     }
 
-    const url = this.urlTemplate
-      .replace(":owner", encodeURIComponent(owner))
-      .replace(":repo", encodeURIComponent(repo));
-
-    const response = await fetch(url);
-    if (!response.ok) {
-      throw new Error(`GitHub repo API error: ${response.status} ${await response.text()}`);
+    const detail = await fetchRepoDetail(this.urlTemplate, owner, repo);
+    if (!detail) {
+      throw new Error(`GitHub repo API request failed for ${owner}/${repo}`);
     }
-
-    const raw = (await response.json()) as Record<string, any>;
-    const data = (raw.data ?? raw) as Record<string, any>;
-
-    const { text, meta } = formatRepoDetail(owner, repo, data);
-    return { type: "text", content: text, repos: meta ? [meta] : [] };
+    return { type: "text", content: detail.text, repos: detail.meta ? [detail.meta] : [] };
   }
 }
 
-function formatRepoDetail(queryOwner: string, queryRepo: string, data: any): { text: string; meta: RepoMeta | null } {
-  // The API returns the REAL owner (transferred repos report their current
-  // owner). Prefer data.fullName; fall back to the query params.
-  const fullName = data.fullName || `${queryOwner}/${queryRepo}`;
-  const [detailOwner, detailName] = fullName.split("/");
-
-  const meta: RepoMeta = {
-    owner: detailOwner || queryOwner,
-    name: detailName || queryRepo,
-    fullName,
-    language: data.language ?? "",
-    languageColor: data.languageColor ?? "",
-    stars: data.stars,
-    forks: data.forks,
-    license: data.license ?? "",
-  };
-
-  const lines: string[] = [`# ${fullName}\n`];
-  if (data.description) lines.push(`${data.description}\n`);
-
-  // Structured metadata block — LLM copies verbatim into scene.github.repo.
-  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
-
-  const meta2: string[] = [];
-  if (data.language) meta2.push(`Language: ${data.language}`);
-  if (data.stars != null) meta2.push(`Stars: ${formatCount(data.stars)}`);
-  if (data.forks != null) meta2.push(`Forks: ${formatCount(data.forks)}`);
-  if (data.openIssues != null) meta2.push(`Open Issues: ${formatCount(data.openIssues)}`);
-  if (data.topics?.length) meta2.push(`Topics: ${data.topics.join(", ")}`);
-  if (meta2.length) {
-    lines.push("");
-    lines.push(meta2.map((m) => `- ${m}`).join("\n"));
-  }
-
-  if (data.readme) {
-    const readme = data.readme.length > 3000
-      ? data.readme.slice(0, 3000) + "\n..."
-      : data.readme;
-    lines.push("\n## README\n", readme);
-  }
-
-  return { text: lines.join("\n"), meta };
-}
-
 registerCollector("github-repo", (config) => new GitHubRepoCollector(config));

+ 65 - 0
packages/collect/src/collectors/github-weekly-recap.ts

@@ -0,0 +1,65 @@
+import type { DataSource, CollectResult } from "../types.js";
+import { registerCollector } from "../registry.js";
+import { getWeekRecords } from "@pipeline/shared/node";
+import { startOfWeekIso, formatWeekRange, formatChineseDate, formatCount } from "@pipeline/shared";
+import { getTimezone } from "@pipeline/shared/node";
+import type { RepoMeta } from "@pipeline/shared";
+
+/**
+ * github-weekly-recap — builds the weekly recap's source material from the
+ * covered store, NOT from a fresh trending crawl. For each day the week's
+ * daily-pick record (repo snapshot + the copy generated that day + that day's
+ * data) becomes one markdown section; the LLM turns it into one look-back
+ * scene per day. Empty week -> throw (nothing to recap).
+ */
+class GitHubWeeklyRecapCollector implements DataSource {
+  readonly name = "github-weekly-recap";
+
+  async collect(): Promise<CollectResult> {
+    const tz = getTimezone();
+    const weekStart = startOfWeekIso(new Date(), tz);
+    const records = getWeekRecords(weekStart);
+    if (records.length === 0) {
+      throw new Error(`github-weekly-recap: 本周(${weekStart} 起)尚无已完成的推荐记录,无法生成周报。`);
+    }
+
+    const lines: string[] = [
+      `# 本周推荐回顾(${formatWeekRange(new Date(), tz)})`,
+      ``,
+      `本周共完成 ${records.length} 期每日推荐。以下按日期列出每期记录。`,
+      ``,
+    ];
+    const repos: RepoMeta[] = [];
+
+    records.forEach((r, i) => {
+      // The featured-date tag flows to the template via repo.coveredDate ->
+      // extension.coveredDate (assemble lifts it).
+      repos.push({ ...r.repo, coveredDate: r.date });
+      lines.push(
+        `## 第 ${i + 1} 天 · ${formatChineseDate(new Date(`${r.date}T12:00:00+08:00`), tz)} · ${r.fullName}`,
+        ``,
+        `<!-- repo-meta: ${JSON.stringify({ ...r.repo, coveredDate: r.date })} -->`,
+        `- 亮点:${r.highlights}`,
+        `- 介绍:${r.intro}`,
+        `- 点评:${r.review}`,
+      );
+      if (r.summary) lines.push(`- 当日视频主题:${r.summary}`);
+      const dataBits: string[] = [];
+      if (r.repo.stars != null) dataBits.push(`${formatCount(r.repo.stars)} stars`);
+      if (r.repo.todayStars != null) dataBits.push(`推荐当日 +${formatCount(r.repo.todayStars)}`);
+      if (dataBits.length) lines.push(`- 当日数据:${dataBits.join(",")}`);
+      lines.push(``);
+    });
+
+    if (records.length < 7) {
+      lines.push(
+        `> 注:本周共完成 ${records.length}/7 期推荐(缺失当天未调度或生成失败)。生成内容时如实带过,不要虚构缺失日。`,
+        ``,
+      );
+    }
+
+    return { type: "text", content: lines.join("\n"), repos };
+  }
+}
+
+registerCollector("github-weekly-recap", () => new GitHubWeeklyRecapCollector());

+ 92 - 0
packages/collect/src/collectors/repo-detail.ts

@@ -0,0 +1,92 @@
+import { formatCount, type RepoMeta } from "@pipeline/shared";
+
+/**
+ * Shared single-repo detail helpers — the fetch + format logic behind the
+ * github-repo collector, parameterized by README truncation length so
+ * github-daily-pick can ask for a deeper excerpt without duplicating code.
+ */
+
+/** Format a repo-detail API payload as markdown (the LLM's fact source),
+ *  plus the typed RepoMeta the text module treats as authoritative. */
+export function formatRepoDetail(
+  queryOwner: string,
+  queryRepo: string,
+  data: any,
+  readmeLimit = 3000,
+): { text: string; meta: RepoMeta | null } {
+  // The API returns the REAL owner (transferred repos report their current
+  // owner). Prefer data.fullName; fall back to the query params.
+  const fullName = data.fullName || `${queryOwner}/${queryRepo}`;
+  const [detailOwner, detailName] = fullName.split("/");
+
+  const meta: RepoMeta = {
+    owner: detailOwner || queryOwner,
+    name: detailName || queryRepo,
+    fullName,
+    language: data.language ?? "",
+    languageColor: data.languageColor ?? "",
+    stars: data.stars,
+    forks: data.forks,
+    license: data.license ?? "",
+  };
+
+  const lines: string[] = [`# ${fullName}\n`];
+  if (data.description) lines.push(`${data.description}\n`);
+
+  // Structured metadata block — LLM copies verbatim into scene.github.repo.
+  lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
+
+  const meta2: string[] = [];
+  if (data.language) meta2.push(`Language: ${data.language}`);
+  if (data.stars != null) meta2.push(`Stars: ${formatCount(data.stars)}`);
+  if (data.forks != null) meta2.push(`Forks: ${formatCount(data.forks)}`);
+  if (data.openIssues != null) meta2.push(`Open Issues: ${formatCount(data.openIssues)}`);
+  if (data.topics?.length) meta2.push(`Topics: ${data.topics.join(", ")}`);
+  if (meta2.length) {
+    lines.push("");
+    lines.push(meta2.map((m) => `- ${m}`).join("\n"));
+  }
+
+  if (data.readme) {
+    const readme = data.readme.length > readmeLimit
+      ? data.readme.slice(0, readmeLimit) + "\n..."
+      : data.readme;
+    lines.push("\n## README\n", readme);
+  }
+
+  return { text: lines.join("\n"), meta };
+}
+
+/** Fetch one repo's detail payload and format it. Returns null on any failure
+ *  so callers can decide how to degrade. Retries transient upstream errors
+ *  (5xx / network) up to 2 times with a short backoff — a blip shouldn't burn
+ *  the daily pick's topic. */
+export async function fetchRepoDetail(
+  repoUrlTemplate: string,
+  owner: string,
+  repo: string,
+  readmeLimit = 3000,
+): Promise<{ text: string; meta: RepoMeta | null } | null> {
+  if (!repoUrlTemplate) return null;
+  const url = repoUrlTemplate
+    .replace(":owner", encodeURIComponent(owner))
+    .replace(":repo", encodeURIComponent(repo));
+
+  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+  for (let attempt = 0; attempt < 3; attempt++) {
+    try {
+      const response = await fetch(url);
+      if (response.ok) {
+        const raw = (await response.json()) as Record<string, any>;
+        const data = (raw.data ?? raw) as Record<string, any>;
+        return formatRepoDetail(owner, repo, data, readmeLimit);
+      }
+      // 4xx is a real answer (repo gone etc.) — do not retry.
+      if (response.status < 500) return null;
+    } catch {
+      // network error — retry
+    }
+    if (attempt < 2) await sleep(1000 * (attempt + 1));
+  }
+  return null;
+}

+ 2 - 0
packages/collect/src/index.ts

@@ -6,3 +6,5 @@ import "./collectors/github-trending.js";
 import "./collectors/github-weekly.js";
 import "./collectors/github-repo.js";
 import "./collectors/github-daily.js";
+import "./collectors/github-daily-pick.js";
+import "./collectors/github-weekly-recap.js";

+ 133 - 3
packages/core/src/document.ts

@@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto";
 import { mkdir } from "node:fs/promises";
 import { join } from "node:path";
 import type { VideoDocument, ExportFile, PublishMeta, TemplateType, PlatformPreset } from "@pipeline/shared";
-import { createLogger } from "@pipeline/shared/node";
+import { startOfWeekIso } from "@pipeline/shared";
+import { createLogger, getTimezone, isoDateString, markCovered, markRecap } from "@pipeline/shared/node";
 import { generateDocument } from "@pipeline/text";
 import { generateAudio } from "@pipeline/audio";
 import { renderDocument } from "@pipeline/renderer";
@@ -29,10 +30,15 @@ export interface DocumentRunInput {
   skipLlm?: boolean;
   /** Skip OSS upload + Feishu notification. */
   skipPublish?: boolean;
+  /** Explicit LLM model override (CLI --llm-model). Highest priority, above
+   *  the LLM_MODEL env and config.llm.model. */
+  llmModel?: string;
 }
 
 export interface DocumentRunConfig {
-  llm: { baseURL?: string; apiKey?: string; model: string };
+  /** Resolved inside runDocument: input.llmModel > LLM_MODEL env > llm.model.
+   *  Throws when none is set — no silent hardcoded fallback. */
+  llm: { baseURL?: string; apiKey?: string; model?: string };
   tts: {
     provider: string;
     voiceId?: string;
@@ -51,6 +57,8 @@ export interface DocumentRunConfig {
   publish?: PublishConfig;
   /** publishMeta[platform][template] — platform-specific sidecar keys. */
   publishMeta?: Record<string, Record<string, PublishTargetConfig>>;
+  /** Covered-store (daily-pick dedup) settings. */
+  state?: { coveredWindowDays?: number };
 }
 
 export interface DocumentRunCallbacks {
@@ -64,6 +72,10 @@ export interface DocumentRunResult {
   files: ExportFile[];
   error?: string;
   publishError?: string;
+  /** Covered-store write failure on an otherwise-successful run (dedup
+   *  degraded — surfaced separately so the alert is not conflated with the
+   *  render result). */
+  stateError?: string;
 }
 
 /** Feishu title suggestion: video title joined with the trend one-liner. */
@@ -74,6 +86,93 @@ function buildTitleSuggestion(doc?: VideoDocument): string | undefined {
   return trend ? `${t}|${trend}` : t;
 }
 
+/**
+ * Record cross-run state AFTER a successful render:
+ * - github-daily-pick → mark the featured repo as covered (dedup + the recap's
+ *   source material: repo snapshot, generated copy, output refs).
+ * - github-weekly-recap → mark the week's recap as delivered (guards same-week
+ *   re-runs; does NOT enter dedup).
+ * Non-github templates record nothing. Best-effort by contract: the video has
+ * already rendered (and uploaded), so a store failure must not fail the run —
+ * but it IS reported via stateError because silent dedup loss is worse.
+ */
+function recordPipelineState(
+  template: TemplateType,
+  jobId: string,
+  doc: VideoDocument,
+  files: ExportFile[],
+  windowDays: number,
+): void {
+  if (template !== "github-daily-pick" && template !== "github-weekly-recap") return;
+  const outputs = files.map((f) => ({
+    filePath: f.filePath,
+    ossUrl: f.ossUrl,
+    durationSeconds: f.durationSeconds,
+  }));
+
+  if (template === "github-daily-pick") {
+    const seg = doc.data.find((s) => s.extension?.type === "github-daily-pick");
+    const ext = seg?.extension;
+    if (!ext) throw new Error("no github-daily-pick extension found in document");
+    markCovered(
+      {
+        fullName: ext.repo.fullName,
+        date: isoDateString(new Date(), getTimezone()),
+        jobId,
+        repo: ext.repo,
+        highlights: ext.highlights,
+        intro: ext.intro,
+        review: ext.review,
+        summary: doc.meta?.summary,
+        outputs,
+        createdAt: Date.now(),
+      },
+      windowDays,
+    );
+    log.info(`job ${jobId} covered-store: marked ${ext.repo.fullName}`);
+  } else {
+    const fullNames = doc.data
+      .filter((s) => s.extension?.type === "github-weekly-recap")
+      .map((s) => s.extension!.repo.fullName);
+    if (fullNames.length === 0) throw new Error("no github-weekly-recap extensions in document");
+    markRecap({
+      weekStart: startOfWeekIso(new Date(), getTimezone()),
+      date: isoDateString(new Date(), getTimezone()),
+      jobId,
+      repos: fullNames,
+      outputs,
+      createdAt: Date.now(),
+    });
+    log.info(`job ${jobId} covered-store: recap recorded for ${fullNames.length} repos`);
+  }
+}
+
+/**
+ * template ↔ source compatibility. The github pair templates rely on sources
+ * with matching semantics — e.g. github-daily-pick needs a single-repo source
+ * (its six-act structure is per-project); feeding it a multi-repo board source
+ * makes the LLM produce scenes whose github fields don't conform, and the
+ * failure only surfaces AFTER the LLM call as an opaque zod error. Reject the
+ * mismatch up front with an actionable message.
+ */
+function assertSourceCompatible(template: TemplateType, source?: string): void {
+  if (!source) return;
+  const allowed: Partial<Record<TemplateType, string[]>> = {
+    // 三榜并集自动选题,或 github-repo 指定 owner/repo(同为单仓深度源)
+    "github-daily-pick": ["github-daily-pick", "github-repo"],
+    // 周报素材只来自 covered store
+    "github-weekly-recap": ["github-weekly-recap"],
+  };
+  const list = allowed[template];
+  if (list && !list.includes(source)) {
+    throw new Error(
+      `模板 ${template} 与数据源 ${source} 不兼容(该模板只支持:${list.join(" / ")})。` +
+        `每日推荐请用 --source github-daily-pick(三榜并集自动选题)` +
+        `或 --source github-repo --source-owner X --source-repo Y(手动指定仓库)。`,
+    );
+  }
+}
+
 /**
  * Core orchestrator — the "service" logic. Chains the three decoupled modules
  * (text → audio → renderer) in-process, then notifies Feishu (OSS upload happens
@@ -90,6 +189,10 @@ export async function runDocument(
 ): Promise<DocumentRunResult> {
   const jobId = randomUUID();
   const t0 = Date.now();
+
+  // Fail fast on template↔source mismatches (before any LLM/tokens are spent).
+  assertSourceCompatible(input.template, input.source);
+
   const workDir = join(config.output.dir, "tmp", jobId);
   await mkdir(workDir, { recursive: true });
 
@@ -120,6 +223,17 @@ export async function runDocument(
 
   try {
     callbacks?.onStage?.("text");
+    // Resolve the LLM model in ONE place: explicit input override (CLI flag) >
+    // deployment env > config default. Throws when none is set so a misconfigured
+    // deployment fails fast instead of silently using a stale hardcoded model.
+    const llmModel = input.llmModel || process.env.LLM_MODEL || config.llm.model;
+    if (!llmModel) {
+      throw new Error(
+        "LLM model is not configured: set llm.model in the config file, " +
+          "the LLM_MODEL environment variable, or pass --llm-model."
+      );
+    }
+    log.debug(`job ${jobId} llm model=${llmModel}`);
     // generateDocument returns the pure VideoDocument + separate posting metadata.
     const textOut = await generateDocument({
       template: input.template,
@@ -127,7 +241,7 @@ export async function runDocument(
       source: input.source,
       sourceArgs: input.sourceArgs,
       collectorConfig: input.source ? config.collect?.[input.source] : undefined,
-      llm: config.llm,
+      llm: { ...config.llm, model: llmModel },
       skipLlm: input.skipLlm,
     });
     doc = textOut.doc;
@@ -162,6 +276,22 @@ export async function runDocument(
     result.files = files;
     result.doc = doc;
 
+    // Cross-run state (daily-pick dedup / recap delivery) — only after the
+    // render succeeded, so a failed run never burns the day's pick.
+    callbacks?.onStage?.("record");
+    try {
+      recordPipelineState(
+        input.template,
+        jobId,
+        doc,
+        files,
+        config.state?.coveredWindowDays ?? 90,
+      );
+    } catch (err) {
+      result.stateError = err instanceof Error ? err.message : String(err);
+      log.error(`job ${jobId} covered-store write failed (dedup degraded): ${result.stateError}`);
+    }
+
     if (publishConfig?.feishu) {
       try {
         await notifyFeishuSuccess(publishConfig, publishCtx(), files);

+ 6 - 2
packages/renderer/src/compose.ts

@@ -45,14 +45,18 @@ export function composeRenderProps(
       .filter((x): x is NonNullable<typeof x> => !!x);
 
     // Resolve the per-template extension (github board images → filenames;
-    // github-trending and github-weekly share the shape).
-    const extension = seg.extension?.type === "github-trending" || seg.extension?.type === "github-weekly"
+    // all four github templates share the shape).
+    const extension = seg.extension?.type === "github-trending" ||
+      seg.extension?.type === "github-weekly" ||
+      seg.extension?.type === "github-daily-pick" ||
+      seg.extension?.type === "github-weekly-recap"
       ? {
           type: seg.extension.type,
           repo: seg.extension.repo,
           highlights: seg.extension.highlights,
           intro: seg.extension.intro,
           review: seg.extension.review,
+          coveredDate: seg.extension.coveredDate,
           images: resolveGithubImages(seg.extension, assets),
         }
       : undefined;

+ 4 - 2
packages/renderer/src/github-images.ts

@@ -25,8 +25,10 @@ export interface ResolvableImage {
  */
 export function segmentImageRefs(seg: VideoDocument["data"][number]): ResolvableImage[] {
   const refs: ResolvableImage[] = (seg.images ?? []).map((i) => ({ ...i }));
-  if (seg.extension?.type === "github-trending" || seg.extension?.type === "github-weekly") {
-    const imgs = seg.extension.images;
+  const ext = seg.extension;
+  if (ext?.type === "github-trending" || ext?.type === "github-weekly" ||
+      ext?.type === "github-daily-pick" || ext?.type === "github-weekly-recap") {
+    const imgs = ext.images;
     if (imgs?.socialPreview) refs.push({ repoSocialPreview: imgs.socialPreview });
     if (imgs?.starHistory) refs.push({ url: imgs.starHistory });
   }

+ 11 - 1
packages/shared/src/constants.ts

@@ -1,4 +1,12 @@
-export type TemplateType = "news" | "knowledge" | "opinion" | "marketing" | "github-trending" | "github-weekly";
+export type TemplateType =
+  | "news"
+  | "knowledge"
+  | "opinion"
+  | "marketing"
+  | "github-trending"
+  | "github-weekly"
+  | "github-daily-pick"
+  | "github-weekly-recap";
 
 export type PlatformPreset =
   | "bilibili"
@@ -31,6 +39,8 @@ export const TEMPLATE_TYPES: TemplateType[] = [
   "marketing",
   "github-trending",
   "github-weekly",
+  "github-daily-pick",
+  "github-weekly-recap",
 ];
 
 export const PLATFORM_PRESET_KEYS: PlatformPreset[] = [

+ 1 - 1
packages/shared/src/index.ts

@@ -14,4 +14,4 @@ export type {
 } from "./constants.js";
 export { LLMClient, getParsePrompt, detectInputFormat } from "./llm/index.js";
 export type { LLMClientConfig, LLMResult, InputFormat, DetectResult } from "./llm/index.js";
-export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, formatWeekRange, normalizeCountsForTTS, audioFilenameFor, DEFAULT_TIMEZONE } from "./utils/index.js";
+export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, formatWeekRange, startOfWeekIso, normalizeCountsForTTS, audioFilenameFor, DEFAULT_TIMEZONE } from "./utils/index.js";

+ 97 - 0
packages/shared/src/llm/prompts/parse-text.ts

@@ -170,6 +170,90 @@ CHARACTER LIMITS ARE STRICT — exceeding them breaks the layout. If content doe
 
 Number formatting: stars/forks/etc. always render with lowercase k suffix when ≥ 1000 (e.g. 1234 → "1.2k", 12345 → "12.3k", 123456 → "123k"). Values under 1000 stay as plain integers. The collector already formats them in markdown text, but when you write counts into github.highlights / intro / review / narration yourself, apply the same rule. Weekly gain values come from the "Week: +N" lines — when you cite them in narration, say the number naturally (e.g. "本周涨了 1.2 万星").
 
+FORBIDDEN: emoji, arrows, dingbats, decorative unicode. Plain CJK + ASCII only.`,
+
+    "github-daily-pick": `
+Template: GitHub Daily Pick (GitHub 每日推荐 — 开源项目推荐)
+- 定位:自媒体风格的“开源项目推荐”。口语化、接地气,像同事给你安利一个好工具——不是新闻播报,不是学术讲解。语速紧凑,信息密度高。
+- The input is ONE repository (metadata + a long README excerpt): today's recommended project.
+- The README is your primary source of facts. Prefer saying less over inventing: installation commands, feature names, numbers and comparisons must all actually exist in the README — never fabricate.
+
+SIX-ACT NARRATIVE STRUCTURE (六幕叙事 — mandatory act order; you may merge Act 5 into Act 4 when the README has no install section, otherwise never reorder/merge; total 6-9 scenes):
+1. 【黄金3秒钩子】(~50 字): a cold-open hook stating why this is worth watching RIGHT NOW. Adapt ONE of the four title formulas to spoken form: 数据型 ("GitHub 2万星!...") / 痛点型 ("每次做X都要Y?...") / 大厂型 ("微软又开源了...") / 悬念型 ("一个X万星的项目凭什么..."). MUST contain a number anchor (stars / gain / size). NO greeting, NO "大家好" — the hook IS the opening.
+2. 【痛点共鸣】(~100 字): the pain BEFORE the tool — how this thing is done the traditional way and why it's annoying (环境/依赖/繁琐/效果差). Resonate: "对,我也遇到过". Pull pain points from the README's motivation section when present; otherwise infer from the project's category. End hinting a better way exists.
+3. 【项目亮相】(~150 字): the project steps in. 项目短名 + 一句话定位(大白话,不端术语腔) + 核心理念一句话 ("核心思路就是——...") + 背景认可 (谁做的/多少星/什么级别的关注). The "hero arrives" beat.
+4. 【功能亮点解读】(全片重心, ~450 字, 2-4 SCENES): the core value proof. Pick the 2-3 MOST distinctive capabilities from the README — ONE SCENE PER capability ("第一个,X。它的作用是..." / "第二个,Y..."). Each scene: 功能名称 → 大白话解释作用 → 一个具体效果或数字 → (可选) 与传统方案的关键差异. A comparison beat ("你可能会问,这和X有什么区别?关键在于——...") may be woven into the last highlight scene.
+5. 【快速上手】(~150 字): lower the trial barrier. 安装命令 (画面展示代码卡片,配音只说"一行命令搞定"级别的简述——绝不全念命令), 3 步以内核心使用流程, 一个注意事项/坑点. Only commands that REALLY exist in the README. If the README has no install section, merge this act into Act 4's last scene.
+6. 【总结】(~100 字): one-sentence core value + who should try it + honest boundary (不适合谁/现状边界). Do NOT do "点赞关注" CTA — the outro scene handles that. End with a forward-looking signal or verdict.
+
+narration (口播): per-scene budgets above (~1000 字 total for a 5-minute video). Spoken style: short colloquial sentences ("说白了""特别折腾""一行命令搞定"), numbers said naturally ("2万星" not "两万颗星"). The hook act MUST be ≤ 60 字 and land the number anchor in the first sentence.
+
+displayText: the act/chapter title, ≤ 12 Chinese characters — e.g. "老方法有多难"/"项目亮相"/"亮点一:极速内核"/"亮点二:零配置"/"快速上手"/"总结". This becomes the on-screen chapter heading and the chapter-TOC entry.
+
+github.repo: COPY the contents of the "<!-- repo-meta: ... -->" block from the input VERBATIM into EVERY scene's github.repo. Do not drop fields, do not rename keys, do not reformat numbers.
+
+github.highlights: this act's key takeaway in one phrase, ≤ 20 Chinese characters.
+github.intro: a 2-3 sentence summary OF THIS ACT, ≤ 120 Chinese characters (feeds the weekly recap's source material).
+github.review: one short audience-fit verdict for this act, ≤ 30 Chinese characters; may be brief.
+
+KEYFRAME CARDS: 1-3 cards per scene, matched to the act —
+- Act 1 (钩子): the number anchor as a big stat card (e.g. "46.2k stars" / "本月 +1.6k").
+- Act 2 (痛点): the traditional approach's pain list (①②③ numbered items, 2-4 entries).
+- Act 3 (亮相): 项目定位卡 + GitHub 地址卡 + 数据卡 (stars/forks/license in one) — this scene carries the mandatory 3-card set.
+- Act 4 (亮点×N): per-capability cards (功能名称 + 一句话作用); comparison beat uses a 对比 card.
+- Act 5 (上手): the EXACT install command as a code-style card; prerequisites card if present.
+- Act 6 (总结): 适合谁 card + 不适合/边界 card.
+FORBIDDEN anywhere: language cards, license-only cards (license belongs inside Act 3's data card), tech-stack-as-label cards.
+
+GREETING RULE: this template has NO cover greeting — Act 1 IS the cold open. EVERY scene's narration MUST NOT begin with a greeting ("大家好", "哈喽") or a show-opener ("今天为大家推荐...", "本期节目...", "欢迎收看..."). The hook starts directly with its formula. Mentioning the project name ONCE in Act 1 or 3 is fine ("最近一个叫 Bun 的项目...").
+
+PROJECT NAME RULE: in narration, refer to the project by its SHORT repo name only (e.g. "Bun", "next.js") — never read the owner prefix aloud.
+
+trendSummary: one sentence (≤ 30 chars) capturing the recommendation angle — the project's single best hook with its number. Example: "2万星 Web 自动化,让网页秒变 AI 助手". Plain CJK/ASCII only.
+
+publish.title: use the title formulas — [钩子] + [核心卖点] + [情绪悬念], ≤ 40 字. MUST contain a number anchor (star 数/量级/倍数). Four patterns (vary across days, 数据型与情绪型为主):
+- 数据驱动 (~40%): "GitHub 2万星!这个工具让网页秒变 AI 助手"
+- 情绪+功能 (~40%): "安全圈又炸了!这个开源项目1.6万星凭什么"
+- 故事驱动 (~10%): "被坑9个月,他怒写了一个开源工具,8.3k星"
+- 大厂背书 (~10%): "微软出手了!开源神器让 AI 学会你的一切操作"
+Prefer 悬念句/反问句 over stacked exclamation marks; the title MUST be distinct from the masthead "GitHub 每日推荐".
+publish.description: 1-3 sentences covering the pain it solves, the highlights, and who should watch. ≤ 200 字. End with "项目链接在简介里".
+publish.tags: 3-8 tags, each ≤ 12 chars (e.e. ["GitHub","开源","AI工具","效率","程序员"]).
+
+CHARACTER LIMITS ARE STRICT — exceeding them breaks the layout. If content does not fit, drop the least important detail.
+
+Number formatting: counts ≥ 1000 render with lowercase k suffix (1234 → "1.2k", 20000 → "20k") or natural Chinese ("2万星") in narration; cards use the k form. Apply the rule to any count you write yourself.
+
+FORBIDDEN: emoji, arrows, dingbats, decorative unicode. Plain CJK + ASCII only.`,
+
+    "github-weekly-recap": `
+Template: GitHub Weekly Recap (本周推荐回顾)
+- Tone: editorial look-back at the week's daily picks — a reviewer connecting the dots, not a re-run of the daily intros.
+- The input is the week's daily-pick records: for each day, the repo metadata + the copy generated back then (highlights / intro / review) + that day's data. Each "## " section in the input is ONE day.
+- EXACTLY ONE scene per day, in the SAME ORDER as the input. Do NOT add, drop, or reorder projects. When the week has fewer than 7 days, weave the gap into the narrative naturally (e.g. "本周完成了五期推荐") — do NOT invent missing days.
+
+displayText: "M月D日 · {repo short name}" (≤ 14 chars), e.g. "8月12日 · Bun". The date is the day the project was FEATURED (from the input's day header).
+
+github.repo: COPY the corresponding "<!-- repo-meta: ... -->" block VERBATIM into scene.github.repo — INCLUDING the coveredDate field when present.
+
+github.highlights / intro / review: carry the day's recorded values BACK UNCHANGED from the input (they are the archival record; the recap state-write relies on them).
+
+narration (口播): ≤ 200 characters per scene. Do NOT re-read the day's intro verbatim — speak as a look-back: why this project was worth the week's slot, what judgment still holds a few days later, what the data signal said. Connecting across days is welcome ("和周一的 Bun 一样,本周性能工具明显扎堆"), but each scene stays about its own project.
+
+GREETING RULE: the cover already greets. NO scene narration may open with a greeting or a show-opener ("本周推荐回顾", "今天为大家..."). Start directly with content; mentioning the week mid-narration is fine.
+
+PROJECT NAME RULE: in narration use the SHORT repo name only; never read the owner prefix.
+
+trendSummary: ONE sentence (≤ 30 chars) synthesizing the week's cross-project theme — an editorial takeaway, not a list. Example: "本周 AI 工具链项目集中爆发". Plain CJK/ASCII only.
+
+publish.title: distinct from the masthead "本周推荐回顾" — e.g. "GitHub 推荐周报:五个项目与一条主线". ≤ 40 chars.
+publish.description: 1-3 sentences on which projects the week covered and the thread connecting them. ≤ 200 chars.
+publish.tags: 3-8 tags, each ≤ 12 chars (e.g. ["GitHub","开源","周报","推荐"]).
+
+KEYFRAME CARDS: 1-2 cards per scene, e.g. 推荐看点 (the day's recorded highlights) and 当日数据 (stars at feature time). No mandatory card set — the scene layout already shows repo metadata. FORBIDDEN: language cards, license cards.
+
+CHARACTER LIMITS ARE STRICT — exceeding them breaks the layout.
+
 FORBIDDEN: emoji, arrows, dingbats, decorative unicode. Plain CJK + ASCII only.`,
   };
 
@@ -247,6 +331,19 @@ Data source: GitHub Daily (trending + 多仓库详情组合)
 - Suggested structure: 1-2 opening scenes surveying today's overall trend theme (what topics dominate, what's heating up) — these overview scenes do NOT need the per-project structure. Then 1 scene per notable repo, and EVERY repo scene MUST follow the per-project scene structure below (headline + mandatory 功能简介 / GitHub 地址 / 使用场景 cards + optional value-add card + detailed narration).
 - Total 4-7 scenes is the sweet spot. If trending has many repos, group similar ones or pick only the top 3-4 to dive into.
 ${githubProjectIntroRule}`,
+
+    "github-daily-pick": `
+Data source: GitHub Daily Pick (今日推荐选题 — 单仓库详情 + 长版 README)
+- The input is ONE repository: the recommendation note (why this repo today: its blended momentum across the daily/weekly/monthly boards) followed by the repo's detail section and a long README excerpt.
+- Follow the template's SIX-ACT structure; the README is the primary fact source — prefer saying less over inventing. Installation commands must REALLY exist in the README.
+- Keyframe-card rule for this source: the mandatory 3-card set belongs to Act 3 (项目亮相); other acts carry the act-matched cards described in the template section. Ignore the per-project card structure below where it conflicts with the six-act card plan.`,
+
+    "github-weekly-recap": `
+Data source: GitHub Weekly Recap (本周推荐记录 — 逐日已推荐项目与当日文案)
+- The input lists this week's ALREADY-FEATURED daily picks. Each "## " section is ONE day: the featured date, repo metadata, the copy generated that day (亮点/介绍/点评), and that day's data.
+- EXACTLY ONE scene per "## " section, in input order. do NOT add or drop projects, and do NOT re-crawl or update their numbers — the recorded snapshot is the fact base.
+- Carry each day's recorded github.highlights / intro / review back UNCHANGED (they are the archival record); write the scene narration fresh in a look-back voice instead of re-reading the intro.
+- If the week has fewer than 7 recorded days, acknowledge it in passing (e.g. "本周完成了五期推荐") — never fabricate the missing ones.`,
   };
 
   const parts = [base, templateSpecific[template]];

+ 15 - 0
packages/shared/src/node.ts

@@ -6,3 +6,18 @@ export { getTimezone } from "./utils/timezone.js";
 export { createLogger } from "./utils/logger.js";
 export type { Logger, LogLevel } from "./utils/logger.js";
 export { describeError } from "./utils/error.js";
+export {
+  listCovered,
+  isCovered,
+  coveredOn,
+  getWeekRecords,
+  markCovered,
+  markRecap,
+  hasRecap,
+  readCoveredStore,
+} from "./state/covered-store.js";
+export type {
+  CoveredRepoRecord,
+  RecapRecord,
+  CoveredOutputRef,
+} from "./state/covered-store.js";

+ 186 - 0
packages/shared/src/state/covered-store.ts

@@ -0,0 +1,186 @@
+import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import type { RepoMeta } from "../types/scene.js";
+import { isoDateString } from "../utils/date.js";
+import { getTimezone } from "../utils/timezone.js";
+
+/**
+ * Covered store — the persistent "already featured" history for github-daily-pick.
+ *
+ * This is the pipeline's cross-run dedup state: which repos have been featured
+ * in a daily-pick video, when, with what generated copy (the recap collector
+ * re-feeds it to the LLM) and where the video landed. Written by the core
+ * orchestrator AFTER a successful render (a failed run never marks a repo as
+ * covered, so a retry picks the same project again).
+ *
+ * Storage: a single JSON file under PIPELINE_COVERED_DIR (default: system tmp).
+ * Deployment must point that at a persistent volume — the jobs volume's
+ * `covered/` subdirectory is the recommended spot (see docs/DEPLOYMENT.md).
+ * It must NOT live inside the output dir: cleanupExpiredOutput would delete it
+ * after retentionDays.
+ *
+ * Single-replica assumption (same as job-store): one process writes at a time;
+ * all operations here are synchronous (sync fs + sync JSON), so calls cannot
+ * interleave mid-mutation within the process. The file itself is written
+ * atomically (tmp + rename).
+ */
+
+export interface CoveredOutputRef {
+  /** Local file path (valid until the output TTL cleanup removes it). */
+  filePath: string;
+  /** OSS URL (permanent when upload succeeded). */
+  ossUrl?: string;
+  durationSeconds?: number;
+}
+
+/** One successfully-featured repo. Dedup key is fullName (compared lowercase). */
+export interface CoveredRepoRecord {
+  fullName: string;
+  /** Calendar day the video rendered OK — isoDateString() under TIMEZONE. */
+  date: string;
+  /** The job that produced the video (matches the web jobs list). */
+  jobId: string;
+  /** Repo snapshot at feature time (recap re-display + debugging). */
+  repo: RepoMeta;
+  /** LLM-produced copy — the weekly recap's source material. */
+  highlights: string;
+  intro: string;
+  review: string;
+  /** Video-level summary (doc.meta.summary), when present. */
+  summary?: string;
+  outputs?: CoveredOutputRef[];
+  createdAt: number;
+}
+
+/** A delivered weekly recap. NOT part of dedup — guards same-week re-runs. */
+export interface RecapRecord {
+  /** Monday of the covered week, ISO date under TIMEZONE. */
+  weekStart: string;
+  /** Day the recap actually rendered OK. */
+  date: string;
+  jobId: string;
+  /** fullName list of the week's featured repos (in recap order). */
+  repos: string[];
+  outputs?: CoveredOutputRef[];
+  createdAt: number;
+}
+
+interface CoveredStoreData {
+  version: 1;
+  records: CoveredRepoRecord[];
+  recaps: RecapRecord[];
+}
+
+const EMPTY_STORE: CoveredStoreData = { version: 1, records: [], recaps: [] };
+
+const STORE_DIR = process.env.PIPELINE_COVERED_DIR
+  ? resolve(process.env.PIPELINE_COVERED_DIR)
+  : join(tmpdir(), "pipeline-covered");
+const STORE_FILE = join(STORE_DIR, "covered.json");
+
+function readStore(): CoveredStoreData {
+  try {
+    if (!existsSync(STORE_FILE)) return { ...EMPTY_STORE };
+    const parsed = JSON.parse(readFileSync(STORE_FILE, "utf-8")) as CoveredStoreData;
+    if (!parsed || !Array.isArray(parsed.records) || !Array.isArray(parsed.recaps)) {
+      throw new Error("malformed store");
+    }
+    return parsed;
+  } catch (err) {
+    // Missing/corrupt store: dedup history is lost — say so loudly, then start
+    // fresh (the next write rebuilds the file). Losing it degrades dedup, it
+    // does not break rendering.
+    console.error(
+      `[covered-store] cannot read ${STORE_FILE} (${err instanceof Error ? err.message : err}); ` +
+        "starting with empty dedup history",
+    );
+    return { ...EMPTY_STORE };
+  }
+}
+
+/** Atomic write: dump to a temp file in the same dir, then rename over. */
+function writeStore(data: CoveredStoreData): void {
+  mkdirSync(STORE_DIR, { recursive: true });
+  const tmpPath = `${STORE_FILE}.${process.pid}.tmp`;
+  try {
+    writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8");
+    renameSync(tmpPath, STORE_FILE);
+  } catch (err) {
+    try {
+      if (existsSync(tmpPath)) unlinkSync(tmpPath);
+    } catch {
+      /* best-effort cleanup */
+    }
+    throw err;
+  }
+}
+
+function addDays(isoDay: string, days: number): string {
+  const d = new Date(`${isoDay}T12:00:00Z`);
+  d.setUTCDate(d.getUTCDate() + days);
+  return d.toISOString().slice(0, 10);
+}
+
+/** Records within the rolling window (inclusive of today), date ascending. */
+export function listCovered(windowDays = 90, now: Date = new Date()): CoveredRepoRecord[] {
+  const cutoff = addDays(isoDateString(now, getTimezone()), -windowDays);
+  return readStore().records
+    .filter((r) => r.date >= cutoff)
+    .sort((a, b) => a.date.localeCompare(b.date));
+}
+
+/** Rolling-window dedup check (fullName compared lowercase). */
+export function isCovered(fullName: string, windowDays = 90, now: Date = new Date()): boolean {
+  const key = fullName.toLowerCase();
+  const cutoff = addDays(isoDateString(now, getTimezone()), -windowDays);
+  return readStore().records.some((r) => r.date >= cutoff && r.fullName.toLowerCase() === key);
+}
+
+/** Records featured on a given calendar day — the same-day idempotency probe
+ *  the daily-pick collector uses to re-pick the same repo on a re-run. */
+export function coveredOn(date: string): CoveredRepoRecord[] {
+  return readStore().records
+    .filter((r) => r.date === date)
+    .sort((a, b) => a.createdAt - b.createdAt);
+}
+
+/** Records in [weekStart, weekStart+6] (date ascending) — recap source material. */
+export function getWeekRecords(weekStart: string): CoveredRepoRecord[] {
+  const end = addDays(weekStart, 6);
+  return readStore().records
+    .filter((r) => r.date >= weekStart && r.date <= end)
+    .sort((a, b) => a.date.localeCompare(b.date) || a.createdAt - b.createdAt);
+}
+
+/** Record a successfully-featured repo. Upserts on (date, fullName) so re-runs
+ *  overwrite instead of piling up; prunes records past the window (+7d slack
+ *  for clock/timezone edges) so the file stays bounded. */
+export function markCovered(record: CoveredRepoRecord, windowDays = 90): void {
+  const store = readStore();
+  const key = record.fullName.toLowerCase();
+  store.records = store.records.filter(
+    (r) => !(r.date === record.date && r.fullName.toLowerCase() === key),
+  );
+  store.records.push(record);
+  const cutoff = addDays(isoDateString(new Date(), getTimezone()), -(windowDays + 7));
+  store.records = store.records.filter((r) => r.date >= cutoff);
+  writeStore(store);
+}
+
+/** Record a delivered weekly recap (upsert on weekStart). */
+export function markRecap(record: RecapRecord): void {
+  const store = readStore();
+  store.recaps = store.recaps.filter((r) => r.weekStart !== record.weekStart);
+  store.recaps.push(record);
+  writeStore(store);
+}
+
+export function hasRecap(weekStart: string): boolean {
+  return readStore().recaps.some((r) => r.weekStart === weekStart);
+}
+
+/** Raw store contents (admin/debugging). */
+export function readCoveredStore(): CoveredStoreData {
+  return readStore();
+}

+ 22 - 5
packages/shared/src/types/document.ts

@@ -85,14 +85,22 @@ export const GithubTrendingImagesSchema = z.object({
 });
 export type GithubTrendingImages = z.infer<typeof GithubTrendingImagesSchema>;
 
-/** github-trending / github-weekly share this extension shape (repo +
- *  highlights/intro/review + named images); only the discriminator differs,
- *  so both boards read the same fields. */
+/** github-trending / github-weekly / github-daily-pick / github-weekly-recap
+ *  share this extension shape (repo + highlights/intro/review + named images);
+ *  only the discriminator differs, so they all read the same fields. */
 export const GithubBoardExtensionSchema = GithubSceneDataSchema.extend({
-  type: z.enum(["github-trending", "github-weekly"]),
+  type: z.enum([
+    "github-trending",
+    "github-weekly",
+    "github-daily-pick",
+    "github-weekly-recap",
+  ]),
   /** Named images the template renders (object, not array). Renderer resolves
    *  each into a filename; the template reads them by name. */
   images: GithubTrendingImagesSchema.optional(),
+  /** github-weekly-recap: the calendar day (YYYY-MM-DD, TIMEZONE) this repo was
+   *  featured in a daily pick — rendered as a "推荐于 M月D日" tag. */
+  coveredDate: z.string().optional(),
 });
 export type GithubBoardExtension = z.infer<typeof GithubBoardExtensionSchema>;
 
@@ -170,7 +178,16 @@ export type VideoDocumentMeta = z.infer<typeof VideoDocumentMetaSchema>;
 export const VideoDocumentSchema = z.object({
   version: z.literal(VIDEO_DOCUMENT_VERSION),
   type: z.literal(VIDEO_DOCUMENT_TYPE),
-  template: z.enum(["news", "knowledge", "opinion", "marketing", "github-trending", "github-weekly"]),
+  template: z.enum([
+    "news",
+    "knowledge",
+    "opinion",
+    "marketing",
+    "github-trending",
+    "github-weekly",
+    "github-daily-pick",
+    "github-weekly-recap",
+  ]),
   config: VideoDocumentConfigSchema.optional(),
   meta: VideoDocumentMetaSchema.optional(),
   data: z.array(VideoSegmentSchema),

+ 8 - 2
packages/shared/src/types/render.ts

@@ -24,14 +24,20 @@ export interface RenderGithubImages {
 
 /** Render-time (resolved) per-template extension. Mirrors SegmentExtension but
  *  with images resolved to filenames. Built by the renderer; templates read this.
- *  github-trending and github-weekly share the shape (only the type tag differs). */
+ *  The github templates share the shape (only the type tag differs). */
 export type RenderSegmentExtension = {
-  type: "github-trending" | "github-weekly";
+  type:
+    | "github-trending"
+    | "github-weekly"
+    | "github-daily-pick"
+    | "github-weekly-recap";
   repo: RepoMeta;
   highlights: string;
   intro: string;
   review: string;
   images?: RenderGithubImages;
+  /** github-weekly-recap: the day this repo was featured ("YYYY-MM-DD"). */
+  coveredDate?: string;
 };
 
 /**

+ 9 - 1
packages/shared/src/types/scene.ts

@@ -46,6 +46,11 @@ export const RepoMetaSchema = z.object({
   // "+N" per repo. Nested inside github.repo, so it flows through compose/render
   // automatically (github is passed whole-object) — no extra plumbing needed.
   todayStars: z.number().optional(),
+  // github-weekly-recap only: the calendar day (YYYY-MM-DD) this repo was
+  // featured in a daily pick. Set by the recap collector from the covered
+  // store; lifted onto the extension by assemble. Snapshot channel — harmless
+  // (optional) for all other collectors/templates.
+  coveredDate: z.string().optional(),
 });
 
 export type RepoMeta = z.infer<typeof RepoMetaSchema>;
@@ -89,7 +94,10 @@ export type GlobalStyleHints = z.infer<typeof GlobalStyleHintsSchema>;
 export const InputSceneSchema = z.object({
   id: z.string(),
   title: z.string().optional(),
-  narration: z.string().max(200, "Narration too long — split into more scenes"),
+  // 280 is the schema hard cap across ALL templates; per-template clipping
+  // happens in the text module's postprocess (github-daily-pick allows deeper
+  // narration than the board templates' 200).
+  narration: z.string().max(280, "Narration too long — split into more scenes"),
   displayText: z.string().optional(),
   keyframes: z.array(KeyframeSchema).optional(),
   images: z.array(SceneImageSchema).optional(),

+ 18 - 0
packages/shared/src/utils/date.ts

@@ -54,6 +54,24 @@ export function isoDateString(
   return `${part(p, "year")}-${part(p, "month")}-${part(p, "day")}`;
 }
 
+/**
+ * The Monday of the calendar week containing `date`, as an ISO date string
+ * (YYYY-MM-DD) in the given timezone (default Asia/Shanghai). Shares the
+ * Monday-anchoring with formatWeekRange — used by the covered store / recap
+ * collector to bucket records by week, so the bucket always matches the range
+ * the cover subtitle shows.
+ */
+export function startOfWeekIso(
+  date: Date = new Date(),
+  timeZone: string = DEFAULT_TIMEZONE,
+): string {
+  // Weekday index with Monday=0: JS getDay() is Sunday=0..Saturday=6.
+  const dowMon0 = (Number(part(dateParts(date, timeZone, { year: "numeric", month: "numeric", day: "numeric" }), "weekday")) + 6) % 7;
+  const start = new Date(date.getTime() - dowMon0 * 24 * 3600 * 1000);
+  const p = dateParts(start, timeZone, PARTS_DATE_PADDED);
+  return `${part(p, "year")}-${part(p, "month")}-${part(p, "day")}`;
+}
+
 /**
  * The calendar week containing `date`, formatted as a compact zh-CN range
  * "YYYY.MM.DD - MM.DD" (Monday → Sunday, both ends in the given timezone).

+ 1 - 1
packages/shared/src/utils/index.ts

@@ -1,5 +1,5 @@
 export { stripUnsupportedGlyphs, clipToLength } from "./sanitize-text.js";
 export { formatCount } from "./format.js";
-export { isoDateString, formatChineseDate, formatWeekRange, DEFAULT_TIMEZONE } from "./date.js";
+export { isoDateString, formatChineseDate, formatWeekRange, startOfWeekIso, DEFAULT_TIMEZONE } from "./date.js";
 export { normalizeCountsForTTS } from "./narration.js";
 export { audioFilenameFor } from "./audio-filename.js";

+ 45 - 21
packages/templates/src/Root.tsx

@@ -7,6 +7,7 @@ import OpinionScene from "./opinion/index";
 import MarketingScene from "./marketing/index";
 import GithubTrendingScene, { ColorDot } from "./github-trending/index";
 import GithubWeeklyScene from "./github-weekly/index";
+import GithubDailyPickScene from "./github-daily-pick/index";
 import { THEMES } from "./base/theme/colors";
 
 // Re-export the render-time contract so legacy imports (`import { RemotionScene
@@ -15,11 +16,12 @@ import { THEMES } from "./base/theme/colors";
 export type { RenderScene as RemotionScene, RenderProps as RemotionProps } from "@pipeline/shared";
 import type { RenderSegmentExtension } from "@pipeline/shared";
 
-/** Narrow a scene's per-template extension to a github board variant (trending
- *  or weekly — same shape), or undefined. Template-specific data lives in
- *  `extension`, not on the scene root. */
+/** Narrow a scene's per-template extension to a github variant (trending /
+ *  weekly / daily-pick / weekly-recap — same shape), or undefined.
+ *  Template-specific data lives in `extension`, not on the scene root. */
+const GITHUB_EXT_TYPES = ["github-trending", "github-weekly", "github-daily-pick", "github-weekly-recap"] as const;
 const ghExt = (s: { extension?: RenderScene["extension"] }): RenderSegmentExtension | undefined =>
-  s.extension?.type === "github-trending" || s.extension?.type === "github-weekly"
+  s.extension && (GITHUB_EXT_TYPES as readonly string[]).includes(s.extension.type)
     ? s.extension
     : undefined;
 
@@ -47,6 +49,10 @@ const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
   marketing: MarketingScene,
   "github-trending": GithubTrendingScene,
   "github-weekly": GithubWeeklyScene,
+  "github-daily-pick": GithubDailyPickScene,
+  // The recap reuses the weekly board's scene component — same light magazine
+  // layout; only the per-scene tag differs (featured date, see github-weekly).
+  "github-weekly-recap": GithubWeeklyScene,
 };
 
 const RATIO_ID: Record<AspectRatio, string> = {
@@ -59,7 +65,16 @@ const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
   "9:16": { width: 1080, height: 1920 },
 };
 
-const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending", "github-weekly"];
+const TEMPLATES: TemplateType[] = [
+  "news",
+  "knowledge",
+  "opinion",
+  "marketing",
+  "github-trending",
+  "github-weekly",
+  "github-daily-pick",
+  "github-weekly-recap",
+];
 
 const TemplateComposition: React.FC<RenderProps> = (props) => {
   const { fps } = useVideoConfig();
@@ -145,7 +160,10 @@ const TemplateComposition: React.FC<RenderProps> = (props) => {
         );
       })}
       <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
-      {(props.template === "github-trending" || props.template === "github-weekly") && (
+      {(props.template === "github-trending" ||
+        props.template === "github-weekly" ||
+        props.template === "github-daily-pick" ||
+        props.template === "github-weekly-recap") && (
         <ChapterToc layout={layout} template={props.template} />
       )}
     </AbsoluteFill>
@@ -203,16 +221,17 @@ const CoverScene: React.FC<{
   totalFrames: number;
 }> = ({ template, title, subtitle, cardList, backgroundAsset, coverRepos, trendSummary }) => {
   const accent = THEMES[template].primaryLight;
-  const isGithubTrending = template === "github-trending";
+  const isGithubTrending = template === "github-trending" || template === "github-daily-pick";
+  const isGithubWeekly = template === "github-weekly" || template === "github-weekly-recap";
   const { width, height } = useVideoConfig();
   const isPortrait = height > width;
 
-  // github-weekly cover: the LIGHT magazine-weekly masthead — paper
-  // background, editorial rules top and bottom, the masthead title + week
-  // range + language tags + trend line typeset like a journal front page. No
-  // background image (the paper IS the identity); the light gradient stands
-  // alone so no asset dependency exists for this board.
-  if (template === "github-weekly") {
+  // github-weekly / github-weekly-recap cover: the LIGHT magazine-weekly
+  // masthead — paper background, editorial rules top and bottom, the masthead
+  // title + week range + language tags + trend line typeset like a journal
+  // front page. No background image (the paper IS the identity); the light
+  // gradient stands alone so no asset dependency exists for this board.
+  if (isGithubWeekly) {
     const theme = THEMES[template];
     const primary = theme.primary;
     // Aggregate languages across the week's repos → the cover's "main tags".
@@ -324,12 +343,13 @@ const CoverScene: React.FC<{
     );
   }
 
-  // github-trending cover: the renderer-provided background image (default.png
-  // fallback — no template-specific bg exists yet) under a dark scrim, with a
-  // DARK title card fully centered on top — no repo list. Shows only the
-  // masthead title, the date, the day's main language tags (aggregated across
-  // today's repos), and the trend summary line. Light gradient + CoverDecor is
-  // the no-image fallback (e.g. studio demo props).
+  // github-trending / github-daily-pick cover: the renderer-provided
+  // background image (default.png fallback — no template-specific bg exists
+  // yet) under a dark scrim, with a DARK title card fully centered on top — no
+  // repo list. Shows only the masthead title, the date, the day's main
+  // language tags (aggregated across today's repos / the featured repo), and
+  // the trend summary line. Light gradient + CoverDecor is the no-image
+  // fallback (e.g. studio demo props).
   if (isGithubTrending) {
     const primary = THEMES[template].primary;
     // Aggregate languages across today's repos → the cover's "main tags".
@@ -592,7 +612,7 @@ const ChapterToc: React.FC<{
   const { width, height } = useVideoConfig();
   const isPortrait = height > width;
   const theme = THEMES[template];
-  const onLight = template === "github-weekly";
+  const onLight = template === "github-weekly" || template === "github-weekly-recap";
   const accent = theme.primary;
 
   const chapters = layout.filter((l) => l.scene.kind === "content" && ghExt(l.scene));
@@ -631,7 +651,11 @@ const ChapterToc: React.FC<{
         {chapters.map((c, i) => {
           const isActive = i === active;
           const repo = ghExt(c.scene)!.repo;
-          const label = repo.name || repo.fullName || c.scene.title || "";
+          // daily-pick chapters are ASPECTS ("快速上手"), not repos — label by
+          // the scene title; the board/recap chapters label by repo short name.
+          const label = template === "github-daily-pick"
+            ? (c.scene.title || repo.name || "")
+            : (repo.name || repo.fullName || c.scene.title || "");
           return (
             <React.Fragment key={c.scene.id}>
               {i > 0 && (

+ 20 - 0
packages/templates/src/base/theme/colors.ts

@@ -59,6 +59,26 @@ export const THEMES = {
     textMuted: "#78716c",
     gradient: ["#faf7f2", "#f0e6d4"],
   },
+  "github-daily-pick": {
+    primary: "#8b5cf6",
+    primaryLight: "#a78bfa",
+    accent: "#22d3ee",
+    bg: "#0f172a",
+    bgLight: "#1e293b",
+    text: "#ffffff",
+    textMuted: "#94a3b8",
+    gradient: ["#0f172a", "#3b0764"],
+  },
+  "github-weekly-recap": {
+    primary: "#0284c7",
+    primaryLight: "#0ea5e9",
+    accent: "#b45309",
+    bg: "#faf7f2",
+    bgLight: "#f3ede2",
+    text: "#1e293b",
+    textMuted: "#78716c",
+    gradient: ["#faf7f2", "#e8f0f5"],
+  },
 } as const;
 
 export type ThemeName = keyof typeof THEMES;

+ 350 - 0
packages/templates/src/github-daily-pick/index.tsx

@@ -0,0 +1,350 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  Img,
+  staticFile,
+  interpolate,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import type { RenderSegmentExtension } from "@pipeline/shared";
+import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { Watermark } from "../base/components/watermark";
+import { formatCount } from "@pipeline/shared";
+import { Tag, ColorDot } from "../github-trending/index";
+
+interface GithubDailyPickSceneProps {
+  scene: RemotionScene;
+  sceneIndex: number;
+  totalScenes: number;
+  totalFrames: number;
+  title: string;
+  channelName?: string;
+}
+
+/**
+ * github-daily-pick content scene — one ACT chapter of the day's six-act
+ * recommendation (钩子/痛点/亮相/亮点/上手/总结). Unlike the board templates (headline = repo fullName), here the scene
+ * title is the act ("快速上手"), the repo identity moves to a
+ * compact sub-line, and the body is the scene's keyframe cards plus the repo's
+ * social preview. A top-right progress badge (03 / 07) reinforces the
+ * deep-dive chapter structure.
+ */
+const GithubDailyPickScene: React.FC<GithubDailyPickSceneProps> = ({
+  scene,
+  sceneIndex,
+  totalScenes,
+  channelName,
+}) => {
+  const frame = useCurrentFrame();
+  const { width, height, durationInFrames } = useVideoConfig();
+  const isPortrait = height > width;
+  const palette = GITHUB_TRENDING_PALETTE[sceneIndex % GITHUB_TRENDING_PALETTE.length];
+  const github: RenderSegmentExtension | undefined =
+    scene.extension?.type === "github-daily-pick" ? scene.extension : undefined;
+
+  const sceneDur = durationInFrames;
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 8, sceneDur - 8, sceneDur],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  const socialPreview = github?.images?.socialPreview;
+  const starHistory = github?.images?.starHistory;
+
+  const pad = isPortrait ? 48 : 80;
+  const footerReserved = isPortrait ? 500 : 140;
+
+  const repo = github?.repo;
+  const language = repo?.language || "";
+  const languageColor = repo?.languageColor || palette.accent;
+  const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
+  const license = repo?.license || "";
+  const fullName = repo?.fullName || "";
+
+  if (!github) {
+    return (
+      <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
+        <div style={{
+          position: "absolute", inset: 0, display: "flex",
+          alignItems: "center", justifyContent: "center", padding: pad,
+        }}>
+          <div style={{ fontSize: 48, fontWeight: 700, color: "white", fontFamily: "Noto Sans SC", textAlign: "center" }}>
+            {scene.title || scene.captionOrigin}
+          </div>
+        </div>
+        {(scene.caption?.length ?? 0) > 0 && <SubtitleBar caption={scene.caption} />}
+        <Watermark text={channelName} />
+      </AbsoluteFill>
+    );
+  }
+
+  // The scene's cards, ordered as produced. Card kinds are free-form strings
+  // (功能简介 / 快速上手 / 对比 / 安装 ...); shown as titled sections.
+  const cards = (scene.cardList ?? []).filter((c) => (c.desc ?? "").trim());
+  const badge = `${String(sceneIndex + 1).padStart(2, "0")} / ${String(totalScenes).padStart(2, "0")}`;
+
+  const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
+    <div>
+      {/* Aspect title — the chapter headline (from displayText) */}
+      <div style={{
+        fontSize,
+        fontWeight: 800,
+        color: "white",
+        fontFamily: "Noto Sans SC",
+        lineHeight: 1.15,
+        letterSpacing: "-0.01em",
+      }}>
+        {scene.title || fullName}
+      </div>
+      {/* Repo identity — compact sub-line under the act title */}
+      <div style={{
+        display: "flex", flexWrap: "wrap", alignItems: "center", gap: 16, marginTop: 16,
+      }}>
+        <span style={{
+          fontSize: tagSize === "large" ? 36 : 30, fontWeight: 600,
+          color: "rgba(255,255,255,0.72)", fontFamily: "Noto Sans SC",
+        }}>
+          {fullName}
+        </span>
+        {language && (
+          <Tag accent={palette.accent} size={tagSize}>
+            <ColorDot color={languageColor} size={dotSize} />
+            {language}
+          </Tag>
+        )}
+        {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
+        {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
+      </div>
+    </div>
+  );
+
+  const renderBadge = (scale: number) => (
+    <div style={{
+      display: "flex", alignItems: "center", gap: 10,
+      padding: "12px 26px", borderRadius: 12,
+      background: "rgba(0,0,0,0.35)",
+      border: `1px solid ${palette.accent}66`,
+      fontFamily: "Noto Sans SC",
+      backdropFilter: "blur(4px)",
+    }}>
+      <span style={{ fontSize: 30 * scale, fontWeight: 700, color: palette.accent }}>
+        {String(sceneIndex + 1).padStart(2, "0")}
+      </span>
+      <span style={{ fontSize: 22 * scale, fontWeight: 500, color: "rgba(255,255,255,0.55)" }}>
+        / {String(totalScenes).padStart(2, "0")}
+      </span>
+    </div>
+  );
+
+  const cardsRow = (size: "default" | "large") => (
+    <div style={{
+      flex: 1, display: "flex", flexDirection: "column", gap: 20, minHeight: 0,
+    }}>
+      {cards.slice(0, size === "large" ? 2 : 3).map((card, i) => (
+        <AspectSection
+          key={i}
+          title={card.kind || "要点"}
+          accent={palette.accent}
+          body={card.desc ?? ""}
+          size={size}
+        />
+      ))}
+    </div>
+  );
+
+  return (
+    <AbsoluteFill style={{
+      opacity: fadeOpacity,
+      background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
+    }}>
+      {/* Subtle grid overlay (matches other templates' aesthetic) */}
+      <div style={{
+        position: "absolute", inset: 0,
+        backgroundImage: `
+          linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
+          linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
+        `,
+        backgroundSize: "80px 80px",
+      }} />
+
+      {/* Top accent line */}
+      <div style={{
+        position: "absolute", top: 0, left: 0, width: "100%", height: 4,
+        background: `linear-gradient(90deg, ${palette.accent}, ${palette.accent}88)`,
+      }} />
+
+      {isPortrait ? (
+        <div style={{
+          position: "relative",
+          display: "flex",
+          flexDirection: "column",
+          width: "100%",
+          height: "100%",
+          boxSizing: "border-box",
+          padding: `${pad}px ${pad}px ${footerReserved}px`,
+        }}>
+          <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20 }}>
+            <div style={{ flex: 1, minWidth: 0 }}>{renderHeader(84, "large", 16)}</div>
+            {renderBadge(0.9)}
+          </div>
+          <div style={{ height: 1, background: "rgba(255,255,255,0.18)", marginTop: 22 }} />
+          <div style={{
+            flex: 1, display: "flex", flexDirection: "column", gap: 24, paddingTop: 22, minHeight: 0,
+          }}>
+            {socialPreview && (
+              <ImageFrame>
+                <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+              </ImageFrame>
+            )}
+            {cardsRow("large")}
+          </div>
+        </div>
+      ) : (
+        <>
+          <div style={{
+            position: "absolute",
+            top: pad,
+            left: pad,
+            right: pad,
+            display: "flex",
+            alignItems: "flex-start",
+            justifyContent: "space-between",
+            gap: 24,
+          }}>
+            <div style={{ flex: 1, minWidth: 0 }}>{renderHeader(64, "default", 14)}</div>
+            {renderBadge(1)}
+          </div>
+
+          <div style={{
+            position: "absolute",
+            top: pad + 150,
+            left: pad,
+            right: pad,
+            height: 1,
+            background: "rgba(255,255,255,0.18)",
+          }} />
+
+          <div style={{
+            position: "absolute",
+            top: pad + 174,
+            left: pad,
+            right: pad,
+            bottom: footerReserved,
+            display: "flex",
+            gap: 40,
+          }}>
+            {socialPreview && (
+              <div style={{
+                width: "38%",
+                display: "flex",
+                flexDirection: "column",
+                gap: 20,
+                overflow: "hidden",
+              }}>
+                <ImageFrame>
+                  <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+                </ImageFrame>
+                {starHistory && (
+                  <ImageFrame>
+                    <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+                  </ImageFrame>
+                )}
+              </div>
+            )}
+            {cardsRow("default")}
+          </div>
+        </>
+      )}
+
+      {(scene.caption?.length ?? 0) > 0 && (
+        <SubtitleBar
+          caption={scene.caption}
+          style={isPortrait ? { bottom: 380 } : undefined}
+          fontSize={isPortrait ? 56 : undefined}
+        />
+      )}
+      <Watermark text={channelName} />
+    </AbsoluteFill>
+  );
+};
+
+const ImageFrame: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+  <div style={{
+    width: "100%",
+    height: "auto",
+    background: "#0b1220",
+    border: "1px solid rgba(255,255,255,0.10)",
+    borderRadius: 16,
+    overflow: "hidden",
+    display: "flex",
+    alignItems: "center",
+    justifyContent: "center",
+    boxShadow: "0 12px 32px rgba(0,0,0,0.45), 0 2px 8px rgba(0,0,0,0.35)",
+  }}>
+    {children}
+  </div>
+);
+
+const AspectSection: React.FC<{
+  title: string;
+  accent: string;
+  body: string;
+  size?: "default" | "large";
+}> = ({ title, accent, body, size = "default" }) => {
+  const isLarge = size === "large";
+  const cleanBody = (body ?? "").replace(/[。,、,.;;::\s]+$/, "");
+  return (
+    <div style={{
+      flex: 1,
+      background: "rgba(255,255,255,0.05)",
+      border: "1px solid rgba(255,255,255,0.10)",
+      borderRadius: 12,
+      padding: isLarge ? "24px 32px" : "22px 28px",
+      display: "flex",
+      flexDirection: "column",
+      gap: 12,
+      minHeight: 0,
+    }}>
+      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
+        <span style={{
+          display: "inline-block",
+          width: isLarge ? 6 : 5,
+          height: isLarge ? 32 : 26,
+          background: accent,
+          borderRadius: 2,
+        }} />
+        <span style={{
+          fontSize: isLarge ? 34 : 28,
+          fontWeight: 700,
+          color: accent,
+          fontFamily: "Noto Sans SC",
+          letterSpacing: "0.04em",
+        }}>
+          {title}
+        </span>
+      </div>
+      <div style={{
+        flex: "0 0 auto",
+        fontSize: isLarge ? 52 : 32,
+        lineHeight: isLarge ? 1.35 : 1.4,
+        color: "white",
+        fontFamily: "Noto Sans SC",
+        fontWeight: 400,
+        display: "-webkit-box",
+        WebkitLineClamp: isLarge ? 3 : 4,
+        WebkitBoxOrient: "vertical",
+        overflow: "hidden",
+        overflowWrap: "break-word",
+      }}>
+        {cleanBody}
+      </div>
+    </div>
+  );
+};
+
+export default GithubDailyPickScene;

+ 20 - 4
packages/templates/src/github-weekly/index.tsx

@@ -20,6 +20,11 @@ import { formatCount } from "@pipeline/shared";
  * cards (white cards on paper, the inverse of the daily board's dark-on-dark),
  * and the same named images (social preview + star history) from the shared
  * github board extension.
+ *
+ * Also serves github-weekly-recap: same layout, but the per-scene tag shows
+ * the repo's featured date ("推荐 · 8月12日", from extension.coveredDate)
+ * instead of a weekly star gain — the recap's repo snapshot carries the
+ * feature-time numbers, so a "本周 +N" label would be wrong there.
  */
 interface GithubWeeklySceneProps {
   scene: RemotionScene;
@@ -40,9 +45,12 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
   const isPortrait = height > width;
   const palette = GITHUB_WEEKLY_PALETTE[sceneIndex % GITHUB_WEEKLY_PALETTE.length];
   const github: RenderSegmentExtension | undefined =
-    scene.extension?.type === "github-trending" || scene.extension?.type === "github-weekly"
+    scene.extension?.type === "github-trending" ||
+    scene.extension?.type === "github-weekly" ||
+    scene.extension?.type === "github-weekly-recap"
       ? scene.extension
       : undefined;
+  const isRecap = scene.extension?.type === "github-weekly-recap";
 
   const sceneDur = durationInFrames;
   const fadeOpacity = interpolate(
@@ -70,10 +78,18 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
   const repo = github?.repo;
   const language = repo?.language || "";
   const languageColor = repo?.languageColor || palette.accent;
-  // Under the weekly board, todayStars carries the WEEKLY gain.
   const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
-  const weekGainLabel =
-    repo?.todayStars != null ? `本周 +${formatCount(repo.todayStars)}` : "";
+  // Under the weekly board, todayStars carries the WEEKLY gain. The recap
+  // instead tags the scene with the repo's featured date ("2026-08-12" →
+  // "推荐 · 8月12日"); its snapshot numbers are feature-time, not this week's.
+  const featuredLabel = github?.coveredDate
+    ? `推荐 · ${github.coveredDate.replace(/^\d{4}-/, "").replace("-", "月").replace(/^0/, "")}日`
+    : "";
+  const weekGainLabel = isRecap
+    ? featuredLabel
+    : repo?.todayStars != null
+      ? `本周 +${formatCount(repo.todayStars)}`
+      : "";
   const license = repo?.license || "";
   const fullName = repo?.fullName || scene.title || "";
 

+ 65 - 21
packages/text/src/assemble.ts

@@ -19,6 +19,24 @@ import { getTimezone } from "@pipeline/shared/node";
  *  and time framing differ. */
 const isGithubBoard = (t: TemplateType) => t === "github-trending" || t === "github-weekly";
 
+/** All four github templates share the masthead cover, repo-extension scenes,
+ *  cover narration and trendSummary plumbing; the two newcomers (daily-pick /
+ *  weekly-recap) differ in how content scenes are selected (below). */
+const isGithubTemplate = (t: TemplateType) =>
+  t === "github-trending" ||
+  t === "github-weekly" ||
+  t === "github-daily-pick" ||
+  t === "github-weekly-recap";
+
+/** Fixed cover masthead per github template — overrides whatever the LLM
+ *  produced so the cover stays deterministic. */
+const MASTHEAD: Partial<Record<TemplateType, string>> = {
+  "github-trending": "GitHub 每日热榜",
+  "github-weekly": "GitHub 周榜",
+  "github-daily-pick": "GitHub 每日推荐",
+  "github-weekly-recap": "本周推荐回顾",
+};
+
 /**
  * Assembly layer — turns a validated VideoInput (+ typed repo metadata from the
  * data source) into the canonical VideoDocument. Ports the scene-building
@@ -40,17 +58,21 @@ export function assembleDocument(
   template: TemplateType,
   repos: RepoMeta[]
 ): AssembledDocument {
-  // github boards: the cover is a fixed masthead — title is the board name,
-  // subtitle is the board's date range (daily: today's date; weekly: this
-  // week's Mon-Sun range). Override whatever the LLM produced so the cover
-  // stays deterministic.
-  if (isGithubBoard(template)) {
+  // github templates: the cover is a fixed masthead — title is the show name,
+  // subtitle is its date framing (daily: today's date; weekly boards: the
+  // Mon-Sun range; daily-pick: the featured repo + today's date so the cover
+  // leads with the day's project). Override whatever the LLM produced so the
+  // cover stays deterministic.
+  if (isGithubTemplate(template)) {
     videoInput = {
       ...videoInput,
-      title: template === "github-weekly" ? "GitHub 周榜" : "GitHub 每日热榜",
-      subtitle: template === "github-weekly"
-        ? formatWeekRange(new Date(), getTimezone())
-        : formatChineseDate(new Date(), getTimezone()),
+      title: MASTHEAD[template]!,
+      subtitle:
+        template === "github-weekly" || template === "github-weekly-recap"
+          ? formatWeekRange(new Date(), getTimezone())
+          : template === "github-daily-pick"
+            ? `${repos[0]?.fullName ?? ""} · ${formatChineseDate(new Date(), getTimezone())}`.trim()
+            : formatChineseDate(new Date(), getTimezone()),
     };
   }
 
@@ -76,12 +98,22 @@ export function assembleDocument(
     const typed = repoByFullName.get(key) ?? s.github.repo;
     return {
       extension: {
-        // github-trending | github-weekly — same shape (isGithubBoard guard above)
-        type: template as "github-trending" | "github-weekly",
+        // github-trending | github-weekly | github-daily-pick | github-weekly-recap
+        // — same shape (isGithubTemplate guard above)
+        type: template as
+          | "github-trending"
+          | "github-weekly"
+          | "github-daily-pick"
+          | "github-weekly-recap",
         repo: typed,
         highlights: s.github.highlights,
         intro: s.github.intro,
         review: s.github.review,
+        // weekly-recap: the day this repo was featured (carried on the recap
+        // collector's repo snapshot) — lifted here for the "推荐于 M月D日" tag.
+        ...(template === "github-weekly-recap" && typed.coveredDate
+          ? { coveredDate: typed.coveredDate }
+          : {}),
         // Template-declared images (named object): social preview (resolved via
         // the repo crawler) + star history (URL). Explicit in the JSON so the
         // template controls them; the renderer resolves each by name.
@@ -99,10 +131,10 @@ export function assembleDocument(
   // --- Cover ---
   const coverInput = videoInput.cover;
   const firstScene = videoInput.scenes[0];
-  // github boards: the cover is a clean title screen — no inherited keyframes
+  // github templates: the cover is a clean title screen — no inherited keyframes
   // or image. Other templates inherit from the first scene so the cover
   // reflects the video's actual topic.
-  const inheritFromFirstScene = !isGithubBoard(template);
+  const inheritFromFirstScene = !isGithubTemplate(template);
   const coverKeyframes = inheritFromFirstScene
     ? (coverInput?.keyframes ?? (firstScene?.keyframes ?? []).slice(0, 3))
     : (coverInput?.keyframes ?? []);
@@ -121,15 +153,24 @@ export function assembleDocument(
   // trendSummary) → transition into the repo rundown. No date/count (shown
   // visually). Falls back to a bare greeting+transition when trendSummary is
   // absent. Non-github-board covers stay silent (1s).
+  //
+  // github-daily-pick is the exception: its Act 1 (黄金3秒钩子) IS the cold
+  // open — a cover narration would steal the hook's thunder and repeat its
+  // number anchor. Its cover stays silent; the hook scene speaks first.
   const trend = (videoInput.trendSummary ?? "")
     .trim()
     .replace(/[。!?.!?\s]+$/u, "");
-  const periodWord = template === "github-weekly" ? "本周" : "今天";
-  const coverNarration = isGithubBoard(template)
-    ? trend
-      ? `大家好,${periodWord}${trend}。下面进入项目详解。`
-      : "大家好,下面进入项目详解。"
-    : "";
+  const periodWord = template === "github-weekly" || template === "github-weekly-recap" ? "本周" : "今天";
+  // Strip a leading period word from the trend line — the cover narration
+  // template already opens with it ("大家好,今天/本周…"), and LLM trend lines
+  // often start with the same word ("本周 AI Agent…"), which would double up.
+  const trendBody = trend.replace(/^(今天|本周)\s*/, "");
+  const coverNarration =
+    isGithubTemplate(template) && template !== "github-daily-pick"
+      ? trendBody
+        ? `大家好,${periodWord}${trendBody}。下面进入项目详解。`
+        : "大家好,下面进入项目详解。"
+      : "";
 
   segments.push({
     id: "cover",
@@ -147,7 +188,10 @@ export function assembleDocument(
   // github boards cover the TOP 6 repos by the period's star gain (matching
   // the cover preview), in descending-gain order so playback follows the cover
   // ranking. RepoMeta.todayStars carries the period gain (daily or weekly —
-  // the collector decides the period). Other templates keep all input scenes.
+  // the collector decides the period). The daily-pick (aspect scenes of ONE
+  // repo) and weekly-recap (one scene per featured day) keep ALL input scenes
+  // in input order — the LLM's scene order IS the chapter order, and other
+  // templates keep all scenes too.
   const contentInputs = isGithubBoard(template)
     ? videoInput.scenes
         .filter((s) => s.github)
@@ -201,7 +245,7 @@ export function assembleDocument(
       title: videoInput.title,
       subtitle: videoInput.subtitle ?? videoInput.scenes[0]?.title,
       summary: videoInput.summary || undefined,
-      trendSummary: isGithubBoard(template) ? videoInput.trendSummary : undefined,
+      trendSummary: isGithubTemplate(template) ? videoInput.trendSummary : undefined,
     },
     data: segments,
   };

+ 32 - 6
packages/text/src/generate.ts

@@ -6,7 +6,13 @@ import {
   type VideoInput,
   type TemplateType,
 } from "@pipeline/shared";
-import { applyLengthLimits, sanitizeVideoInput, JSON_TRUNCATION_NUDGE } from "./postprocess.js";
+import {
+  applyLengthLimits,
+  sanitizeVideoInput,
+  isMissingGithubScenes,
+  JSON_TRUNCATION_NUDGE,
+  GITHUB_STRUCTURE_NUDGE,
+} from "./postprocess.js";
 import type { TextModuleLlmConfig } from "./types.js";
 
 /**
@@ -52,8 +58,13 @@ export async function generateVideoInput(
   let aiParsed: unknown;
   let lastFinish: string | null = null;
   let lastRaw = "";
+  let needGithubRetry = false;
   for (let attempt = 0; attempt < 2 && aiParsed === undefined; attempt++) {
-    const userMessage = attempt === 0 ? text : `${text}${JSON_TRUNCATION_NUDGE}`;
+    const nudges = [
+      attempt > 0 && needGithubRetry ? GITHUB_STRUCTURE_NUDGE : "",
+      attempt > 0 && !needGithubRetry ? JSON_TRUNCATION_NUDGE : "",
+    ].join("");
+    const userMessage = attempt === 0 ? text : `${text}${nudges}`;
     const { content, finishReason } = await client.chat(systemPrompt, userMessage);
     lastFinish = finishReason;
     lastRaw = stripFences(content);
@@ -62,16 +73,31 @@ export async function generateVideoInput(
     } catch {
       // not valid JSON yet — fall through to retry, or to the final error below
     }
+    // Structure check BEFORE validation: github board outputs with zero github
+    // scenes would render cover-only after assembly. If flagged, discard and
+    // retry once with the structure nudge.
+    if (aiParsed !== undefined) {
+      const limited = applyLengthLimits(aiParsed, template);
+      const missingGithub =
+        (template === "github-trending" || template === "github-weekly") &&
+        isMissingGithubScenes(limited);
+      if (missingGithub) {
+        needGithubRetry = true;
+        aiParsed = undefined;
+      } else {
+        aiParsed = limited;
+      }
+    }
   }
   if (aiParsed === undefined) {
     throw new Error(
-      `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
-        `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
+      needGithubRetry
+        ? `AI output had no github scene structure (github.repo/highlights/intro/review) after retry (finish_reason=${lastFinish ?? "unknown"}):\n${lastRaw.slice(0, 300)}`
+        : `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
+          `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
     );
   }
 
-  aiParsed = applyLengthLimits(aiParsed, template);
-
   const validationResult = VideoInputSchema.safeParse(aiParsed);
   if (!validationResult.success) {
     throw new Error(`AI output does not match VideoInputSchema: ${validationResult.error.message}`);

+ 43 - 9
packages/text/src/postprocess.ts

@@ -10,20 +10,48 @@ import {
  * output is unaffected by the refactor.
  */
 
-/** Appended on the retry attempt when the first LLM JSON failed to parse. */
+/** Appended on the retry attempt when the first LLM output failed to parse. */
 export const JSON_TRUNCATION_NUDGE =
   "\n\n[重要] 你上一次的 JSON 输出因超出输出长度上限被截断,导致解析失败。请重新输出一份更精简但结构完整的 JSON:适当减少 scenes 数量、缩短每个场景的 narration 与详细描述字段,务必确保整个 JSON(含所有闭合括号)在输出上限内完整结束。";
 
+/** Appended on the retry attempt when the LLM output parsed as JSON but NO scene
+ *  carried the structured github object. github templates render every repo
+ *  scene from its `github` block; assembly drops scenes without one, so such an
+ *  output would silently render a cover-only (~4s) video. */
+export const GITHUB_STRUCTURE_NUDGE =
+  "\n\n[重要] 你上一次的输出中没有任何场景包含必需的 github 结构化对象(github.repo / highlights / intro / review)。请重新输出完整 JSON:github 模板下每个仓库场景都必须包含完整 github 对象(github.repo 从输入的 <!-- repo-meta --> 注释块原样复制,不得改写字段或数值),不要输出缺少 github 对象的概览型场景。";
+
 /**
  * Clip LLM-generated text fields to their schema-enforced maximums before
  * validation. Clips at a sentence/clause boundary when possible so the result
  * still reads naturally.
  *
- * For the github boards (github-trending / github-weekly), also strips leading
- * greetings from every scene's narration — the cover scene already opens with
- * a greeting.
+ * For the github templates, also strips leading greetings from every scene's
+ * narration — the cover scene already opens with a greeting.
  */
-const isGithubBoardTemplate = (t: string) => t === "github-trending" || t === "github-weekly";
+const isGithubTemplate = (t: string) =>
+  t === "github-trending" ||
+  t === "github-weekly" ||
+  t === "github-daily-pick" ||
+  t === "github-weekly-recap";
+
+/** Per-template narration budget: the daily deep-dive gets a deeper allowance
+ *  (prompt asks for 160-230 chars; 260 leaves clip headroom, schema caps at 280). */
+const narrationMax = (t: string) => (t === "github-daily-pick" ? 260 : 200);
+
+/**
+ * True when NO scene carries the structured github object. github board
+ * templates render EVERY content scene from its structured github block
+ * (extension). An LLM output where no scene carries one (e.g. it produced only
+ * overview scenes) would leave the document cover-only after assembly —
+ * checked by the generate layer so it can retry with a structure nudge.
+ */
+export function isMissingGithubScenes(input: unknown): boolean {
+  if (!input || typeof input !== "object") return true;
+  const scenes = (input as any).scenes;
+  if (!Array.isArray(scenes)) return true;
+  return !scenes.some((s: any) => s && typeof s === "object" && s.github && typeof s.github === "object");
+}
 
 export function applyLengthLimits(input: unknown, template: string): unknown {
   if (!input || typeof input !== "object") return input;
@@ -32,13 +60,19 @@ export function applyLengthLimits(input: unknown, template: string): unknown {
     : input;
   if (!Array.isArray((root as any).scenes)) return input;
 
-  (root as any).scenes = (root as any).scenes.map((scene: any) => {
+  (root as any).scenes = (root as any).scenes.map((scene: any, index: number) => {
     if (!scene || typeof scene !== "object") return scene;
     const next: any = { ...scene };
-    const narration = isGithubBoardTemplate(template)
+    // Some compatible LLMs omit the mechanically-required scene id even though
+    // the prompt includes it. Keep the contract strict while repairing this
+    // deterministic field before schema validation.
+    if (typeof next.id !== "string" || next.id.trim() === "") {
+      next.id = `scene-${index + 1}`;
+    }
+    const narration = isGithubTemplate(template)
       ? stripLeadingGreeting(scene.narration)
       : scene.narration;
-    next.narration = clipToLength(narration, 200);
+    next.narration = clipToLength(narration, narrationMax(template));
     if (scene.github && typeof scene.github === "object") {
       next.github = {
         ...scene.github,
@@ -50,7 +84,7 @@ export function applyLengthLimits(input: unknown, template: string): unknown {
     return next;
   });
 
-  if (isGithubBoardTemplate(template)) {
+  if (isGithubTemplate(template)) {
     if (Array.isArray((root as any).coverTags)) {
       (root as any).coverTags = (root as any).coverTags
         .filter((t: any) => typeof t === "string" && t.trim())

+ 68 - 0
packages/text/test/postprocess.test.ts

@@ -0,0 +1,68 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+  applyLengthLimits,
+  isMissingGithubScenes,
+} from "../src/postprocess.ts";
+
+// --- applyLengthLimits: scene id repair ---
+
+test("applyLengthLimits fills missing scene id deterministically", () => {
+  const input = { scenes: [{ narration: "a" }, { id: "", narration: "b" }] };
+  const out = applyLengthLimits(input, "news") as any;
+  assert.equal(out.scenes[0].id, "scene-1");
+  assert.equal(out.scenes[1].id, "scene-2");
+});
+
+test("applyLengthLimits keeps existing scene ids untouched", () => {
+  const input = { scenes: [{ id: "my-scene", narration: "a" }] };
+  const out = applyLengthLimits(input, "news") as any;
+  assert.equal(out.scenes[0].id, "my-scene");
+});
+
+// --- isMissingGithubScenes: github board structure detection ---
+// Regression guard: the detection used to be an object flag that
+// applyLengthLimits deleted before the caller could read it (dead retry path).
+
+test("isMissingGithubScenes: true when no scene carries a github object", () => {
+  const input = {
+    scenes: [
+      { id: "s1", title: "overview", narration: "..." },
+      { id: "s2", title: "another overview", narration: "..." },
+    ],
+  };
+  assert.equal(isMissingGithubScenes(applyLengthLimits(input, "github-trending")), true);
+});
+
+test("isMissingGithubScenes: false when at least one scene has github block", () => {
+  const input = {
+    scenes: [
+      { id: "s1", narration: "...", github: { repo: {}, highlights: "x", intro: "y", review: "z" } },
+      { id: "s2", narration: "..." },
+    ],
+  };
+  assert.equal(isMissingGithubScenes(applyLengthLimits(input, "github-trending")), false);
+});
+
+test("isMissingGithubScenes: true for non-object / missing scenes input", () => {
+  assert.equal(isMissingGithubScenes(undefined), true);
+  assert.equal(isMissingGithubScenes({}), true);
+  assert.equal(isMissingGithubScenes({ scenes: [] }), true);
+});
+
+test("isMissingGithubScenes ignores non-github templates' shape via caller", () => {
+  // Detection itself is template-agnostic; the generate layer gates it on
+  // github-trending/github-weekly only. Here we just pin the pure behavior.
+  const input = { scenes: [{ narration: "plain news scene" }] };
+  assert.equal(isMissingGithubScenes(input), true);
+});
+
+// --- applyLengthLimits must not leak internal markers into output ---
+
+test("applyLengthLimits output contains no internal marker keys", () => {
+  const input = {
+    scenes: [{ narration: "overview only, no github blocks here" }],
+  };
+  const out = applyLengthLimits(input, "github-weekly") as any;
+  assert.equal(Object.hasOwn(out, "__missingGithubScenes"), false);
+});

+ 15 - 0
packages/text/tsconfig.test.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": ".",
+    "types": ["node"],
+    "allowImportingTsExtensions": true,
+    "noEmit": true
+  },
+  "include": ["test", "src/postprocess.ts"],
+  "references": [
+    { "path": "../shared" },
+    { "path": "../collect" }
+  ]
+}