sync: update from private repo (dfadcd5f)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-23 06:38:48 +00:00
parent 6a2f2cc736
commit 29ccaf1e92
377 changed files with 31028 additions and 8994 deletions
+121
View File
@@ -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');
});
});
+165
View File
@@ -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;
}