import { describe, it, expect } from 'vitest'; import { extractReadableMarkdown, truncateForAgent } from './readability.js'; const ARTICLE_HTML = ` Test Article Title
SITE BANNER — SUBSCRIBE NOW — ADVERTISEMENT

The Main Heading

The first paragraph of the article body, with a helpful link inside it. This needs to be long enough that readability accepts it as real content rather than boilerplate noise.

A Subsection

NameValue
alpha1
beta2

A closing paragraph with sufficient length so the readability heuristics treat this document as a genuine article and extract the body cleanly. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

`; describe('extractReadableMarkdown', () => { it('記事本文を抽出しボイラープレートを除去する', () => { const result = extractReadableMarkdown(ARTICLE_HTML); expect(result).not.toBeNull(); const md = result!.markdown; // 本文は残る expect(md).toContain('first paragraph of the article body'); expect(md).toContain('closing paragraph'); // ナビ・広告・フッターは除去される expect(md).not.toContain('SITE BANNER'); expect(md).not.toContain('FOOTER COPYRIGHT'); expect(md).not.toContain('PRIVACY POLICY'); }); it('見出しが Markdown の atx 記法で残る', () => { const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown; expect(md).toMatch(/#+\s+The Main Heading/); expect(md).toMatch(/#+\s+A Subsection/); }); it('リストが Markdown 箇条書きで残る', () => { const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown; expect(md).toMatch(/-\s+first bullet point/); expect(md).toMatch(/-\s+second bullet point/); }); it('テーブルが GFM テーブル記法で残る', () => { const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown; expect(md).toContain('| Name | Value |'); expect(md).toContain('| --- | --- |'); expect(md).toMatch(/\| alpha \| 1 \|/); }); it('リンクが Markdown リンク記法で残る', () => { const md = extractReadableMarkdown(ARTICLE_HTML)!.markdown; expect(md).toContain('[helpful link](https://example.com/page)'); }); it('タイトルを抽出する', () => { const result = extractReadableMarkdown(ARTICLE_HTML); expect(result!.title).toBe('Test Article Title'); }); it('空 HTML では null を返す(フォールバック用)', () => { expect(extractReadableMarkdown('')).toBeNull(); expect(extractReadableMarkdown(' ')).toBeNull(); }); it('本文のないガベージ HTML では null を返す', () => { const garbage = '
'; expect(extractReadableMarkdown(garbage)).toBeNull(); }); it('selector 指定時はそのサブツリーだけを抽出する', () => { const html = `

Picked Section

only this part should be extracted here

this other part must NOT appear in the output

`; const result = extractReadableMarkdown(html, { selector: '#wanted' }); expect(result).not.toBeNull(); expect(result!.markdown).toContain('Picked Section'); expect(result!.markdown).toContain('only this part'); expect(result!.markdown).not.toContain('other part'); }); it('selector が見つからなければ null を返す(フォールバック用)', () => { expect(extractReadableMarkdown(ARTICLE_HTML, { selector: '#does-not-exist' })).toBeNull(); }); it('baseUrl 指定時にルート相対リンクを絶対化する', () => { const html = `

Heading For Length

A paragraph long enough to pass readability with a root relative link embedded so we can verify absolutization works correctly here. Lorem ipsum dolor sit amet consectetur.

`; const md = extractReadableMarkdown(html, { baseUrl: 'https://site.example/blog/post' })!.markdown; expect(md).toContain('https://site.example/docs/page'); expect(md).not.toContain('](/docs/page)'); }); }); describe('truncateForAgent', () => { it('上限以下ならそのまま返す', () => { expect(truncateForAgent('short', 100)).toBe('short'); }); it('上限超過で切り詰め、source 保存済みなら full 版の在処を案内する', () => { const long = 'x'.repeat(50); const out = truncateForAgent(long, 10, 'article.md'); expect(out.startsWith('x'.repeat(10))).toBe(true); expect(out).toContain('full version saved to source/article.md'); }); it('上限超過で source 未保存なら素の truncated note を付ける', () => { const out = truncateForAgent('y'.repeat(50), 10, null); expect(out).toContain('... (truncated)'); expect(out).not.toContain('saved to source'); }); });