diff --git a/.claude/skills/mt-add-image/SKILL.md b/.claude/skills/mt-add-image/SKILL.md index f4feef2..83a8834 100644 --- a/.claude/skills/mt-add-image/SKILL.md +++ b/.claude/skills/mt-add-image/SKILL.md @@ -28,13 +28,21 @@ Find the source post: ```bash node .claude/skills/mt-add-image/scripts/find-substack-post.js --uuid ``` -- `found: true` → label = `caption` if non-empty, else `postTitle`. -- `found: false` → retry with the deeper sitemap crawl (slower — scans ~3 months, warn the user it may take ~15s): +- `found: false` → retry with the deeper sitemap crawl (slower — scans ~3 months; warn the user it may take ~15s): ```bash node .claude/skills/mt-add-image/scripts/find-substack-post.js --uuid --deep ``` - - `found: true` → label as above. - - `found: false` → no automatic label; go to step 3 (ask). The result reports `scanned` + `cutoff` — mention how far back it looked. + The result reports `scanned` + `cutoff` — mention how far back it looked. +- `found: false` after `--deep` → no source post; go to step 3 (ask) and/or step 4 (add publication). + +**When `found: true` — pick the label (confirm-from-candidates):** +ByteByteGo doesn't attach captions to images (verified: empty `alt`, no `
`), and image order can't be mapped to titles automatically because sponsor/video items interleave. So the script returns `candidates` — the issue's topic titles from the post's TOC. Pick the label this way: +1. If `caption` is non-empty (some publications do caption images) → propose it as the default. +2. Otherwise present `candidates` to the user via `AskUserQuestion` (the correct title is in this list): + - "Image is from **[postTitle]** (`postUrl`). Which title matches it?" + - Offer the candidates. If there are more than 4, show them as a numbered list in text and ask the user to pick a number (AskUserQuestion allows max 4 options). + - Always include a "Type my own" escape. +3. Label = the user's pick (or the typed value). Localize to Vietnamese where natural; keep proper nouns. ### 2b. Non-Substack image (`isSubstack: false`) Best-effort only (no reverse-image-search): if you happen to know the page that contains the image, WebFetch it and read the OpenGraph title / nearby caption. Usually there's no containing page from just an image URL → go to step 3. @@ -58,13 +66,13 @@ Create `### Bonus` / `**Images:**` if missing. ``` ✅ Image added to Newsletter #[number] (Bonus → Images) 📄 content/post/YYYY/MM/DD/index.md -🏷️ label: "[label]" (source: rss | sitemap | opengraph | user-input) +🏷️ label: "[label]" (source: candidate-pick | caption | user-input; post: [postTitle]) 🖼️ [clean_image_url] ``` ## Checklist - [ ] Source detected (substack + uuid, or non-substack) -- [ ] Label resolved by priority (caption → post title → your input) +- [ ] Label confirmed (caption if present, else user picked from candidates, else typed) - [ ] Entry under Bonus → **Images** (section/subsection created if missing) - [ ] Label localized to Vietnamese where natural (keep proper nouns) diff --git a/.claude/skills/mt-add-image/scripts/find-substack-post.js b/.claude/skills/mt-add-image/scripts/find-substack-post.js index 88f0d78..c4380fb 100644 --- a/.claude/skills/mt-add-image/scripts/find-substack-post.js +++ b/.claude/skills/mt-add-image/scripts/find-substack-post.js @@ -76,6 +76,30 @@ function itemLink(item) { return m ? m[1].trim() : ""; } +// Extract candidate topic titles from a post's TOC bullet list. ByteByteGo does +// not attach captions to images — the topic titles live only in the "in this +// issue" bullets. We can't reliably map image→title automatically (sponsor / +// video items interleave and break positional order), so we surface these +// candidates for the user to pick from. Light filtering keeps the list short: +// dedupe, drop sub-point explanations ("Term: long sentence") and over-long +// lines. The correct title is always present; the user selects it. +function extractCandidates(html) { + const seen = new Set(); + const out = []; + const re = /]*>\s*(?:]*>)?([\s\S]*?)(?:<\/p>)?\s*<\/li>/g; + let m; + while ((m = re.exec(html))) { + const text = stripTags(m[1]); + if (!text) continue; + if (text.length < 6 || text.length > 70) continue; // titles are short; long lines are sub-point explanations + const key = text.toLowerCase(); + if (seen.has(key)) continue; // content is duplicated in the page + seen.add(key); + out.push(text); + } + return out; +} + // If the UUID sits inside a
, return that figure's //
text. Cover images live in (no figure) → "". function captionForUuid(item, id) { @@ -134,6 +158,7 @@ async function searchSitemap(publication, id, maxFetch = 40, monthsBack = 3) { postTitle: postTitleFromHtml(html), postUrl: c.url, caption: captionForUuid(html, id), + candidates: extractCandidates(html), }, scanned, cutoff: cutoff.toISOString().slice(0, 10), @@ -157,6 +182,7 @@ async function searchRss(publication, id) { postTitle, postUrl: itemLink(item), caption, + candidates: extractCandidates(item), }; } return null;