Making documentation pages agent friendly
* Cloudflare Worker: Markdown Mirror for I'd Rather Be Writing
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
**
- Cloudflare Worker: Markdown Mirror for I'd Rather Be Writing
- Serves a clean Markdown version of any page when the URL ends in .md
- or when the request's Accept header asks for text/markdown. */
const SITE_ORIGIN = 'https://idratherbewriting.com';
const LLMS_TXT_URL = ${SITE_ORIGIN}/llms.txt;
const MIRROR_USER_AGENT = 'Cloudflare-Markdown-Mirror/1.1';
const CACHE_SECONDS = 3600;
export default { async fetch(request, env, ctx) { const method = request.method.toUpperCase(); const url = new URL(request.url);
const isMdExtension = url.pathname.endsWith('.md'); const acceptsMarkdown = (request.headers.get('Accept') || '').toLowerCase().includes('text/markdown');
// Only GET and HEAD requests that ask for Markdown are handled here if ((method !== 'GET' && method !== 'HEAD') || (!isMdExtension && !acceptsMarkdown)) { return fetch(request); }
try { return await serveMarkdown(url, method, isMdExtension, ctx); } catch (err) { // Never break the site. If anything goes wrong, fall back to the normal response. console.error('Markdown mirror error:', err && err.stack ? err.stack : err); return fetch(request); } } };
async function serveMarkdown(url, method, isMdExtension, ctx) { const originBase = url.origin.includes('workers.dev') ? SITE_ORIGIN : url.origin;
// 1. Resolve the path of the HTML page this request maps to let targetPath = url.pathname; if (isMdExtension) { targetPath = targetPath.slice(0, -3); // Strip .md if (targetPath === '' || targetPath.endsWith('/')) { targetPath += 'index.html'; } }
// 2. Check the edge cache. The cache key is separate from the HTML page's URL,
// so a cached Markdown response can never be served to a browser.
const cache = caches.default;
const cacheKey = new Request(${originBase}${targetPath}?markdown-mirror=1, { method: 'GET' });
const cached = await cache.match(cacheKey);
if (cached) {
return method === 'HEAD'
? new Response(null, { status: cached.status, headers: cached.headers })
: cached;
}
// 3. Fetch the HTML page from the origin let originRes = await fetchOrigin(new URL(targetPath, originBase));
// Extensionless .md requests (e.g. /ai/product-skills-docs-first.md) map to .html pages
if (originRes.status === 404 && isMdExtension && !/.html?$/i.test(targetPath)) {
const htmlRes = await fetchOrigin(new URL(${targetPath}.html, originBase));
if (htmlRes.ok) {
originRes = htmlRes;
}
}
if (!originRes.ok) {
if (!isMdExtension) {
return originRes; // Let the origin's own error page through
}
return textResponse(Page not found: ${url.pathname}, originRes.status, method);
}
// Images, PDFs, feeds, llms.txt, and other non-HTML files pass through unchanged const contentType = (originRes.headers.get('Content-Type') || '').toLowerCase(); if (!contentType.includes('text/html')) { return originRes; }
// 4. Convert the HTML to Markdown const canonicalUrl = originRes.url || new URL(targetPath, originBase).toString(); const html = await originRes.text(); const markdown = convertHtmlToMarkdown(html, canonicalUrl);
// 5. Return the Markdown and cache it at the edge
const headers = {
'Content-Type': 'text/markdown; charset=utf-8',
'Cache-Control': public, max-age=${CACHE_SECONDS},
'Vary': 'Accept',
'Link': <${canonicalUrl}>; rel="canonical",
'Access-Control-Allow-Origin': '*'
};
const mdResponse = new Response(markdown, { status: 200, headers });
ctx.waitUntil(cache.put(cacheKey, mdResponse.clone()));
return method === 'HEAD' ? new Response(null, { status: 200, headers }) : mdResponse; }
function fetchOrigin(originUrl) { return fetch(originUrl.toString(), { headers: { 'User-Agent': MIRROR_USER_AGENT, 'Accept': 'text/html' } }); }
function textResponse(body, status, method) { return new Response(method === 'HEAD' ? null : body, { status, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); }
// --------------------------------------------------------------------------- // HTML to Markdown conversion // ---------------------------------------------------------------------------
const PH = String.fromCharCode(0xE000); // Wraps code placeholders (a private-use character)
const INDENT = String.fromCharCode(0xE001); // Marks intentional indentation that cleanup must keep
const BLOCK_TOKEN = (n) => ${PH}B${n}${PH}; // Placeholder for a fenced code block
const INLINE_TOKEN = (n) => ${PH}I${n}${PH}; // Placeholder for an inline code span
function convertHtmlToMarkdown(html, canonicalUrl) { // Extract metadata const title = extractTitle(html); const description = getMetaContent(html, 'name', 'description').replace(/\s+/g, ' '); const dateMatch = html.match(/"datePublished":\s*"([^"]+)"/i); const date = (dateMatch ? dateMatch[1] : getMetaContent(html, 'property', 'article:published_time')).split('T')[0];
const blocks = []; const inlines = [];
let content = extractMainContent(html);
// Remove scripts, styles, comments, and form widgets first, so that markup // inside script strings (like the ads' document.write calls) can't confuse // any of the steps that follow content = content .replace(/<!--[\s\S]*?-->/g, '') .replace(/<(script|style|noscript|template|svg|form|button|select|textarea|nav)\b[\s\S]?</\1>/gi, '') .replace(/<input\b[^>]>/gi, '');
// Remove page chrome and in-post ads. Ads (div.comicBlock) appear in the middle // of pages, so they're removed here instead of being treated as the end of the page. content = removeElements(content, 'div', classPattern('comicBlock', 'courseProgress', 'lastModifiedDate', 'subheading')); content = removeElements(content, 'ol', classPattern('breadcrumb')); content = removeElements(content, 'ul', /\bid\s*=\s*["']markdown-toc["']/i); content = removeElements(content, 'p', classPattern('page-meta', 'sponsoredLabel')); content = removeElements(content, 'span', classPattern('pageCategories'));
// Remove the draft notice but keep the other callouts (Note, Tip, Important, Warning) content = content .replace(/<div\b[^>]\bclass\s=\s*"'?alert(?:\s[^"'])?["'][^>]>\s*<i\b[^>]\bfa-flask\b[^>]>[\s\S]?</div>/gi, '') .replace(/<i\b[^>]\bclass\s*=\s*"'?fa(?:\s[^"'])?["'][^>]>\s*</i>/gi, '') .replace(/<div\b[^>]\bclass\s=\s*"'?(?:alert|summary)(?:\s[^"'])?["'][^>]>([\s\S]*?)</div>/gi, '<blockquote>$1</blockquote>');
// Protect code from the cleanup steps below. Code blocks keep their exact
// whitespace and get a fence that's longer than any backtick run inside them.
content = content.replace(/<pre\b([^>])>\s(?:<code\b([^>])>)?([\s\S]?)(?:</code>\s*)?</pre>/gi,
(match, preAttrs, codeAttrs, code, offset, whole) => {
const lang = detectLanguage(${codeAttrs || ''} ${preAttrs}, whole.slice(Math.max(0, offset - 300), offset));
const text = decodeHtml(code.replace(/<[^>]+>/g, '')).replace(/^\n+|\s+$/g, '');
const fence = ''.repeat(Math.max(3, longestRun(text, '') + 1));
return <div>${BLOCK_TOKEN(blocks.push(${fence}${lang}\n${text}\n${fence}) - 1)}</div>;
});
content = content.replace(/<(code|kbd|samp)\b[^>]>([\s\S]?)</\1>/gi, (match, tag, code) => {
const text = decodeHtml(code.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ');
if (!text.trim()) return '';
const ticks = ''.repeat(longestRun(text, '') + 1);
const pad = text.startsWith('') || text.endsWith('') ? ' ' : '';
return INLINE_TOKEN(inlines.push(${ticks}${pad}${text}${pad}${ticks}) - 1);
});
// Whitespace in HTML isn't significant, so collapse it. Line breaks are // re-created from the block-level tags below. content = content.replace(/\s+/g, ' ');
// Embedded media becomes links
content = content.replace(/<iframe\b([^>])>(?:[\s\S]?</iframe>)?/gi, (match, attrs) => {
const src = getAttr(attrs, 'src');
if (!src || !/(?:youtube.com|youtu.be|vimeo.com|loom.com)/i.test(src)) return '';
const label = escapeBrackets(getAttr(attrs, 'title') || 'Video');
return <p>[Video: ${label}](${absoluteUrl(src, canonicalUrl)})</p>;
});
content = content.replace(/<(audio|video)\b([^>])>([\s\S]?)</\1>/gi, (match, tag, attrs, inner) => {
const source = inner.match(/<source\b([^>]*)>/i);
const src = getAttr(attrs, 'src') || (source ? getAttr(source[1], 'src') : '');
if (!src) return '';
const label = tag.toLowerCase() === 'audio' ? 'Audio' : 'Video';
return <p>[${label}](${absoluteUrl(src, canonicalUrl)})</p>;
});
// Figures and images, with absolute URLs
content = content.replace(/<figure\b[^>]>([\s\S]?)</figure>/gi, (match, inner) => {
const img = inner.match(/<img\b([^>])>/i);
if (!img) return <div>${inner}</div>; // Not an image figure, so keep its contents
const src = getAttr(img[1], 'src');
if (!src) return '';
const alt = getAttr(img[1], 'alt');
const capMatch = inner.match(/<figcaption\b[^>]>([\s\S]?)</figcaption>/i);
const caption = capMatch ? capMatch[1].trim() : '';
const captionText = stripTags(caption);
let out = <p>})</p>;
if (alt && captionText && captionText !== alt) {
out += <p><em>${caption}</em></p>;
}
return out;
});
content = content.replace(/<img\b([^>])>/gi, (match, attrs) => {
const src = getAttr(attrs, 'src');
if (!src) return '';
return });
});
// Links, with absolute URLs (same-page #anchors are left as-is)
content = content.replace(/<a\b([^>])>([\s\S]?)</a>/gi, (match, attrs, inner) => {
const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
const href = getAttr(attrs, 'href');
if (//cdn-cgi/l/email-protection/i.test(href)) return '(email address hidden)'; // Cloudflare email obfuscation
if (!href || /^javascript:/i.test(href)) return text;
if (!text) return '';
return [${text}](${absoluteUrl(href, canonicalUrl)});
});
// Inline formatting
content = content
.replace(/<(strong|b)\b[^>]>([\s\S]?)</\1>/gi, (m, tag, inner) => (inner.trim() ? **${inner.trim()}** : ''))
.replace(/<(em|i|cite)\b[^>]>([\s\S]?)</\1>/gi, (m, tag, inner) => (inner.trim() ? *${inner.trim()}* : ''))
.replace(/<(del|s|strike)\b[^>]>([\s\S]?)</\1>/gi, (m, tag, inner) => (inner.trim() ? ~~${inner.trim()}~~ : ''))
.replace(/<br\s*/?>/gi, '\n')
.replace(/<hr\b[^>]*>/gi, '\n\n---\n\n');
// Tables become Markdown tables instead of runs of cell text content = content.replace(/<table\b[^>]>([\s\S]?)</table>/gi, (match, inner) => convertTable(inner));
// Headings (remove <h1> since the title is already emitted at the top)
content = content
.replace(/<h1\b[^>]>[\s\S]?</h1>/gi, '')
.replace(/<h([2-6])\b[^>]>([\s\S]?)</h\1>/gi, (match, level, inner) => {
const text = stripTags(inner);
return text ? \n\n${'#'.repeat(Number(level))} ${text}\n\n : '';
});
// Definition lists and expandable sections
content = content
.replace(/<dt\b[^>]>([\s\S]?)</dt>/gi, (m, inner) => \n\n**${stripTags(inner)}**\n)
.replace(/<dd\b[^>]>([\s\S]?)</dd>/gi, '$1\n\n')
.replace(/</?dl\b[^>]>/gi, '\n\n')
.replace(/<summary\b[^>]>([\s\S]?)</summary>/gi, (m, inner) => \n\n**${stripTags(inner)}**\n\n)
.replace(/</?details\b[^>]>/gi, '\n\n');
// Lists (with numbering and nesting) and blockquotes, including callouts inside list steps content = convertListsAndQuotes(content);
// Paragraphs and other block elements become paragraph breaks content = content .replace(/</?p\b[^>]>/gi, '\n\n') .replace(/</?(?:div|section|article|header|footer|main|aside|figure|figcaption|center|address)\b[^>]>/gi, '\n\n') .replace(/<[^>]+>/g, '');
// Decode HTML entities, then clean up whitespace line by line content = decodeHtml(content) .replace(/[email\sprotected]/gi, '(email address hidden)') .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) .join('\n') .replace(new RegExp(INDENT, 'g'), ' ') .replace(/\n{3,}/g, '\n\n') .trim();
// Restore code blocks (
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。