/**
* HTML to Markdown Converter - Scrapes web pages and converts to clean markdown
*/
export interface MarkdownDocument {
title: string;
url: string;
content: string;
wordCount: number;
description?: string;
}
/**
* Selectors to find main content
*/
const CONTENT_SELECTORS = [
'article',
'main',
'[role="main"]',
'.docs-content',
'.documentation',
'.content',
'.markdown-body',
'.prose',
'#content',
'#main-content',
'.post-content',
'.article-content',
];
/**
* Elements to remove before conversion
*/
const REMOVE_SELECTORS = [
'script',
'style',
'noscript',
'nav',
'footer',
'header',
'aside',
'.sidebar',
'.navigation',
'.nav',
'.menu',
'.toc',
'.table-of-contents',
'.ads',
'.advertisement',
'.cookie-banner',
'.cookie-consent',
'.newsletter',
'.social-share',
'.comments',
'.related-posts',
'[role="navigation"]',
'[role="banner"]',
'[role="contentinfo"]',
];
/**
* Count words in a string
*/
export function countWords(text: string): number {
return text
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 0)
.length;
}
/**
* Extract title from HTML
*/
function extractTitle(html: string, url: string): string {
// Try og:title first
const ogTitleMatch = html.match(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i);
if (ogTitleMatch) {
return decodeHtmlEntities(ogTitleMatch[1].trim());
}
// Try
tag
const titleMatch = html.match(/]*>([^<]+)<\/title>/i);
if (titleMatch) {
// Clean up common suffixes like " - Company Name" or " | Docs"
let title = decodeHtmlEntities(titleMatch[1].trim());
title = title.replace(/\s*[-|–]\s*[^-|–]+$/, '').trim();
if (title) return title;
}
// Try first
const h1Match = html.match(/]*>([^<]+)<\/h1>/i);
if (h1Match) {
return decodeHtmlEntities(h1Match[1].trim());
}
// Fallback to URL
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(Boolean);
if (pathParts.length > 0) {
return pathParts[pathParts.length - 1]
.replace(/[-_]/g, ' ')
.replace(/\.(html?|md|mdx)$/i, '')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
return urlObj.hostname;
} catch {
return 'Documentation';
}
}
/**
* Extract description from HTML
*/
function extractDescription(html: string): string | undefined {
// Try og:description
const ogDescMatch = html.match(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i);
if (ogDescMatch) {
return decodeHtmlEntities(ogDescMatch[1].trim());
}
// Try meta description
const descMatch = html.match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
if (descMatch) {
return decodeHtmlEntities(descMatch[1].trim());
}
return undefined;
}
/**
* Decode common HTML entities
*/
function decodeHtmlEntities(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/ /g, ' ')
.replace(/(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
.replace(/([a-fA-F0-9]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
}
/**
* Remove unwanted elements from HTML
*/
function removeUnwantedElements(html: string): string {
let cleaned = html;
// Remove script, style, and comment blocks
cleaned = cleaned.replace(/