Files
jackwener__opencli/tests/e2e/article-download-pipeline.test.ts
jakevin e83148a2c1 feat(download): harden HTML→Markdown pipeline (#1143)
* feat(download): harden HTML→Markdown pipeline

Inspired by the MD-This-Page / markdown-viewer-extension analysis, tighten
the shared article→Markdown converter used by zhihu/weixin/web adapters:

- enable turndown-plugin-gfm (tables, strikethrough, task lists)
- strip script/style/noscript/iframe/canvas/form/button/dialog unconditionally
- strip SVG via a dedicated rule (not in HTMLElementTagNameMap)
- drop base64 data-URI images so they don't bloat .md output
- post-process: collapse NBSP, lone bullet/middle-dot residue,
  trailing whitespace, and 3+ blank lines
- frontmatter shape guarantees ≤2 consecutive newlines even when
  some metadata fields are absent

Adds a minimal local .d.ts for turndown-plugin-gfm and 6 new tests
covering GFM conversion, tag stripping, base64 drop, and whitespace cleanup.

* fix(download): emit canonical markdown strikethrough

* feat(download,browser): finish article pipeline polish

Per the follow-up from the MD-This-Page / markdown-viewer-extension
analysis, land the remaining items in the same PR instead of splitting:

article-download.ts
- extend STRIPPED_TAGS with header/footer/nav/aside (page chrome; the
  article's title/author/publishTime are supplied as separate fields on
  ArticleData, so duplicated DOM is redundant)
- new option ArticleDownloadOptions.cleanSelectors — per-adapter CSS
  selector list removed before conversion, applied as a Turndown rule
  via node.matches so invalid selectors fail silently

browser/article-extract.ts (new)
- generic Readability-based extraction that runs in-page via CDP
  evaluate (no jsdom in Node)
- short-circuits non-HTML documents (text/plain, JSON, XML) and the
  single-<pre> "browser rendering a plain text file" case
- clones the document before any mutation (preserves live page state
  for subsequent snapshot / click)
- isProbablyReaderable gate, Readability.parse on the clone, then a
  fallback chain main → [role="main"] → #main-content → … → body
- library sources are JSON-embedded and eval'd inside a Function scope
  so their backticks / module.exports guards don't collide with the
  surrounding IIFE

Tests
- article-download: page-chrome strip, cleanSelectors match + invalid
  selector silently ignored (2 new)
- article-extract: JS generation contents, default fallback chain,
  response normalization, null / malformed handling, and a Function()
  parse check to catch any template-literal break-out in the embedded
  Readability sources (8 new)

* fix(download): honor selector cleanup in fallback paths

* test(e2e): real-site regression for hardened article pipeline

Adds tests/e2e/article-download-pipeline.test.ts driving `opencli web read`
through 6 representative pages (example.com baseline, Wikipedia GFM tables,
MDN metadata, GitHub fenced code, Vercel SSR blog, Ruan Yifeng CJK+images)
and asserting the post-processing invariants: no base64/script/style leaks,
no blank-line runs, no residue, no trailing whitespace, no NBSP.

Graceful skip on bot detection / transient CDP errors, with a single retry.

All 6 sites pass locally (37s total).

* test(browser): add article extraction e2e fixtures
2026-04-22 14:49:35 +08:00

175 lines
6.0 KiB
TypeScript

/**
* E2E regression tests for the HTML → Markdown article pipeline.
*
* Drives real pages through `opencli web read` and asserts the hardened
* converter's invariants hold on the produced file:
* - no base64 `data:image/…` leaks
* - no <script> / <style> leakage
* - no runs of 3+ blank lines
* - no lone `-` / `·` residue lines
* - no trailing-whitespace lines
* - no NBSP residue
*
* Sites are picked to cover the features the pipeline claims to support:
* example.com — baseline / tiny article
* Wikipedia — GFM tables, many headings, long content
* MDN — meta-tag extracted author + published_time
* GitHub README — fenced code blocks, dense chrome
* Vercel blog — JS-heavy SSR, publish_time from schema.org
* Ruan Yifeng blog — CJK, inline images, multi-link paragraphs
*
* Each run exits cleanly with `status: 'success'` or is skipped on transient
* / bot-detection failures (mirroring `browser-public.test.ts` patterns).
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { runCli, parseJsonOutput } from './helpers.js';
interface WebReadResult {
title: string;
author: string;
publish_time: string;
status: string;
size: string;
saved: string;
}
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
}
tempDirs.length = 0;
});
function isTransient(text: string): boolean {
return /Detached while handling command|No tab with id|Debugger is not attached|Browser Bridge.*not connected|net::ERR_/i.test(text);
}
async function runWebReadOrSkip(
url: string,
label: string,
): Promise<WebReadResult | null> {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-article-e2e-'));
tempDirs.push(tempDir);
const args = ['web', 'read', '--url', url, '--output', tempDir, '--download-images', 'false', '--format', 'json'];
let result = await runCli(args, { timeout: 90_000 });
if (result.code !== 0 && isTransient(result.stderr + result.stdout)) {
result = await runCli(args, { timeout: 90_000 });
}
if (result.code !== 0) {
console.warn(`${label}: skipped — CLI failed (likely bot detection / network): ${result.stderr.slice(0, 200)}`);
return null;
}
let parsed: WebReadResult[];
try {
parsed = parseJsonOutput(result.stdout);
} catch {
console.warn(`${label}: skipped — CLI output was not JSON`);
return null;
}
if (!Array.isArray(parsed) || parsed.length === 0) {
console.warn(`${label}: skipped — empty result array`);
return null;
}
const row = parsed[0];
if (row.status !== 'success') {
console.warn(`${label}: skipped — status=${row.status} (${row.saved})`);
return null;
}
return row;
}
function assertPipelineInvariants(md: string, label: string) {
expect(md.length, `${label}: markdown should be non-trivial`).toBeGreaterThan(200);
expect(md.match(/data:image/g) ?? [], `${label}: no base64 data-URI images`).toHaveLength(0);
expect(md.match(/<script[\s>]/gi) ?? [], `${label}: no leaked <script> tags`).toHaveLength(0);
expect(md.match(/<style[\s>]/gi) ?? [], `${label}: no leaked <style> tags`).toHaveLength(0);
// The post-processing pipeline guarantees blank-line collapse to at most 2.
expect(md).not.toMatch(/\n{3,}/);
// Lone dash / middle-dot residue from lost list bullets.
expect(md).not.toMatch(/^[ \t]*-[ \t]*$/m);
expect(md).not.toMatch(/^[ \t]*·[ \t]*$/m);
// Trailing whitespace should never appear (stripped in post-processing).
expect(md).not.toMatch(/[ \t]+\n/);
// NBSP should be normalized to a regular space.
expect(md.match(/\u00a0/g) ?? [], `${label}: NBSP should be normalized`).toHaveLength(0);
}
interface SiteCase {
url: string;
label: string;
extra?: (md: string) => void;
}
const SITES: SiteCase[] = [
{
url: 'https://example.com/',
label: 'example.com (baseline)',
},
{
url: 'https://en.wikipedia.org/wiki/Markdown',
label: 'Wikipedia — Markdown (GFM tables + headings)',
extra: (md) => {
// Wikipedia's Markdown article contains several tables — the hardened
// converter with turndown-plugin-gfm should produce real `|---|---|` rows.
expect(md.match(/^\|.*---.*\|/gm) ?? []).not.toHaveLength(0);
expect(md.match(/^## /gm) ?? []).not.toHaveLength(0);
},
},
{
url: 'https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table',
label: 'MDN — <table> element',
},
{
url: 'https://github.com/mozilla/readability',
label: 'GitHub — mozilla/readability README',
extra: (md) => {
// README uses fenced code blocks extensively; ensure a few survived.
expect(md.match(/^```/gm) ?? [], 'fenced code blocks preserved').not.toHaveLength(0);
},
},
{
url: 'https://vercel.com/blog/vercel-ship-2024',
label: 'Vercel blog — Ship 2024 recap (JS-heavy SSR)',
},
{
url: 'https://www.ruanyifeng.com/blog/2024/07/weekly-issue-309.html',
label: 'Ruan Yifeng blog — CJK + image-dense',
extra: (md) => {
// Chinese site with many inline images. The extractor should preserve
// both CJK text and remote image URLs (not drop them like base64 would).
expect(md).toMatch(/[\u4e00-\u9fff]/); // contains at least one CJK char
expect(md.match(/^!\[.*?\]\(https?:\/\//gm) ?? []).not.toHaveLength(0);
},
},
];
describe('web read — hardened article pipeline (real-site regression)', () => {
for (const site of SITES) {
it(`${site.label} survives the hardened pipeline`, async () => {
const row = await runWebReadOrSkip(site.url, site.label);
if (!row) return;
expect(row.saved, `${site.label}: saved path present`).toBeTruthy();
expect(fs.existsSync(row.saved)).toBe(true);
const md = fs.readFileSync(row.saved, 'utf8');
assertPipelineInvariants(md, site.label);
if (site.extra) site.extra(md);
}, 120_000);
}
});