feat(mt-add-image): confirm image label from source post candidates

ByteByteGo images carry no caption/alt and image order cannot be mapped to
titles automatically (sponsor and video items interleave). Extract the source
post's TOC topic titles as candidates and let the user pick the matching label
(caption is used directly when a publication provides one; typing is always
available). Validated against newsletters 101-106: the correct title appears
in the candidate set for all 8 images.
This commit is contained in:
2026-05-30 17:23:10 +07:00
parent c9fcb1a0b1
commit bbd1125702
2 changed files with 40 additions and 6 deletions
+14 -6
View File
@@ -28,13 +28,21 @@ Find the source post:
```bash
node .claude/skills/mt-add-image/scripts/find-substack-post.js --uuid <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 <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 `<figcaption>`), 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)
@@ -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 = /<li[^>]*>\s*(?:<p[^>]*>)?([\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 <figure>…</figure>, return that figure's
// <figcaption> text. Cover images live in <enclosure> (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;