feat(skills): add mt-add-image handler with Substack source lookup

Add the image handler in the meta-dispatch architecture. Given an image
URL it resolves a label and inserts it into the newsletter Bonus -> Images.

For Substack-hosted images, decode the S3 image UUID and find the source
post across configured publications (ByteByteGo by default): RSS feed first,
then a sitemap crawl up to ~3 months back. Label priority is figure caption,
then post title, then a user-typed name. Non-Substack images are best-effort
then ask. Publications are configurable and the skill offers to add a new one
when the user identifies it.

Wire mt-add-url to dispatch the image route to mt-add-image (previously a
fallback). Update the project skills list.
This commit is contained in:
tiennm99 committed 2026-05-30 16:55:47 +07:00
1 parent f5fa315ea1
commit f40103fe82
6 files changed
+331 -5

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
---
name: mt-add-image
description: 'Image handler for the Hugo blog newsletter. Adds an image URL to today''s newsletter Bonus → Images with a detected label. For Substack-hosted images it finds the source post (ByteByteGo and other configured publications) via RSS then sitemap, using the figure caption or post title as the label; for other sites it is best-effort; if no label is found it asks you to type one. Normally invoked by the mt-add-url meta skill after classification, but can be used directly for a known image URL.'
---
## Overview
`mt-add-image` is the **image handler**: given an image URL, it resolves a human-readable **label** and inserts `![label](image_url)` into today's newsletter **Bonus → Images**. It does not classify/route — that is `mt-add-url`'s job.
Shared scripts: `.claude/skills/mt-add-url/scripts/`. Shared procedure: `../mt-add-url/references/newsletter-post-mechanics.md` — **follow it** for post find/create, Bonus insertion, and language rules.
**Label priority:** figure caption → source post title → your typed input.
## Input
A clean image URL (passed by `mt-add-url`, or given directly).
## Workflow
### 1. Detect source
```bash
node .claude/skills/mt-add-image/scripts/detect-image-source.js "<url>"
```
→ `{ isSubstack, uuid?, innerUrl? }`. Skip the URL if it's a duplicate/inaccessible (the meta skill already checks; if invoked directly, sanity-check first).
### 2a. Substack image (`isSubstack: true` with `uuid`)
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):
```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.
### 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.
### 3. Ask for a label (fallback)
If no label was detected, use `AskUserQuestion`:
- "Couldn't detect a name for this image (`<clean_url>`). What label should I use?" (free-text).
### 4. Add-publication offer (Substack miss only)
If a Substack image wasn't found and the user tells you which publication it's from, offer to append that host to `.claude/skills/mt-add-image/config/substack-publications.json`, then retry step 2a. This grows coverage for next time.
### 5. Insert into Bonus → Images
Follow `../mt-add-url/references/newsletter-post-mechanics.md` to find/create today's post, then add under **Images**:
```markdown
**Images:**
![label](clean_image_url)
```
Create `### Bonus` / `**Images:**` if missing.
### 6. Report
```
✅ Image added to Newsletter #[number] (Bonus → Images)
📄 content/post/YYYY/MM/DD/index.md
🏷️ label: "[label]" (source: rss | sitemap | opengraph | user-input)
🖼️ [clean_image_url]
```
## Checklist
- [ ] Source detected (substack + uuid, or non-substack)
- [ ] Label resolved by priority (caption → post title → your input)
- [ ] Entry under Bonus → **Images** (section/subsection created if missing)
- [ ] Label localized to Vietnamese where natural (keep proper nouns)
@@ -0,0 +1 @@
["blog.bytebytego.com"]
@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Detect whether an image URL is Substack-hosted and extract its S3 image UUID.
// Usage: node detect-image-source.js "<image-url>"
// Output: JSON { original_url, clean_url, isSubstack, uuid?, innerUrl? }
//
// Substack images are usually served via a CDN wrapper:
// https://substackcdn.com/image/fetch/$s_!x!,.../https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F<uuid>_WxH.png
// The publication is NOT encoded in the URL — only the image identity (uuid) is.
const path = require("path");
const { cleanUrl } = require("../../mt-add-url/scripts/url-utils.js");
const url = process.argv[2];
if (!url) {
console.error("Usage: node detect-image-source.js <image-url>");
process.exit(1);
}
const SUBSTACK_HOSTS = ["substackcdn.com", "substack-post-media.s3.amazonaws.com"];
// Pull the inner S3 URL out of a substackcdn /image/fetch/ wrapper (if present).
function extractInnerUrl(targetUrl) {
const marker = targetUrl.indexOf("/https%3A%2F%2F");
if (marker !== -1) {
return decodeURIComponent(targetUrl.slice(marker + 1));
}
// Some forms embed a plain (already-decoded) inner https URL.
const plain = targetUrl.indexOf("/https://", 8);
if (plain !== -1) return targetUrl.slice(plain + 1);
return targetUrl;
}
function detect(targetUrl) {
let host = "";
try {
host = new URL(targetUrl).host.toLowerCase();
} catch {
/* fall through — non-URL input is non-Substack */
}
const innerUrl = extractInnerUrl(targetUrl);
const isSubstack =
SUBSTACK_HOSTS.includes(host) || /substack-post-media/i.test(innerUrl);
// public/images/<uuid>_WxH.ext — uuid is the stable image identity.
const m = innerUrl.match(/public\/images\/([0-9a-fA-F-]{36})/);
const uuid = m ? m[1].toLowerCase() : undefined;
return { isSubstack, uuid, innerUrl: isSubstack ? innerUrl : undefined };
}
const cleaned = cleanUrl(url);
const { isSubstack, uuid, innerUrl } = detect(url);
console.log(JSON.stringify({
original_url: url,
clean_url: cleaned,
isSubstack,
...(uuid ? { uuid } : {}),
...(innerUrl ? { innerUrl } : {}),
}, null, 2));
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// Find which Substack post embeds a given image UUID, and extract a label.
// Usage: node find-substack-post.js --uuid <uuid> [--deep]
// Output: JSON { found, source?, publication?, postTitle?, postUrl?, caption?, scanned? }
//
// Strategy: RSS feed first (fast, ~recent weeks). With --deep, fall back to a
// sitemap crawl up to ~3 months back (added in the deep-lookup phase).
// A Substack CDN URL does not encode its publication, so we search each
// publication listed in config/substack-publications.json.
const fs = require("fs");
const path = require("path");
const args = process.argv.slice(2);
function flag(name) {
const i = args.indexOf(name);
return i !== -1 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
}
const uuid = flag("--uuid");
const deep = !!flag("--deep");
if (!uuid || uuid === true) {
console.error("Usage: node find-substack-post.js --uuid <uuid> [--deep]");
process.exit(1);
}
const CONFIG = path.resolve(__dirname, "../config/substack-publications.json");
function loadPublications() {
try {
return JSON.parse(fs.readFileSync(CONFIG, "utf-8"));
} catch {
return ["blog.bytebytego.com"];
}
}
async function fetchText(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(url, {
redirect: "follow",
signal: controller.signal,
headers: { "User-Agent": "Mozilla/5.0" },
});
return res.ok ? await res.text() : "";
} catch {
return "";
} finally {
clearTimeout(timeout);
}
}
function decodeEntities(s) {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#0?39;|&apos;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n))
.trim();
}
function stripTags(html) {
return decodeEntities(html.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
}
// Pull the first <title> (CDATA or plain) and <link> from an <item> chunk.
function itemTitle(item) {
const m = item.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/);
return m ? decodeEntities(m[1].trim()) : "";
}
function itemLink(item) {
const m = item.match(/<link>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/link>/);
return m ? m[1].trim() : "";
}
// 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) {
const at = item.indexOf(id);
if (at === -1) return "";
const figStart = item.lastIndexOf("<figure", at);
if (figStart === -1) return "";
const figEnd = item.indexOf("</figure>", at);
if (figEnd === -1) return "";
const figure = item.slice(figStart, figEnd);
const cap = figure.match(/<figcaption[^>]*>([\s\S]*?)<\/figcaption>/);
return cap ? stripTags(cap[1]) : "";
}
// Extract a post title from server-rendered post HTML (og:title preferred).
function postTitleFromHtml(html) {
const og = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
if (og) return decodeEntities(og[1]);
const h1 = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
if (h1) return stripTags(h1[1]);
const t = html.match(/<title>([\s\S]*?)<\/title>/i);
return t ? decodeEntities(t[1].trim()) : "";
}
// Deep fallback: crawl the sitemap back ~3 months, fetch posts most-recent-first
// (capped), and look for the UUID. Heavier than RSS — only used on RSS miss.
async function searchSitemap(publication, id, maxFetch = 40, monthsBack = 3) {
const xml = await fetchText(`https://${publication}/sitemap.xml`);
if (!xml) return { hit: null, scanned: 0, cutoff: null };
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - monthsBack);
const candidates = [];
for (const block of xml.split("<url>").slice(1)) {
const loc = block.match(/<loc>([^<]+)<\/loc>/);
const lastmod = block.match(/<lastmod>([^<]+)<\/lastmod>/);
if (!loc || !lastmod) continue;
if (!/\/p\//.test(loc[1])) continue; // posts only
const when = new Date(lastmod[1]);
if (isNaN(when) || when < cutoff) continue;
candidates.push({ url: loc[1], when });
}
candidates.sort((a, b) => b.when - a.when);
let scanned = 0;
for (const c of candidates.slice(0, maxFetch)) {
scanned++;
const html = await fetchText(c.url);
if (!html || !html.includes(id)) continue;
return {
hit: {
found: true,
source: "sitemap",
publication,
postTitle: postTitleFromHtml(html),
postUrl: c.url,
caption: captionForUuid(html, id),
},
scanned,
cutoff: cutoff.toISOString().slice(0, 10),
};
}
return { hit: null, scanned, cutoff: cutoff.toISOString().slice(0, 10) };
}
async function searchRss(publication, id) {
const xml = await fetchText(`https://${publication}/feed`);
if (!xml) return null;
const items = xml.split("<item>").slice(1);
for (const item of items) {
if (!item.includes(id)) continue;
const postTitle = itemTitle(item);
const caption = captionForUuid(item, id);
return {
found: true,
source: "rss",
publication,
postTitle,
postUrl: itemLink(item),
caption,
};
}
return null;
}
async function main() {
const publications = loadPublications();
for (const pub of publications) {
const hit = await searchRss(pub, uuid);
if (hit) return console.log(JSON.stringify(hit, null, 2));
}
// Deep fallback: sitemap crawl up to ~3 months back.
if (deep) {
let totalScanned = 0;
let lastCutoff = null;
for (const pub of publications) {
const { hit, scanned, cutoff } = await searchSitemap(pub, uuid);
totalScanned += scanned;
lastCutoff = cutoff;
if (hit) return console.log(JSON.stringify(hit, null, 2));
}
return console.log(JSON.stringify({
found: false,
source: "sitemap",
scanned: totalScanned,
cutoff: lastCutoff,
}, null, 2));
}
console.log(JSON.stringify({ found: false }, null, 2));
}
main();
+6 -4
View File
@@ -10,8 +10,9 @@ description: 'Meta entry for adding URLs to the Hugo blog newsletter. Use whenev
**Supported routes (this version):**
- `article` → `mt-add-post`
- `youtube` → `mt-add-video`
- `image` → `mt-add-image`
Everything else (`image`, direct `video` file, `document`, or anything unrecognized) is **not supported yet** → ask the user how to handle it. Image support is planned as a future `mt-add-image` skill.
Everything else (direct `video` file, `document`, or anything unrecognized) is **not supported yet** → ask the user how to handle it.
## Workflow
@@ -37,7 +38,8 @@ Output (JSON): `{ original_url, clean_url, http_status, accessible, duplicate, r
|-------|--------|
| `article` | Invoke the **`mt-add-post`** skill, passing `clean_url` |
| `youtube` | Invoke the **`mt-add-video`** skill, passing `clean_url` |
| `image` / `video` / `document` / anything else | **Fallback** — see step 4 |
| `image` | Invoke the **`mt-add-image`** skill, passing `clean_url` |
| `video` (direct file) / `document` / anything else | **Fallback** — see step 4 |
Dispatch by calling the Skill tool for the chosen handler with `clean_url` as the argument. The handler completes the write end-to-end.
@@ -45,11 +47,11 @@ Dispatch by calling the Skill tool for the chosen handler with `clean_url` as th
### 4. Fallback for unsupported types
When `route` is not `article` or `youtube`, do NOT write anything automatically. Use `AskUserQuestion`:
When `route` is not `article`, `youtube`, or `image`, do NOT write anything automatically. Use `AskUserQuestion`:
- **Question:** "URL type `<route>` isn't supported yet (`<clean_url>`). How do you want to handle it?"
- **Options:**
- "Add a new skill" — e.g. create `mt-add-image` for images. (Recommended for a type you'll reuse.)
- "Add a new skill" — e.g. a handler for this type. (Recommended for a type you'll reuse.)
- "Update an existing skill" — extend a handler to support this URL type.
- "Skip this URL" — leave it out of the newsletter.
+2 -1
View File
@@ -41,9 +41,10 @@ This project uses custom skills for automated workflows:
- **mt-add-url**: Meta entry for adding URLs — classifies each URL and auto-dispatches to the right handler (the default entry when adding URLs to the newsletter)
- **mt-add-post**: Article handler — adds an article/blog URL to the newsletter main content
- **mt-add-video**: YouTube handler — adds a YouTube link to the newsletter Bonus → Videos
- **mt-add-image**: Image handler — adds an image to Bonus → Images; for Substack images finds the source post (ByteByteGo + configured publications) via RSS/sitemap to label it, else asks for a label
- **mt-add-tags**: Add/update tags in Hugo post frontmatter
Shared scripts (`add-url.js`, `url-utils.js`, `find-newsletter-number.js`) and the shared post-mechanics reference live under `.claude/skills/mt-add-url/`. Image support is planned as a future `mt-add-image` handler; until then `mt-add-url` asks how to handle non-article/non-YouTube URLs.
Shared scripts (`add-url.js`, `url-utils.js`, `find-newsletter-number.js`) and the shared post-mechanics reference live under `.claude/skills/mt-add-url/`. `mt-add-url` dispatches `article`/`youtube`/`image`; other types (direct video files, documents, unknown) prompt the user to add or extend a handler.
Refer to `.claude/skills/` for skill implementations.