refactor(skills): fix dedup edge cases and modularize mt-* scripts

- fix checkDuplicate false-negative on Substack cover images (uuid with no size suffix)
- stop cleanUrl from corrupting the query string on unparseable input
- extract shared fetchWithTimeout into url-utils (consistent timer cleanup)
- split find-substack-post html/rss helpers into html-text-utils
- scan content/post for duplicates, matching the other scripts
- correct doc drift in mt-add-image/mt-add-url and drop plan-phase code comment
This commit is contained in:
tiennm99 committed 2026-05-30 20:31:50 +07:00
1 parent eada44d407
commit d262159742
6 files changed
+150 -127

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ description: 'Meta entry for adding URLs to the Hugo blog newsletter. Use whenev
## Overview
`mt-add-url` is the **router**: it classifies each URL and auto-invokes the matching handler skill. Handlers (`mt-add-post`, `mt-add-video`) own the actual content writing. Shared scripts live in `.claude/skills/mt-add-url/scripts/`; shared post mechanics in `references/newsletter-post-mechanics.md`.
`mt-add-url` is the **meta dispatcher**: it classifies each URL and auto-invokes the matching handler skill. Handlers (`mt-add-post`, `mt-add-video`, `mt-add-image`) own the actual content writing. Shared scripts live in `.claude/skills/mt-add-url/scripts/`; shared post mechanics in `references/newsletter-post-mechanics.md`.
**Supported routes (this version):**
- `article` → `mt-add-post`
+5 -7
View File
@@ -9,6 +9,7 @@ const path = require("path");
const {
cleanUrl,
isSubstackImage,
fetchWithTimeout,
checkAccessibility,
checkDuplicate,
classifyType,
@@ -21,7 +22,7 @@ if (!url) {
}
const PROJECT_ROOT = path.resolve(__dirname, "../../../..");
const CONTENT_DIR = path.join(PROJECT_ROOT, "content");
const CONTENT_DIR = path.join(PROJECT_ROOT, "content", "post");
const YT_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com"]);
@@ -60,13 +61,10 @@ function canonicalWatchUrl(videoId) {
// Fetch title/author via YouTube oEmbed (no API key). Best-effort: any failure
// returns {} so the route stays `youtube` and the skill can fall back.
async function fetchYouTubeMeta(watchUrl) {
const endpoint = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchUrl)}&format=json`;
const res = await fetchWithTimeout(endpoint);
if (!res || !res.ok) return {};
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const endpoint = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchUrl)}&format=json`;
const res = await fetch(endpoint, { redirect: "follow", signal: controller.signal });
clearTimeout(timeout);
if (!res.ok) return {};
const data = await res.json();
return { title: data.title, author: data.author_name };
} catch {
+35 -24
View File
@@ -21,10 +21,10 @@ function cleanUrl(rawUrl) {
});
return parsed.toString();
} catch {
// If URL parsing fails, do basic string cleanup
return rawUrl.replace(/[?&](utm_[^&]*|fbclid|gclid|msclkid|mc_eid|aid|ref|ref_src|ref_url|source|ck_subscriber_id|igshid|yclid|vero_id)=[^&]*/gi, "")
.replace(/\?&/, "?")
.replace(/[?&]$/, "");
// Unparseable input (not a real URL): the per-param string surgery above
// would mangle the query (drop the `?`, leave a dangling `&`), so leave it
// untouched rather than corrupt it.
return rawUrl;
}
}
@@ -77,21 +77,26 @@ function bareUrl(targetUrl) {
}
}
// fetch() with an abort timeout. Returns the Response on success, or null on
// network error / timeout. Callers decide what to read (.text/.json/.status).
// Centralizes the AbortController + clearTimeout dance so every caller cleans
// up the timer (via finally) on both the success and failure paths.
async function fetchWithTimeout(targetUrl, { method = "GET", timeoutMs = 10000, headers } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(targetUrl, { method, redirect: "follow", signal: controller.signal, headers });
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
// Check if URL is accessible (returns HTTP status code as a string).
async function checkAccessibility(targetUrl) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const res = await fetch(targetUrl, {
method: "HEAD",
redirect: "follow",
signal: controller.signal,
});
clearTimeout(timeout);
return res.status.toString();
} catch {
return "000";
}
const res = await fetchWithTimeout(targetUrl, { method: "HEAD" });
return res ? res.status.toString() : "000";
}
function escapeRegExp(s) {
@@ -116,16 +121,21 @@ function collectMarkdown(dir, acc = []) {
// Check whether a URL identity already exists in the stored markdown.
// Pure JS (no external `grep` dependency, works the same from any shell) and
// boundary-aware: the identity must be followed by a delimiter (close paren,
// quote, whitespace, query/fragment, the image dimension separator `_`, etc.)
// so a URL that is merely a PREFIX of a stored longer URL (e.g. /p/foo vs
// /p/foo-bar) is NOT a false duplicate.
// boundary-aware so a needle that is merely a PREFIX of a stored longer string
// is NOT a false duplicate. The two identity kinds need different boundaries:
// - Substack image UUID: the next char must not extend the hex id, so all of
// <uuid>.png (cover image, no size suffix), <uuid>_WxH and <uuid>) match.
// - URL: must be followed by a path/punctuation delimiter so /p/foo does not
// match a stored /p/foo-bar.
function checkDuplicate(targetUrl, contentDir) {
const needle = bareUrl(targetUrl);
if (!needle) return false;
// Allow an optional trailing slash (bareUrl strips it, stored URLs may keep
// it) before the delimiter.
const boundary = new RegExp(escapeRegExp(needle) + `/?(?:[)\\]\\s"'?#<_&,]|$)`, "m");
const isUuid = new RegExp(`^${UUID}$`, "i").test(needle);
// For URLs, allow an optional trailing slash (bareUrl strips it, stored URLs
// may keep it) before the delimiter.
const boundary = isUuid
? new RegExp(escapeRegExp(needle) + `(?![0-9a-f])`, "i")
: new RegExp(escapeRegExp(needle) + `/?(?:[)\\]\\s"'?#<_&,]|$)`, "m");
for (const file of collectMarkdown(contentDir)) {
let text;
try { text = fs.readFileSync(file, "utf-8"); } catch { continue; }
@@ -149,6 +159,7 @@ module.exports = {
IDENTITY_PARAMS,
isSubstackImage,
substackImageUuid,
fetchWithTimeout,
checkAccessibility,
checkDuplicate,
classifyType,