feat(landing): human-in-the-loop tutorial sync + contribute CTA (#4038)

* feat(landing): human-in-the-loop tutorial sync + contribute CTA

Two tutorials-page improvements:

1. Move the daily YouTube sync to a human review step in Feishu. The cron no
   longer generates entries or opens PRs on its own; notify-candidates.ts posts
   a numbered candidate digest (after dedupe + the LLM relevance gate) to a
   Feishu webhook, a maintainer replies which to publish, and
   generate-selected.ts turns the approved ids into entries for a PR. Splits the
   old fetch script into youtube.ts (API client) + notify/generate entries.

2. Add a 'Contribute a tutorial' CTA to the tutorials index that opens GitHub's
   create-file UI prefilled with a frontmatter template, so anyone can propose a
   tutorial via pull request. New i18n keys (en + zh + zh-tw; others fall back
   to en).

* feat(landing): friendlier tutorial contribution via issue form, surfaced higher

Review feedback on the contribute entry point:
- Submitting via a prefilled markdown file was intimidating. Add a GitHub issue
  form (tutorial-submission.yml) — paste a video link, pick a category, done —
  as the primary path; keep the prefilled-PR link as a secondary option for
  contributors who prefer a pull request.
- Move the CTA from the very bottom to directly under the masthead and restyle
  it as a compact, coral-accented bar so it is seen without scrolling past the
  whole list.

Also address PR review:
- notify-candidates: treat Feishu's legacy non-zero StatusCode (returned on an
  HTTP 200) as a failure, not just the newer code field.
- generate-selected: report selected ids that were not generated (unparseable
  args, videos not found on YouTube, write failures) and exit non-zero, so an
  approved selection can't be silently dropped.

---------

Co-authored-by: Joey <276262049+xne998808-ai@users.noreply.github.com>
This commit is contained in:
xne998808-ai
2026-06-10 14:13:21 +08:00
committed by GitHub
parent 6869b1208b
commit 4ee0cf8efd
8 changed files with 507 additions and 163 deletions

View File

@@ -0,0 +1,59 @@
name: 📺 Submit a tutorial
description: Share an Open Design tutorial video to be featured on open-design.ai/tutorials.
title: "[Tutorial]: "
labels: ["tutorials"]
body:
- type: markdown
attributes:
value: |
Thanks for sharing! Made or found a great Open Design tutorial on YouTube? Fill in the video link below — that's the only required field. A maintainer will review it and add it to the tutorials page. No pull request or markdown needed.
- type: input
id: video_url
attributes:
label: YouTube video URL
description: Paste the full link, e.g. https://www.youtube.com/watch?v=XXXXXXXXXXX
placeholder: https://www.youtube.com/watch?v=...
validations:
required: true
- type: input
id: author
attributes:
label: Creator / channel name
description: Who made the video? Leave blank to use the YouTube channel name.
placeholder: e.g. Your channel name
validations:
required: false
- type: dropdown
id: category
attributes:
label: Category
description: Pick the closest fit (optional — a maintainer will confirm).
options:
- Getting started
- Tutorial
- Demo
- Review
- Community
default: 1
validations:
required: false
- type: textarea
id: summary
attributes:
label: What does it cover?
description: One or two sentences on what the video shows (optional).
placeholder: A quick walkthrough of installing Open Design and shipping a first design.
validations:
required: false
- type: checkboxes
id: confirm
attributes:
label: Before you submit
options:
- label: This video is actually about Open Design (nexu-io/open-design), not a different product.
required: true

View File

@@ -2,8 +2,11 @@ name: tutorials-youtube-sync
on:
# Daily sweep for new community YouTube tutorials about Open Design. New
# entries are written to apps/landing-page/app/content/tutorials and opened
# as a pull request for maintainer review (never pushed straight to main).
# candidates are posted to Feishu for a maintainer to review — this workflow
# does NOT generate entries or open PRs. A maintainer replies with which
# candidates to publish, and the selected videos are turned into entries via
# scripts/youtube-tutorials/generate-selected.ts (run by a maintainer, who
# opens the PR).
schedule:
- cron: '30 2 * * *'
# Manual trigger: force a sweep outside the daily schedule, optionally with a
@@ -23,8 +26,8 @@ concurrency:
cancel-in-progress: true
jobs:
sync:
name: Sync YouTube tutorials
notify:
name: Post tutorial candidates to Feishu
if: github.repository == 'nexu-io/open-design'
runs-on: ubuntu-latest
@@ -37,61 +40,15 @@ jobs:
with:
node-version: '24'
- name: Generate tutorial entries from YouTube
- name: Discover candidates and post Feishu digest
working-directory: apps/landing-page
env:
YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_AUTH_TOKEN: ${{ secrets.ANTHROPIC_AUTH_TOKEN }}
ANTHROPIC_BASE_URL: ${{ secrets.ANTHROPIC_BASE_URL }}
FEISHU_TUTORIALS_WEBHOOK: ${{ secrets.FEISHU_TUTORIALS_WEBHOOK }}
FEISHU_TUTORIALS_SECRET: ${{ secrets.FEISHU_TUTORIALS_SECRET }}
run: |
npx --yes tsx@4.22.3 scripts/youtube-tutorials/fetch-youtube-tutorials.ts \
npx --yes tsx@4.22.3 scripts/youtube-tutorials/notify-candidates.ts \
--days "${{ github.event.inputs.days || '14' }}"
- name: Check for new entries
id: diff
run: |
if git status --porcelain apps/landing-page/app/content/tutorials | grep -q .; then
echo "changed=true" >> "$GITHUB_OUTPUT"
ADDED="$(git status --porcelain apps/landing-page/app/content/tutorials | grep -c '^??' || true)"
echo "count=$ADDED" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No new tutorials found."
fi
- name: Generate Open Design bot token
if: steps.diff.outputs.changed == 'true'
id: open-design-bot-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
owner: nexu-io
repositories: open-design
permission-contents: write
permission-pull-requests: write
- name: Create tutorials pull request
if: steps.diff.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ steps.open-design-bot-token.outputs.token }}
add-paths: |
apps/landing-page/app/content/tutorials/*.md
branch: automation/tutorials-youtube-sync
delete-branch: true
commit-message: 'content(landing): add new community YouTube tutorials'
author: 'open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>'
committer: 'open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>'
title: 'content(landing): sync ${{ steps.diff.outputs.count }} new YouTube tutorial(s)'
body: |
Adds newly discovered community YouTube tutorials about Open Design to
`apps/landing-page/app/content/tutorials/`.
Each entry was found via the YouTube Data API, passed an LLM relevance
gate (filters out lookalike products and generic AI roundups), and had
its summary, body, and category generated by the same gate model.
Generated by the scheduled `tutorials-youtube-sync` workflow. Please
review the copy and relevance of each new entry before merging.

View File

@@ -914,6 +914,10 @@ export interface LandingUiCopy {
noEntries: string;
suggestVideo: string;
noCategory: string;
contributeTitle: string;
contributeBody: string;
contributeCta: string;
contributeSuggest: string;
thumbnailAlt: (title: string) => string;
detailTitle: (title: string) => string;
localizedTitle: (title: string, author: string) => string;
@@ -3433,6 +3437,11 @@ const LANDING_UI_COPY: LandingUiCopy = {
noEntries: "No tutorials yet. We're curating the first batch — check back soon, or",
suggestVideo: 'suggest a video',
noCategory: 'No tutorials in this category yet. More are on the way.',
contributeTitle: 'Made an Open Design tutorial?',
contributeBody:
'Share your video — just paste a link in a quick form and a maintainer will add it to this page.',
contributeCta: 'Submit a tutorial ↗',
contributeSuggest: 'Prefer a pull request? ↗',
thumbnailAlt: (title) => `Thumbnail for ${title}`,
detailTitle: (title) => `${title} — Open Design Tutorials`,
localizedTitle: (title) => title,
@@ -3719,6 +3728,11 @@ const LANDING_UI_COPY_OVERRIDES: Partial<
noEntries: '暂时还没有教程。我们正在整理第一批视频,请稍后再来,或者',
suggestVideo: '推荐一个视频',
noCategory: '这个分类还没有教程,更多内容正在整理。',
contributeTitle: '做过 Open Design 的教程?',
contributeBody:
'把你的视频分享给社区——在简单的表单里贴个链接就行,维护者会把它加到这个页面。',
contributeCta: '提交一个教程 ↗',
contributeSuggest: '想直接提 PR↗',
thumbnailAlt: (title) => `${title} 的视频封面`,
detailTitle: (title) => `${title} — Open Design 教程`,
localizedTitle: (_title, author) => `Open Design 教程:${author}`,
@@ -3987,6 +4001,11 @@ const LANDING_UI_COPY_OVERRIDES: Partial<
noEntries: '暫時還沒有教學。我們正在整理第一批影片,請稍後再來,或者',
suggestVideo: '推薦一支影片',
noCategory: '這個分類還沒有教學,更多內容正在整理。',
contributeTitle: '做過 Open Design 的教學?',
contributeBody:
'把你的影片分享給社群——在簡單的表單裡貼個連結就行,維護者會把它加到這個頁面。',
contributeCta: '提交一個教學 ↗',
contributeSuggest: '想直接提 PR↗',
thumbnailAlt: (title) => `${title} 的影片封面`,
detailTitle: (title) => `${title} — Open Design 教學`,
localizedTitle: (_title, author) => `Open Design 教學:${author}`,

View File

@@ -114,6 +114,36 @@ const ytThumb = (id: string) => `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
const featuredTutorial = tutorials[0];
const cardTutorials = tutorials.slice(1);
const featuredCopy = featuredTutorial ? localizedTutorial(featuredTutorial) : undefined;
// Contribute flow. Primary path is a friendly GitHub issue form (just paste a
// video link — no markdown). A secondary link opens GitHub's create-file UI
// prefilled with a frontmatter template for contributors who prefer a PR.
const submitIssueUrl =
'https://github.com/nexu-io/open-design/issues/new?template=tutorial-submission.yml';
const contributeTemplate = `---
title: 'Your video title'
youtubeId: XXXXXXXXXXX
summary: 'One sentence describing what the video covers.'
date: 2026-01-01
category: Tutorial
durationSeconds: 600
author: 'Your channel name'
official: false
---
A short paragraph or a few bullet points about the tutorial.
<!--
category must be one of: Getting started | Tutorial | Demo | Review | Community
youtubeId is the 11-character id from the video URL (youtube.com/watch?v=THIS)
durationSeconds is the video length in seconds
Rename this file to something descriptive, e.g. open-design-quickstart-yourname.md
-->
`;
const contributePrUrl =
'https://github.com/nexu-io/open-design/new/main' +
'?filename=apps/landing-page/app/content/tutorials/my-open-design-tutorial.md' +
`&value=${encodeURIComponent(contributeTemplate)}`;
---
<!doctype html>
@@ -146,6 +176,35 @@ const featuredCopy = featuredTutorial ? localizedTutorial(featuredTutorial) : un
</div>
</section>
<section class='tut-contribute'>
<div class='container'>
<div class='tut-contribute-bar'>
<div class='tut-contribute-copy'>
<span class='tut-contribute-title'>{ui.tutorials.contributeTitle}</span>
<span class='tut-contribute-body'>{ui.tutorials.contributeBody}</span>
</div>
<div class='tut-contribute-actions'>
<a
class='tut-contribute-cta'
href={submitIssueUrl}
target='_blank'
rel='noreferrer noopener'
>
{ui.tutorials.contributeCta}
</a>
<a
class='tut-contribute-suggest'
href={contributePrUrl}
target='_blank'
rel='noreferrer noopener'
>
{ui.tutorials.contributeSuggest}
</a>
</div>
</div>
</div>
</section>
{
featuredTutorial && featuredCopy && (
<section
@@ -614,6 +673,83 @@ const featuredCopy = featuredTutorial ? localizedTutorial(featuredTutorial) : un
.tut-empty.is-filter-empty {
border-top: 1px solid var(--line);
}
.tut-contribute {
padding: 14px 0 2px;
}
.tut-contribute-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px 28px;
padding: 16px 22px;
border: 1px solid var(--line);
border-left: 3px solid var(--coral);
background: rgba(247, 241, 222, 0.6);
}
.tut-contribute-copy {
min-width: 0;
flex: 1 1 380px;
display: flex;
flex-direction: column;
gap: 3px;
}
.tut-contribute-title {
font-family: var(--sans);
font-weight: 700;
font-size: 16px;
letter-spacing: -0.01em;
color: var(--ink);
}
.tut-contribute-body {
font-family: var(--body);
font-size: 13.5px;
line-height: 1.45;
color: var(--ink-mute);
max-width: 72ch;
}
.tut-contribute-actions {
display: flex;
align-items: center;
gap: 16px;
}
.tut-contribute-cta {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 9px 20px;
border: 1px solid var(--ink);
background: var(--ink);
color: var(--paper);
font-family: var(--sans);
font-size: 13.5px;
font-weight: 600;
letter-spacing: 0.01em;
text-decoration: none;
white-space: nowrap;
transition: background 160ms ease, border-color 160ms ease;
}
.tut-contribute-cta:hover {
background: var(--coral);
border-color: var(--coral);
}
.tut-contribute-suggest {
font-family: var(--sans);
font-size: 12.5px;
color: var(--ink-mute);
text-decoration: none;
white-space: nowrap;
transition: color 160ms ease;
}
.tut-contribute-suggest:hover {
color: var(--coral);
}
@media (max-width: 600px) {
.tut-contribute-actions {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 980px) {
.tut-feature-card {
grid-template-columns: 1fr;

View File

@@ -1,46 +1,68 @@
# youtube-tutorials
Keeps `app/content/tutorials/*.md` in sync with the latest community YouTube
tutorials about Open Design.
tutorials about Open Design, with a human in the loop.
## Flow
```
daily cron (GitHub Actions)
notify-candidates.ts
→ YouTube Data API search (last N days)
→ drop already-catalogued videos
→ LLM relevance gate (reject lookalikes / roundups)
→ post a numbered digest to Feishu
maintainer reviews the digest in Feishu and replies which numbers to publish
generate-selected.ts (run by the maintainer / agent)
→ fetch the approved video ids
→ LLM-generate summary + body + category in each video's language
→ write *.md → open a pull request
```
The cron **never** generates entries or opens PRs on its own — selection is the
human review step, done in Feishu before any content is written.
## Files
- `lib.ts` — shared core: relevance gate, LLM copy generation, slug rules,
markdown writer, existing-id/slug readers.
- `fetch-youtube-tutorials.ts` — daily cron entry. Queries the YouTube Data API
v3, filters out videos already in the catalogue and lookalike products, and
writes new entries. Run by `.github/workflows/tutorials-youtube-sync.yml`.
- `youtube.ts` — YouTube Data API v3 client: key loading, candidate discovery
(`fetchCandidates`), and id lookup (`fetchByIds`).
- `notify-candidates.ts` — daily cron entry; posts the candidate digest to
Feishu. Run by `.github/workflows/tutorials-youtube-sync.yml`.
- `generate-selected.ts` — turns approved video ids/URLs into entries.
- `backfill-tutorials.ts` — one-off importer that reads pre-fetched `yt-dlp -j`
JSON lines instead of the API (used for the initial backfill).
JSON lines (used for the initial backfill).
## How it stays accurate
## Why the relevance gate
A YouTube search for "open design" surfaces many lookalikes (OpenCode,
OpenClaude, a separate small "Open Codesign" repo, generic AI-agent roundups,
and unrelated tools whose title merely mentions "Claude Design"). Titles alone
are not enough, so every candidate passes an **LLM relevance gate**
(`isAboutOpenDesign`) that confirms the video is specifically about nexu-io's
Open Design before any entry is generated. The same model then writes the
summary, body, and category in the video's own language.
and videos that only mention "Claude Design" in passing). Titles alone are not
enough, so every candidate passes an LLM relevance gate (`isAboutOpenDesign`)
before it ever reaches the digest.
## Secrets / env
| Var | Where | Purpose |
| --- | --- | --- |
| `YOUTUBE_API_KEY` | repo secret + `~/.youtube/.env` locally | YouTube Data API v3 search/videos |
| `ANTHROPIC_AUTH_TOKEN` (or `ANTHROPIC_API_KEY`) + `ANTHROPIC_BASE_URL` | repo secret + local env | LLM relevance gate + copy generation (Claude Haiku) |
| `BOT_APP_ID` + `BOT_APP_PRIVATE_KEY` | existing repo secrets | open-design-bot token used to open the PR |
The workflow opens a pull request for maintainer review; it never pushes to
`main`.
| `YOUTUBE_API_KEY` | repo secret + `~/.youtube/.env` | YouTube Data API v3 |
| `ANTHROPIC_AUTH_TOKEN` (or `ANTHROPIC_API_KEY`) + `ANTHROPIC_BASE_URL` | repo secret + local env | relevance gate + copy generation (Claude Haiku) |
| `FEISHU_TUTORIALS_WEBHOOK` | repo secret | Feishu custom-bot incoming webhook for the digest |
| `FEISHU_TUTORIALS_SECRET` | repo secret (optional) | only if the Feishu bot has signature verification on |
## Manual runs
```bash
# Daily-style sweep via the API (needs YOUTUBE_API_KEY + ANTHROPIC_*)
npx tsx scripts/youtube-tutorials/fetch-youtube-tutorials.ts --days 14 [--dry-run]
# Reproduce the candidate digest locally (no Feishu post)
npx tsx scripts/youtube-tutorials/notify-candidates.ts --days 14 --print
# Backfill from a yt-dlp dump (needs ANTHROPIC_* only)
# Generate approved entries (ids or URLs), then open a PR with the new files
npx tsx scripts/youtube-tutorials/generate-selected.ts dQw4w9WgXcQ https://youtu.be/XXXXXXXXXXX
# Backfill from a yt-dlp dump
yt-dlp -a urls.txt --skip-download --cookies-from-browser chrome -j > videos.jsonl
npx tsx scripts/youtube-tutorials/backfill-tutorials.ts videos.jsonl [--dry-run] [--no-gate]
```

View File

@@ -0,0 +1,77 @@
/*
* generate-selected — turns a maintainer-approved set of YouTube videos into
* tutorial entries. Run after a human picks numbers from the Feishu digest
* produced by `notify-candidates.ts`.
*
* Usage:
* tsx scripts/youtube-tutorials/generate-selected.ts <id|url> [<id|url> ...]
*
* Accepts raw 11-char video ids or any YouTube URL. Already-present videos are
* skipped. The relevance gate is intentionally skipped here — selection is the
* human review step. Requires YOUTUBE_API_KEY + ANTHROPIC_* (copy generation).
*/
import {
readExistingSlugs,
readExistingVideoIds,
writeTutorial,
} from './lib.ts';
import { fetchByIds, loadYoutubeKey } from './youtube.ts';
function extractId(arg: string): string | null {
const trimmed = arg.trim();
if (/^[\w-]{11}$/.test(trimmed)) return trimmed;
const m = trimmed.match(/(?:v=|youtu\.be\/|embed\/|shorts\/)([\w-]{11})/);
return m ? m[1] : null;
}
async function main(): Promise<void> {
const args = process.argv.slice(2).filter((a) => !a.startsWith('--'));
if (args.length === 0) {
console.error('Usage: tsx generate-selected.ts <id|url> [<id|url> ...]');
process.exit(1);
}
// Keep unparseable args visible — a pasted typo must not silently disappear.
const unparseable = args.filter((a) => !extractId(a));
const ids = [...new Set(args.map(extractId).filter((v): v is string => Boolean(v)))];
const key = await loadYoutubeKey();
const existingIds = await readExistingVideoIds();
const takenSlugs = await readExistingSlugs();
const fresh = ids.filter((id) => !existingIds.has(id));
const skipped = ids.filter((id) => existingIds.has(id));
if (skipped.length) console.log(`Already in catalogue (ok, skipped): ${skipped.join(', ')}`);
const videos = await fetchByIds(key, fresh);
// Videos requested but not returned (deleted, private, region-locked, or a bad
// id) — these are approved selections that would otherwise vanish silently.
const returnedIds = new Set(videos.map((v) => v.videoId));
const notFound = fresh.filter((id) => !returnedIds.has(id));
console.log(`Generating ${videos.length} entr(y/ies)`);
let ok = 0;
const writeFailed: string[] = [];
for (const v of videos) {
try {
const slug = await writeTutorial(v, takenSlugs);
ok++;
console.log(` + ${slug} <- ${v.videoId} (${v.author})`);
} catch (e) {
writeFailed.push(v.videoId);
console.error(` ! write failed ${v.videoId}: ${(e as Error).message}`);
}
}
console.log(`Done: ${ok} written, ${skipped.length} already present`);
const problems: string[] = [];
if (unparseable.length) problems.push(`unparseable args: ${unparseable.join(', ')}`);
if (notFound.length) problems.push(`not found on YouTube: ${notFound.join(', ')}`);
if (writeFailed.length) problems.push(`write failed: ${writeFailed.join(', ')}`);
if (problems.length) {
console.error(`\nNot generated — ${problems.join(' | ')}`);
process.exitCode = 2;
}
}
void main();

View File

@@ -0,0 +1,126 @@
/*
* notify-candidates — daily cron entry. Discovers new Open-Design tutorial
* candidates from YouTube, then posts a numbered digest to a Feishu (Lark)
* webhook for a human to review. It does NOT generate entries or open a PR:
* a maintainer replies with which numbers to publish, and the selected videos
* are turned into entries by `generate-selected.ts`.
*
* Usage:
* tsx scripts/youtube-tutorials/notify-candidates.ts [--days 14] [--print]
*
* Env:
* YOUTUBE_API_KEY YouTube Data API v3 (or ~/.youtube/.env)
* ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL relevance gate
* FEISHU_TUTORIALS_WEBHOOK Feishu custom-bot incoming webhook URL
* FEISHU_TUTORIALS_SECRET optional, if the bot has signing enabled
*
* --print skips Feishu and writes the digest to stdout (used locally to
* reproduce the candidate numbering before generating selected entries).
*/
import { createHmac } from 'node:crypto';
import { readExistingVideoIds, type VideoInput } from './lib.ts';
import { fetchCandidates, loadYoutubeKey } from './youtube.ts';
function fmtViews(n?: number): string {
if (!n) return '';
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
return String(n);
}
function fmtDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function buildDigest(candidates: VideoInput[], today: string): string {
const lines: string[] = [];
lines.push(`📺 Open Design 教程候选 · ${today} · 共 ${candidates.length} 条待审`);
lines.push('');
candidates.forEach((v, i) => {
const meta = [v.author, v.date, fmtViews(v.viewCount) && `${fmtViews(v.viewCount)} 次观看`, fmtDuration(v.durationSeconds)]
.filter(Boolean)
.join(' · ');
lines.push(`[${i + 1}] ${v.title}`);
lines.push(` ${meta}`);
lines.push(` https://youtu.be/${v.videoId}`);
});
lines.push('');
lines.push('回复指令(发给 Claude):');
lines.push('• 上架 1 3 5 只上这几条');
lines.push('• 全上 / 全不上');
lines.push('• 全上 除 2 4 除这几条其余都上');
lines.push('');
lines.push('(已自动过滤:已收录的 + 经 LLM 闸门判定非 Open Design 的内容)');
return lines.join('\n');
}
async function postToFeishu(webhook: string, secret: string | undefined, text: string): Promise<void> {
const body: Record<string, unknown> = { msg_type: 'text', content: { text } };
if (secret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const sign = createHmac('sha256', `${timestamp}\n${secret}`).update('').digest('base64');
body.timestamp = timestamp;
body.sign = sign;
}
const res = await fetch(webhook, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => ({}))) as { code?: number; msg?: string; StatusCode?: number };
// Feishu signals failure with a non-zero `code` (new format) OR a non-zero
// `StatusCode` (legacy format), both returned on an HTTP 200. Treat either as
// a failure so a digest that never reached the group does not look posted.
const failed = !res.ok || (json.code != null && json.code !== 0) || (json.StatusCode != null && json.StatusCode !== 0);
if (failed) {
throw new Error(`Feishu webhook failed: HTTP ${res.status} ${JSON.stringify(json).slice(0, 200)}`);
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const printOnly = args.includes('--print');
const daysIdx = args.indexOf('--days');
const days = daysIdx !== -1 ? Number(args[daysIdx + 1]) : 14;
const key = await loadYoutubeKey();
const existing = await readExistingVideoIds();
const { candidates, searchFailures, queryCount } = await fetchCandidates(key, days, existing);
if (searchFailures === queryCount) {
console.error(`All ${queryCount} search queries failed; aborting.`);
process.exitCode = 1;
return;
}
console.log(`${candidates.length} candidate(s) after dedupe + relevance gate (last ${days}d)`);
// Stamp the date from the publishedAfter window's "now" without Date APIs in
// the digest body? We need a date string for the header; derive from newest
// candidate or fall back to a generic label.
const today = candidates[0]?.date ?? new Date().toISOString().slice(0, 10);
const digest = buildDigest(candidates, today);
if (printOnly) {
console.log('\n' + digest);
return;
}
if (candidates.length === 0) {
console.log('No new candidates; skipping Feishu post.');
return;
}
const webhook = process.env.FEISHU_TUTORIALS_WEBHOOK;
if (!webhook) {
console.error('Missing FEISHU_TUTORIALS_WEBHOOK; printing digest instead:\n');
console.log(digest);
process.exitCode = 1;
return;
}
await postToFeishu(webhook, process.env.FEISHU_TUTORIALS_SECRET, digest);
console.log('Posted candidate digest to Feishu.');
}
void main();

View File

@@ -1,39 +1,20 @@
/*
* fetch-youtube-tutorials daily cron entry. Queries the YouTube Data API v3
* for recent community tutorials about Open Design, filters out videos already
* in the catalogue and lookalike products, then writes new `*.md` entries using
* the shared LLM copy generator.
*
* Usage:
* tsx scripts/youtube-tutorials/fetch-youtube-tutorials.ts [--days 14] [--dry-run]
*
* Env:
* YOUTUBE_API_KEY required (or ~/.youtube/.env)
* ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY + ANTHROPIC_BASE_URL for copy gen
* TUTORIALS_QUERIES optional, '||'-separated search queries (override default)
*
* The GitHub Actions workflow runs this, then opens a PR if new files appear.
* youtube-tutorials/youtube YouTube Data API v3 client shared by the daily
* notifier (candidate discovery) and the selected-entry generator.
*/
import { readFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
isAboutOpenDesign,
iso8601ToSeconds,
readExistingSlugs,
readExistingVideoIds,
writeTutorial,
type VideoInput,
} from './lib.ts';
import { isAboutOpenDesign, iso8601ToSeconds, type VideoInput } from './lib.ts';
const DEFAULT_QUERIES = [
export const DEFAULT_QUERIES = [
'open design open source claude design alternative',
'open design ai design agent github',
'open design nexu io design agent',
'open design 开源 设计 agent claude',
];
async function loadYoutubeKey(): Promise<string> {
export async function loadYoutubeKey(): Promise<string> {
if (process.env.YOUTUBE_API_KEY) return process.env.YOUTUBE_API_KEY;
try {
const raw = await readFile(path.join(os.homedir(), '.youtube', '.env'), 'utf8');
@@ -82,8 +63,8 @@ async function searchVideoIds(query: string, publishedAfter: string, key: string
return data.items.map((i) => i.id?.videoId).filter((v): v is string => Boolean(v));
}
async function fetchVideoDetails(ids: string[], key: string): Promise<VideoItem[]> {
const out: VideoItem[] = [];
export async function fetchVideoDetails(ids: string[], key: string): Promise<VideoInput[]> {
const out: VideoInput[] = [];
for (let i = 0; i < ids.length; i += 50) {
const batch = ids.slice(i, i + 50);
const data = await ytGet<{ items: VideoItem[] }>(
@@ -91,7 +72,7 @@ async function fetchVideoDetails(ids: string[], key: string): Promise<VideoItem[
{ part: 'snippet,contentDetails,statistics', id: batch.join(',') },
key,
);
out.push(...data.items);
out.push(...data.items.map(toVideoInput));
}
return out;
}
@@ -123,20 +104,24 @@ async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise
return out;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const daysIdx = args.indexOf('--days');
const days = daysIdx !== -1 ? Number(args[daysIdx + 1]) : 14;
export interface CandidateResult {
candidates: VideoInput[];
searchFailures: number;
queryCount: number;
}
const key = await loadYoutubeKey();
const queries = process.env.TUTORIALS_QUERIES
? process.env.TUTORIALS_QUERIES.split('||').map((q) => q.trim()).filter(Boolean)
: DEFAULT_QUERIES;
// publishedAfter window. Date.now is acceptable here (no resume semantics).
/**
* Discover Open-Design-relevant tutorial candidates from the last `days`,
* already filtered against the existing catalogue (caller passes known ids) and
* the LLM relevance gate. Sorted by date descending so numbering is stable.
*/
export async function fetchCandidates(
key: string,
days: number,
existingIds: Set<string>,
queries: string[] = DEFAULT_QUERIES,
): Promise<CandidateResult> {
const publishedAfter = new Date(Date.now() - days * 86400_000).toISOString();
const idSet = new Set<string>();
let searchFailures = 0;
for (const q of queries) {
@@ -147,57 +132,20 @@ async function main(): Promise<void> {
console.error(`search failed for "${q}": ${(e as Error).message}`);
}
}
console.log(`Found ${idSet.size} unique video ids across ${queries.length} queries (last ${days}d)`);
const fresh = [...idSet].filter((id) => !existingIds.has(id));
if (fresh.length === 0) return { candidates: [], searchFailures, queryCount: queries.length };
// Fail loud instead of drifting silently: if every query errored (e.g. an API
// outage or bad key), an empty result set is indistinguishable from "nothing
// new". Exit non-zero so the scheduled run is observably red, not falsely green.
if (searchFailures === queries.length) {
console.error(`All ${queries.length} search queries failed; aborting without writing.`);
process.exitCode = 1;
return;
}
const existingIds = await readExistingVideoIds();
const candidateIds = [...idSet].filter((id) => !existingIds.has(id));
console.log(`${candidateIds.length} not yet in catalogue`);
if (candidateIds.length === 0) return;
const details = await fetchVideoDetails(candidateIds, key);
const videos = details.map(toVideoInput);
// Relevance gate, then generate.
const videos = await fetchVideoDetails(fresh, key);
const gated = await mapPool(videos, 4, async (v) => ({ v, ok: await isAboutOpenDesign(v) }));
const kept = gated.filter((r) => r.ok).map((r) => r.v);
for (const r of gated.filter((r) => !r.ok)) {
console.log(` - rejected (not Open Design): ${r.v.videoId} | ${r.v.author} | ${r.v.title}`);
}
console.log(`Gate: ${kept.length} relevant, ${gated.length - kept.length} rejected`);
if (dryRun) {
for (const v of kept) console.log(` would add: ${v.videoId} | ${v.date} | ${v.author} | ${v.title}`);
return;
}
const takenSlugs = await readExistingSlugs();
let ok = 0;
let failed = 0;
await mapPool(kept, 4, async (v) => {
try {
const slug = await writeTutorial(v, takenSlugs);
ok++;
console.log(` + ${slug} <- ${v.videoId} (${v.author})`);
} catch (e) {
failed++;
console.error(` ! failed ${v.videoId}: ${(e as Error).message}`);
}
});
console.log(`Done: ${ok}/${kept.length} new tutorials written, ${failed} failed`);
// A kept (relevant) video that cannot be written would otherwise vanish: the
// git-status PR step only sees the files that did land. Exit non-zero so a
// partial sync surfaces, mirroring the backfill script's exit 2.
if (failed > 0) process.exitCode = 2;
const candidates = gated
.filter((r) => r.ok)
.map((r) => r.v)
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
return { candidates, searchFailures, queryCount: queries.length };
}
void main();
/** Fetch specific videos by id (for generating maintainer-approved entries). */
export async function fetchByIds(key: string, ids: string[]): Promise<VideoInput[]> {
if (ids.length === 0) return [];
return fetchVideoDetails(ids, key);
}