This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractReadableMarkdown, truncateForAgent } from './readability.js';
|
||||
|
||||
const ARTICLE_HTML = `<!DOCTYPE html>
|
||||
<html><head><title>Test Article Title</title></head>
|
||||
<body>
|
||||
<nav><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav>
|
||||
<header>SITE BANNER — SUBSCRIBE NOW — ADVERTISEMENT</header>
|
||||
<aside>Related links sidebar junk and tracking pixels</aside>
|
||||
<article>
|
||||
<h1>The Main Heading</h1>
|
||||
<p>The first paragraph of the article body, with a <a href="https://example.com/page">helpful link</a> inside it. This needs to be long enough that readability accepts it as real content rather than boilerplate noise.</p>
|
||||
<h2>A Subsection</h2>
|
||||
<ul><li>first bullet point</li><li>second bullet point</li><li>third bullet point</li></ul>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Value</th></tr></thead>
|
||||
<tbody><tr><td>alpha</td><td>1</td></tr><tr><td>beta</td><td>2</td></tr></tbody>
|
||||
</table>
|
||||
<p>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.</p>
|
||||
</article>
|
||||
<footer>FOOTER COPYRIGHT — PRIVACY POLICY — TERMS</footer>
|
||||
</body></html>`;
|
||||
|
||||
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 = '<html><body><div></div><span></span></body></html>';
|
||||
expect(extractReadableMarkdown(garbage)).toBeNull();
|
||||
});
|
||||
|
||||
it('selector 指定時はそのサブツリーだけを抽出する', () => {
|
||||
const html = `<html><body>
|
||||
<div id="wanted"><h2>Picked Section</h2><p>only this part should be extracted here</p></div>
|
||||
<div id="other"><p>this other part must NOT appear in the output</p></div>
|
||||
</body></html>`;
|
||||
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 = `<html><body><article>
|
||||
<h1>Heading For Length</h1>
|
||||
<p>A paragraph long enough to pass readability with a <a href="/docs/page">root relative link</a> embedded so we can verify absolutization works correctly here. Lorem ipsum dolor sit amet consectetur.</p>
|
||||
</article></body></html>`;
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Readability ベースのクリーン本文抽出ヘルパー。
|
||||
*
|
||||
* WebFetch が取得した生 HTML を「記事本文だけ」のクリーンな Markdown に変換する。
|
||||
* ナビ・広告・ヘッダー・フッター等のボイラープレートを除去し、見出し・リスト・
|
||||
* テーブル・リンクといった構造を保ったまま Markdown 化する。
|
||||
*
|
||||
* パイプライン: HTML → linkedom で DOM 化 → @mozilla/readability で記事抽出
|
||||
* → turndown(+gfm) で HTML→Markdown 変換。
|
||||
*
|
||||
* 抽出に失敗(非記事ページ・SPA・空など)したら null を返す。呼び出し側は
|
||||
* 既存の素朴な htmlToText にフォールバックして WebFetch を絶対に hard-fail させない。
|
||||
*
|
||||
* 注意: これはヘルパーモジュールであり、ツールではない(TOOL_DEFS を持たない)。
|
||||
*
|
||||
* ESM/CJS 注意: turndown は CJS-main / turndown-plugin-gfm も CJS-main。
|
||||
* ESM ビルド("type":"module")で `not a constructor` 系の罠を踏まないよう、
|
||||
* default / named import を明示し、dist 実機での import を検証済み
|
||||
* (@kenjiuno/msgreader インシデントの教訓)。
|
||||
*/
|
||||
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import TurndownService from 'turndown';
|
||||
import { gfm } from 'turndown-plugin-gfm';
|
||||
import { logger } from '../../../logger.js';
|
||||
|
||||
/** クリーン抽出の結果 */
|
||||
export interface ReadableExtraction {
|
||||
/** 記事タイトル(取得できれば) */
|
||||
title?: string;
|
||||
/** クリーンな Markdown 本文(全文・未切り詰め) */
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* turndown インスタンスは生成コストがあるので使い回す。
|
||||
* - headingStyle: atx(`#` 記法)
|
||||
* - codeBlockStyle: fenced(``` 記法)
|
||||
* - gfm プラグインでテーブル・打消し線・タスクリストを Markdown 化
|
||||
*/
|
||||
let _turndown: TurndownService | null = null;
|
||||
function getTurndown(): TurndownService {
|
||||
if (_turndown) return _turndown;
|
||||
const td = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
hr: '---',
|
||||
});
|
||||
td.use(gfm);
|
||||
// 画像は alt テキスト + URL を残す(agent が参照できるように)
|
||||
_turndown = td;
|
||||
return _turndown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生 HTML から記事本文を抽出してクリーンな Markdown を返す。
|
||||
* 抽出できなければ null(呼び出し側で htmlToText にフォールバック)。
|
||||
*
|
||||
* @param html 生 HTML 文字列
|
||||
* @param opts.baseUrl 相対リンク解決用の URL(任意)。Markdown 内の相対リンクを絶対化する
|
||||
* @param opts.selector CSS セレクタ(任意)。指定すると、その要素のサブツリーだけを
|
||||
* クリーン抽出する。要素が見つからなければ null(呼び出し側でフォールバック)。
|
||||
*/
|
||||
export function extractReadableMarkdown(
|
||||
html: string,
|
||||
opts: { baseUrl?: string; selector?: string } = {},
|
||||
): ReadableExtraction | null {
|
||||
if (!html || html.trim().length === 0) return null;
|
||||
const { baseUrl, selector } = opts;
|
||||
|
||||
let articleHtml: string;
|
||||
let title: string | undefined;
|
||||
try {
|
||||
// linkedom で DOM を構築。
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
// selector 指定時は、その要素サブツリーを直接 Markdown 化する。
|
||||
if (selector) {
|
||||
let el: Element | null = null;
|
||||
try {
|
||||
el = document.querySelector(selector) as unknown as Element | null;
|
||||
} catch {
|
||||
el = null; // 不正なセレクタは無視(フォールバックさせる)
|
||||
}
|
||||
if (!el) return null;
|
||||
const inner = (el as unknown as { innerHTML?: string }).innerHTML ?? '';
|
||||
if (!inner.trim()) return null;
|
||||
articleHtml = inner;
|
||||
const docTitle = (document as unknown as { title?: string }).title;
|
||||
title = docTitle?.trim() || undefined;
|
||||
} else {
|
||||
const reader = new Readability(document as unknown as Document, {
|
||||
// 短い記事でも諦めないよう閾値を下げる(デフォルト 500)
|
||||
charThreshold: 250,
|
||||
});
|
||||
const parsed = reader.parse();
|
||||
if (!parsed || !parsed.content || parsed.content.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
articleHtml = parsed.content;
|
||||
title = parsed.title?.trim() || undefined;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`[readability] extraction failed: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let markdown = getTurndown().turndown(articleHtml).trim();
|
||||
if (markdown.length === 0) return null;
|
||||
// baseUrl があれば、Markdown 内のルート相対 / プロトコル相対リンクを絶対化する。
|
||||
if (baseUrl) markdown = absolutizeLinks(markdown, baseUrl);
|
||||
return { ...(title ? { title } : {}), markdown };
|
||||
} catch (e) {
|
||||
logger.debug(`[readability] turndown conversion failed: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 内の相対リンク `](/path)` `](path)` を baseUrl 基準で絶対化する。
|
||||
* 既に絶対 (http/https/mailto/data 等) や アンカー (#) のものはそのまま。
|
||||
*/
|
||||
function absolutizeLinks(markdown: string, baseUrl: string): string {
|
||||
let base: URL;
|
||||
try {
|
||||
base = new URL(baseUrl);
|
||||
} catch {
|
||||
return markdown;
|
||||
}
|
||||
// Markdown のリンク / 画像の URL 部分のみ書き換える: ](URL) と ](URL "title")
|
||||
return markdown.replace(/(\]\()([^)\s]+)(\s+"[^"]*")?(\))/g, (_m, open, url, titlePart, close) => {
|
||||
let abs = url;
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('#')) {
|
||||
try {
|
||||
abs = new URL(url, base).toString();
|
||||
} catch {
|
||||
abs = url;
|
||||
}
|
||||
}
|
||||
return `${open}${abs}${titlePart ?? ''}${close}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* agent のコンテキストに返すために markdown を上限文字数で切り詰める。
|
||||
* 切り詰めた場合は note を付与(source 保存済みなら full 版の在処を案内)。
|
||||
*
|
||||
* @param markdown 全文クリーン Markdown
|
||||
* @param limit 上限文字数
|
||||
* @param savedSourceFile source/ に保存された相対ファイル名(あれば note に含める)
|
||||
*/
|
||||
export function truncateForAgent(
|
||||
markdown: string,
|
||||
limit: number,
|
||||
savedSourceFile?: string | null,
|
||||
): string {
|
||||
if (markdown.length <= limit) return markdown;
|
||||
const note = savedSourceFile
|
||||
? `\n\n... (truncated; full version saved to source/${savedSourceFile})`
|
||||
: '\n\n... (truncated)';
|
||||
return markdown.slice(0, limit) + note;
|
||||
}
|
||||
Reference in New Issue
Block a user