]*class=(?:"[^"]*\bVPHome\b[^"]*"|'[^']*\bVPHome\b[^']*')[^>]*>/i)
+ ?? '';
+}
+
+function extractBalancedDiv(html, openingPattern) {
+ const opening = openingPattern.exec(html);
+ if (!opening) return '';
+ const start = opening.index + opening[0].length;
+ const tags = /
]*>|<\/div\s*>/gi;
+ tags.lastIndex = start;
+ let depth = 1;
+ let tag;
+ while ((tag = tags.exec(html))) {
+ depth += /^<\/div/i.test(tag[0]) ? -1 : 1;
+ if (depth === 0) return html.slice(start, tag.index);
+ }
+ return '';
+}
+
+function pageModuleUrl(html, pageUrl) {
+ for (const tag of html.match(/
]*>/gi) ?? []) {
+ if (extractAttribute(tag, 'rel').toLowerCase() !== 'modulepreload') continue;
+ const href = extractAttribute(tag, 'href');
+ if (/\.md\..+\.lean\.js(?:\?|$)/i.test(href)) return new URL(href, pageUrl).href;
+ }
+ return '';
+}
+
+function decodeJsString(value) {
+ try {
+ return JSON.parse(`"${value.replace(/"/g, '\\"')}"`);
+ } catch {
+ return value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\//g, '/');
+ }
+}
+
+export function parseModuleResources(moduleText, pageUrl, pageTitle) {
+ const records = [];
+ const seen = new Set();
+ for (const objectMatch of moduleText.matchAll(/\{[^{}]{1,800}\}/g)) {
+ const object = objectMatch[0];
+ const nameMatch = object.match(/\bname:"((?:\\.|[^"\\])*)"/);
+ const urlMatch = object.match(/\burl:"((?:\\.|[^"\\])*)"/);
+ if (!nameMatch || !urlMatch) continue;
+ const title = decodeJsString(nameMatch[1]);
+ const rawUrl = decodeJsString(urlMatch[1]);
+ if (!title.trim() || !rawUrl.trim()) continue;
+ let url;
+ try {
+ url = new URL(rawUrl, pageUrl).href;
+ } catch {
+ continue;
+ }
+ if (seen.has(url)) continue;
+ seen.add(url);
+ const chordMatch = object.match(/\bchord:"((?:\\.|[^"\\])*)"/);
+ records.push({
+ page: new URL(pageUrl).pathname,
+ page_title: pageTitle,
+ section: `${pageTitle} > Bookmarks`,
+ kind: 'resource',
+ title,
+ description: chordMatch ? `Shortcut: ${decodeJsString(chordMatch[1])}` : '',
+ url,
+ links: url,
+ });
+ }
+ return records;
+}
+
+function anchorsFrom(block, pageUrl) {
+ const anchors = [];
+ const anchorRegex = /
]*)>([\s\S]*?)<\/a>/gi;
+ let match;
+ while ((match = anchorRegex.exec(block))) {
+ const href = extractAttribute(``, 'href');
+ if (!href || href.startsWith('#') || href.startsWith('javascript:')) continue;
+ let url;
+ try {
+ url = new URL(href, pageUrl).href;
+ } catch {
+ continue;
+ }
+ const title = stripTags(match[2]);
+ anchors.push({ title, url });
+ }
+ return anchors;
+}
+
+function descriptionWithoutTitle(text, title) {
+ if (!title) return text;
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return text
+ .replace(new RegExp(`^\\s*${escaped}\\s*(?:[-–—:]\\s*)?`, 'i'), '')
+ .trim();
+}
+
+export function normalizePageUrl(input) {
+ const raw = String(input ?? '').trim();
+ if (!raw || raw === '/') return `${BASE_URL}/`;
+ const url = new URL(raw.startsWith('http://') || raw.startsWith('https://') ? raw : `/${raw.replace(/^\/+/, '')}`, BASE_URL);
+ if (url.origin !== BASE_URL) throw new Error('FMHY adapters only accept fmhy.net page URLs.');
+ url.hash = '';
+ url.search = '';
+ return url.href;
+}
+
+export function parseSitemap(xml) {
+ const urls = [];
+ const seen = new Set();
+ const locRegex = /([\s\S]*?)<\/loc>/gi;
+ let match;
+ while ((match = locRegex.exec(xml))) {
+ const url = normalizePageUrl(decodeEntities(match[1].trim()));
+ if (!seen.has(url)) {
+ seen.add(url);
+ urls.push(url);
+ }
+ }
+ return urls;
+}
+
+function robotsPattern(value) {
+ const anchored = value.endsWith('$');
+ const source = value
+ .replace(/\$$/, '')
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
+ .replace(/\*/g, '.*');
+ return new RegExp(`^${source}${anchored ? '$' : ''}`);
+}
+
+export function parseRobots(text) {
+ const rules = [];
+ let applies = false;
+ for (const rawLine of text.split(/\r?\n/)) {
+ const line = rawLine.replace(/\s*#.*$/, '').trim();
+ if (!line) continue;
+ const separator = line.indexOf(':');
+ if (separator < 0) continue;
+ const field = line.slice(0, separator).trim().toLowerCase();
+ const value = line.slice(separator + 1).trim();
+ if (field === 'user-agent') {
+ applies = value === '*';
+ } else if (applies && (field === 'allow' || field === 'disallow') && value) {
+ rules.push({ allow: field === 'allow', pattern: value, regex: robotsPattern(value) });
+ }
+ }
+ return rules;
+}
+
+export function isRobotsAllowed(url, rules) {
+ const path = `${url.pathname}${url.search}`;
+ const matches = rules.filter(rule => rule.regex.test(path));
+ if (!matches.length) return true;
+ matches.sort((a, b) => b.pattern.length - a.pattern.length || Number(b.allow) - Number(a.allow));
+ return matches[0].allow;
+}
+
+export async function fetchText(url, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
+ const response = await fetch(url, {
+ headers: {
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8',
+ 'user-agent': USER_AGENT,
+ },
+ redirect: 'follow',
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (!response.ok) throw new Error(`FMHY request failed (${response.status}) for ${url}`);
+ return response.text();
+}
+
+async function getRobotsRules() {
+ robotsPromise ??= fetchText(ROBOTS_URL).then(parseRobots);
+ return robotsPromise;
+}
+
+export async function fetchSitemap() {
+ return parseSitemap(await fetchText(SITEMAP_URL));
+}
+
+export async function fetchPage(input, { sitemap } = {}) {
+ const url = new URL(normalizePageUrl(input));
+ const knownUrls = sitemap ?? await fetchSitemap();
+ if (!knownUrls.includes(url.href)) throw new Error(`FMHY page is not present in the live sitemap: ${url.pathname}`);
+ const rules = await getRobotsRules();
+ if (!isRobotsAllowed(url, rules)) throw new Error(`FMHY robots.txt disallows crawling: ${url.pathname}`);
+ const html = await fetchText(url.href);
+ const page = parsePage(html, url.href);
+ if (page.records.length === 1 && page.records[0].kind === 'page') {
+ const moduleUrl = pageModuleUrl(html, url.href);
+ if (moduleUrl) {
+ const moduleRecords = parseModuleResources(await fetchText(moduleUrl), page.url, page.title);
+ if (moduleRecords.length) page.records = moduleRecords;
+ }
+ }
+ return page;
+}
+
+export function parsePage(html, requestedUrl) {
+ const url = canonicalUrl(html, requestedUrl);
+ const title = metaContent(html, 'og:title', 'property')
+ || stripTags(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? '')
+ .replace(/\s*[•|]\s*freemediaheckyeah\s*$/i, '');
+ const pageDescription = metaContent(html, 'description');
+ const content = mainHtml(html);
+ const pagePath = new URL(url).pathname;
+ const records = [];
+ const headings = [];
+ const blockRegex = /]*>([\s\S]*?)<\/h\1>|<(p|li)\b[^>]*>([\s\S]*?)<\/\3>/gi;
+ let match;
+ while ((match = blockRegex.exec(content))) {
+ if (match[1]) {
+ const level = Number(match[1]);
+ const heading = stripTags(match[2]).replace(//g, '').trim();
+ if (!heading) continue;
+ headings[level - 1] = heading;
+ headings.length = level;
+ continue;
+ }
+
+ const kind = match[3].toLowerCase() === 'li' ? 'resource' : 'text';
+ const block = match[4];
+ const text = stripTags(block);
+ if (!text || /^(Got feedback\?|Send us your suggestions)/i.test(text)) continue;
+ const anchors = anchorsFrom(block, url);
+ const primary = anchors.find(anchor => anchor.title) ?? anchors[0];
+ records.push({
+ page: pagePath,
+ page_title: title,
+ section: headings.filter(Boolean).join(' > '),
+ kind,
+ title: primary?.title ?? '',
+ description: descriptionWithoutTitle(text, primary?.title ?? ''),
+ url: primary?.url ?? '',
+ links: anchors.map(anchor => anchor.url).join(' '),
+ });
+ }
+
+ if (!records.length) {
+ records.push({
+ page: pagePath,
+ page_title: title,
+ section: '',
+ kind: 'page',
+ title,
+ description: pageDescription,
+ url,
+ links: '',
+ });
+ }
+ return { url, title, description: pageDescription, records };
+}
+
+export function sitemapRows(urls) {
+ return urls.map(url => {
+ const pathname = new URL(url).pathname;
+ const parts = pathname.split('/').filter(Boolean);
+ return {
+ path: pathname,
+ group: parts[0] ?? 'root',
+ kind: parts[0] === 'posts' && parts.length > 1 ? 'post' : 'directory',
+ url,
+ };
+ });
+}
+
+export async function mapLimit(values, concurrency, mapper) {
+ const results = new Array(values.length);
+ let next = 0;
+ async function worker() {
+ while (next < values.length) {
+ const index = next++;
+ results[index] = await mapper(values[index], index);
+ }
+ }
+ const count = Math.max(1, Math.min(Number(concurrency) || 1, 6, values.length || 1));
+ await Promise.all(Array.from({ length: count }, worker));
+ return results;
+}
+
+export function selectPages(urls, { group, maxPages } = {}) {
+ let selected = urls;
+ if (group) {
+ const normalized = String(group).replace(/^\/+|\/+$/g, '').toLowerCase();
+ selected = selected.filter(url => new URL(url).pathname.split('/').filter(Boolean)[0]?.toLowerCase() === normalized);
+ }
+ const cap = Number(maxPages) || 0;
+ return cap > 0 ? selected.slice(0, cap) : selected;
+}
+
+export async function crawlPages({ group, maxPages = 0, concurrency = 3, delayMs = 150 } = {}) {
+ const sitemap = await fetchSitemap();
+ const pages = selectPages(sitemap, { group, maxPages });
+ const settled = await mapLimit(pages, concurrency, async (url) => {
+ if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, Math.min(Number(delayMs) || 0, 2_000)));
+ try {
+ return { ok: true, page: await fetchPage(url, { sitemap }) };
+ } catch (error) {
+ return { ok: false, url, error: error instanceof Error ? error.message : String(error) };
+ }
+ });
+ const records = settled.flatMap(result => result.ok ? result.page.records : [{
+ page: new URL(result.url).pathname,
+ page_title: '',
+ section: '',
+ kind: 'error',
+ title: '',
+ description: result.error,
+ url: result.url,
+ links: '',
+ }]);
+ return { pages, records, failures: settled.filter(result => !result.ok) };
+}
+
+export const __test__ = {
+ decodeEntities,
+ extractBalancedDiv,
+ mainHtml,
+ pageModuleUrl,
+ stripTags,
+};
diff --git a/integrations/opencli/fmhy/utils.test.mjs b/integrations/opencli/fmhy/utils.test.mjs
new file mode 100644
index 0000000..5f40f94
--- /dev/null
+++ b/integrations/opencli/fmhy/utils.test.mjs
@@ -0,0 +1,104 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ isRobotsAllowed,
+ normalizePageUrl,
+ parsePage,
+ parseModuleResources,
+ parseRobots,
+ parseSitemap,
+ selectPages,
+} from './utils.js';
+
+test('parses and deduplicates FMHY sitemap URLs', () => {
+ const urls = parseSitemap(`
+
+ https://fmhy.net/ai
+ https://fmhy.net/posts/jan-2026
+ https://fmhy.net/ai
+
+ `);
+ assert.deepEqual(urls, ['https://fmhy.net/ai', 'https://fmhy.net/posts/jan-2026']);
+ assert.deepEqual(selectPages(urls, { group: 'posts' }), ['https://fmhy.net/posts/jan-2026']);
+});
+
+test('rejects foreign origins', () => {
+ assert.throws(() => normalizePageUrl('https://example.com/ai'), /only accept fmhy\.net/);
+});
+
+test('enforces allow and disallow robots rules', () => {
+ const rules = parseRobots(`
+ User-agent: *
+ Disallow: /assets/
+ Disallow: /*.png$
+ Allow: /
+ `);
+ assert.equal(isRobotsAllowed(new URL('https://fmhy.net/ai'), rules), true);
+ assert.equal(isRobotsAllowed(new URL('https://fmhy.net/assets/app.js'), rules), false);
+ assert.equal(isRobotsAllowed(new URL('https://fmhy.net/logo.png'), rules), false);
+});
+
+test('extracts headings, prose, and resource links from VitePress main content', () => {
+ const page = parsePage(`
+
+
+
+
+
+
+
Artificial Intelligence
+
Chatbots
+
Useful public services.
+
+
+
+ `, 'https://fmhy.net/ai');
+
+ assert.equal(page.title, 'Artificial Intelligence');
+ assert.deepEqual(page.records, [
+ {
+ page: '/ai',
+ page_title: 'Artificial Intelligence',
+ section: 'Artificial Intelligence > Chatbots',
+ kind: 'text',
+ title: '',
+ description: 'Useful public services.',
+ url: '',
+ links: '',
+ },
+ {
+ page: '/ai',
+ page_title: 'Artificial Intelligence',
+ section: 'Artificial Intelligence > Chatbots',
+ kind: 'resource',
+ title: 'Example Chat',
+ description: 'Fast & free',
+ url: 'https://example.com/chat',
+ links: 'https://example.com/chat',
+ },
+ ]);
+});
+
+test('extracts homepage content from the VPHome wrapper', () => {
+ const page = parsePage(`
+
+ FMHY
Browse the directories.
+
+
+ `, 'https://fmhy.net/');
+ assert.equal(page.records[0].description, 'Browse the directories.');
+ assert.equal(page.records.some(record => record.description.includes('Footer noise')), false);
+});
+
+test('extracts static startpage bookmarks from the VitePress page module', () => {
+ const records = parseModuleResources(
+ 'const a=[{name:"YouTube",chord:"YT",url:"https://youtube.com/",icon:"video"},{name:"Guide",url:"/beginners-guide"}]',
+ 'https://fmhy.net/startpage',
+ 'Startpage',
+ );
+ assert.deepEqual(records.map(({ title, url }) => ({ title, url })), [
+ { title: 'YouTube', url: 'https://youtube.com/' },
+ { title: 'Guide', url: 'https://fmhy.net/beginners-guide' },
+ ]);
+});