mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-03 12:27:55 +08:00
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:
59
.github/ISSUE_TEMPLATE/tutorial-submission.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/tutorial-submission.yml
vendored
Normal 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
|
||||
65
.github/workflows/tutorials-youtube-sync.yml
vendored
65
.github/workflows/tutorials-youtube-sync.yml
vendored
@@ -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.
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
```
|
||||
|
||||
@@ -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();
|
||||
126
apps/landing-page/scripts/youtube-tutorials/notify-candidates.ts
Normal file
126
apps/landing-page/scripts/youtube-tutorials/notify-candidates.ts
Normal 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();
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user