@@ -16,7 +16,7 @@ import * as net from 'node:net';
1616import { createRequire , syncBuiltinESMExports } from 'node:module' ;
1717import { fileURLToPath } from 'node:url' ;
1818import * as zlib from 'node:zlib' ;
19- import { checkContentLength , fetchAndConvertToMarkdown } from '../../src/url-reader.js' ;
19+ import { checkContentLength , extractMainContent , extractMetadata , fetchAndConvertToMarkdown } from '../../src/url-reader.js' ;
2020import { createUrlReaderLookup } from '../../src/proxy.js' ;
2121import { urlCache } from '../../src/cache.js' ;
2222import { testFunction , createTestResults , printTestSummary } from '../helpers/test-utils.js' ;
@@ -1994,6 +1994,224 @@ async function runTests() {
19941994 }
19951995 } , results ) ;
19961996
1997+ // ── Readability content extraction ────────────────────────────────────────
1998+
1999+ await testFunction ( 'extractMainContent strips nav, sidebar, and footer' , async ( ) => {
2000+ const html = `<html><body>
2001+ <nav>Navigation</nav>
2002+ <article><h1>Main Article</h1><p>This is the article body.</p></article>
2003+ <aside>Sidebar</aside>
2004+ <footer>Footer</footer>
2005+ </body></html>` ;
2006+
2007+ const result = extractMainContent ( html , 'http://test.local' ) ;
2008+ assert . ok ( result ) ;
2009+ assert . ok ( result . includes ( 'Main Article' ) , 'Expected article heading' ) ;
2010+ assert . ok ( ! result . includes ( 'Navigation' ) , 'Expected nav to be stripped' ) ;
2011+ assert . ok ( ! result . includes ( 'Sidebar' ) , 'Expected aside to be stripped' ) ;
2012+ assert . ok ( ! result . includes ( 'Footer' ) , 'Expected footer to be stripped' ) ;
2013+ } , results ) ;
2014+
2015+ await testFunction ( 'extractMainContent returns null for empty body' , async ( ) => {
2016+ const html = '<html><body></body></html>' ;
2017+ const result = extractMainContent ( html , 'http://test.local' ) ;
2018+ assert . equal ( result , null ) ;
2019+ } , results ) ;
2020+
2021+ await testFunction ( 'extractMainContent preserves heading hierarchy' , async ( ) => {
2022+ const html = `<html><body>
2023+ <article><h1>Title</h1><h2>Subtitle</h2><p>Body text.</p></article>
2024+ </body></html>` ;
2025+
2026+ const result = extractMainContent ( html , 'http://test.local' ) ;
2027+ assert . ok ( result ) ;
2028+ assert . ok ( result . includes ( 'Title' ) ) ;
2029+ assert . ok ( result . includes ( 'Subtitle' ) ) ;
2030+ } , results ) ;
2031+
2032+ await testFunction ( 'extractMainContent is not called when extractMainContent is false' , async ( ) => {
2033+ const mockServer = createMockServer ( ) ;
2034+ urlCache . clear ( ) ;
2035+
2036+ const { url, close } = await startTestServer ( {
2037+ body : '<html><body><nav>Nav</nav><article><h1>Article</h1></article></body></html>' ,
2038+ } ) ;
2039+
2040+ try {
2041+ const result = await fetchAndConvertToMarkdown ( mockServer as any , url , 10000 , {
2042+ extractMainContent : false ,
2043+ } ) ;
2044+ assert . ok ( result . includes ( 'Nav' ) , 'Expected full HTML when extractMainContent is false' ) ;
2045+ assert . ok ( result . includes ( 'Article' ) , 'Expected article content' ) ;
2046+ } finally {
2047+ await close ( ) ;
2048+ urlCache . clear ( ) ;
2049+ }
2050+ } , results ) ;
2051+
2052+ await testFunction ( 'extractMainContent defaults to on via fetchAndConvertToMarkdown' , async ( ) => {
2053+ const mockServer = createMockServer ( ) ;
2054+ urlCache . clear ( ) ;
2055+
2056+ const { url, close } = await startTestServer ( {
2057+ body : '<html><body><nav>Nav</nav><article><h1>Article</h1></article></body></html>' ,
2058+ } ) ;
2059+
2060+ try {
2061+ const result = await fetchAndConvertToMarkdown ( mockServer as any , url ) ;
2062+ assert . ok ( ! result . includes ( 'Nav' ) , 'Expected nav stripped by default' ) ;
2063+ assert . ok ( result . includes ( 'Article' ) , 'Expected article content' ) ;
2064+ } finally {
2065+ await close ( ) ;
2066+ urlCache . clear ( ) ;
2067+ }
2068+ } , results ) ;
2069+
2070+ await testFunction ( 'extractMainContent handles malformed HTML gracefully' , async ( ) => {
2071+ const result = extractMainContent ( '<html><body><p>Hello' , 'http://test.local' ) ;
2072+ assert . ok ( result === null || result . includes ( 'Hello' ) ) ;
2073+ } , results ) ;
2074+
2075+ // ── metadata extraction ────────────────────────────────────────────────────
2076+
2077+ await testFunction ( 'extractMetadata pulls title from og:title' , async ( ) => {
2078+ const html = `<html><head>
2079+ <title>Fallback Title</title>
2080+ <meta property="og:title" content="OG Title">
2081+ </head></html>` ;
2082+
2083+ const meta = extractMetadata ( html , 'http://test.local' ) ;
2084+ assert . equal ( meta . title , 'OG Title' ) ;
2085+ } , results ) ;
2086+
2087+ await testFunction ( 'extractMetadata falls back to title tag' , async ( ) => {
2088+ const html = `<html><head>
2089+ <title>Document Title</title>
2090+ </head></html>` ;
2091+
2092+ const meta = extractMetadata ( html , 'http://test.local' ) ;
2093+ assert . equal ( meta . title , 'Document Title' ) ;
2094+ } , results ) ;
2095+
2096+ await testFunction ( 'extractMetadata extracts author' , async ( ) => {
2097+ const html = `<html><head>
2098+ <meta name="author" content="Test Author">
2099+ </head></html>` ;
2100+
2101+ const meta = extractMetadata ( html , 'http://test.local' ) ;
2102+ assert . equal ( meta . author , 'Test Author' ) ;
2103+ } , results ) ;
2104+
2105+ await testFunction ( 'extractMetadata extracts publish date' , async ( ) => {
2106+ const html = `<html><head>
2107+ <meta property="article:published_time" content="2024-01-15">
2108+ </head></html>` ;
2109+
2110+ const meta = extractMetadata ( html , 'http://test.local' ) ;
2111+ assert . equal ( meta . publishedDate , '2024-01-15' ) ;
2112+ } , results ) ;
2113+
2114+ await testFunction ( 'extractMetadata extracts description' , async ( ) => {
2115+ const html = `<html><head>
2116+ <meta property="og:description" content="Test description">
2117+ </head></html>` ;
2118+
2119+ const meta = extractMetadata ( html , 'http://test.local' ) ;
2120+ assert . equal ( meta . description , 'Test description' ) ;
2121+ } , results ) ;
2122+
2123+ await testFunction ( 'extractMetadata extracts site name' , async ( ) => {
2124+ const html = `<html><head>
2125+ <meta property="og:site_name" content="Test Site">
2126+ </head></html>` ;
2127+
2128+ const meta = extractMetadata ( html , 'http://test.local' ) ;
2129+ assert . equal ( meta . siteName , 'Test Site' ) ;
2130+ } , results ) ;
2131+
2132+ await testFunction ( 'extractMetadata returns empty object for no metadata' , async ( ) => {
2133+ const html = '<html><head></head><body><p>No metadata here.</p></body></html>' ;
2134+ const meta = extractMetadata ( html , 'http://test.local' ) ;
2135+ assert . deepEqual ( meta , { } ) ;
2136+ } , results ) ;
2137+
2138+ await testFunction ( 'extractMetadata handles malformed HTML gracefully' , async ( ) => {
2139+ const meta = extractMetadata ( '<html><head><meta' , 'http://test.local' ) ;
2140+ assert . ok ( typeof meta === 'object' ) ;
2141+ } , results ) ;
2142+
2143+ // ── metadata block integration ─────────────────────────────────────────────
2144+
2145+ await testFunction ( 'metadata is prepended as YAML block with Readability body' , async ( ) => {
2146+ const mockServer = createMockServer ( ) ;
2147+ urlCache . clear ( ) ;
2148+
2149+ const html = `<html><head>
2150+ <title>Test Page</title>
2151+ <meta name="author" content="Author Name">
2152+ <meta property="og:description" content="Page description">
2153+ </head><body>
2154+ <nav>Skip this</nav>
2155+ <article><h1>Hello World</h1><p>Article body here.</p></article>
2156+ </body></html>` ;
2157+
2158+ const { url, close } = await startTestServer ( { body : html } ) ;
2159+
2160+ try {
2161+ const result = await fetchAndConvertToMarkdown ( mockServer as any , url ) ;
2162+ assert . ok ( result . startsWith ( '---' ) , 'Expected YAML frontmatter' ) ;
2163+ assert . ok ( result . includes ( 'title: Test Page' ) ) ;
2164+ assert . ok ( result . includes ( 'author: Author Name' ) ) ;
2165+ assert . ok ( result . includes ( 'description: Page description' ) ) ;
2166+ assert . ok ( ! result . includes ( 'Skip this' ) , 'Expected nav stripped' ) ;
2167+ } finally {
2168+ await close ( ) ;
2169+ urlCache . clear ( ) ;
2170+ }
2171+ } , results ) ;
2172+
2173+ await testFunction ( 'extractMetadata false skips metadata block' , async ( ) => {
2174+ const mockServer = createMockServer ( ) ;
2175+ urlCache . clear ( ) ;
2176+
2177+ const html = `<html><head>
2178+ <meta property="og:title" content="Title">
2179+ </head><body><h1>Hello</h1></body></html>` ;
2180+
2181+ const { url, close } = await startTestServer ( { body : html } ) ;
2182+
2183+ try {
2184+ const result = await fetchAndConvertToMarkdown ( mockServer as any , url , 10000 , {
2185+ extractMetadata : false ,
2186+ } ) ;
2187+ assert . ok ( ! result . startsWith ( '---' ) , 'Expected no YAML frontmatter' ) ;
2188+ assert . ok ( result . includes ( 'Hello' ) ) ;
2189+ } finally {
2190+ await close ( ) ;
2191+ urlCache . clear ( ) ;
2192+ }
2193+ } , results ) ;
2194+
2195+ await testFunction ( 'extractMainContent false preserves full HTML when metadata is also off' , async ( ) => {
2196+ const mockServer = createMockServer ( ) ;
2197+ urlCache . clear ( ) ;
2198+
2199+ const html = '<html><body><nav>Nav</nav><p>Just text.</p></body></html>' ;
2200+ const { url, close } = await startTestServer ( { body : html } ) ;
2201+
2202+ try {
2203+ const result = await fetchAndConvertToMarkdown ( mockServer as any , url , 10000 , {
2204+ extractMainContent : false ,
2205+ extractMetadata : false ,
2206+ } ) ;
2207+ assert . ok ( result . includes ( 'Nav' ) , 'Expected Nav to be present' ) ;
2208+ assert . ok ( result . includes ( 'Just text' ) ) ;
2209+ } finally {
2210+ await close ( ) ;
2211+ urlCache . clear ( ) ;
2212+ }
2213+ } , results ) ;
2214+
19972215 if ( originalAllowPrivateUrls === undefined ) {
19982216 delete process . env . MCP_HTTP_ALLOW_PRIVATE_URLS ;
19992217 } else {
0 commit comments