Files
longbridge__developers/scripts/opencli/dom-diff.ts
twosugar 53666718af feat(migrate): opencli — url/dom/visual/interaction diff toolkit
- crawl-routes.ts: parse sitemap XML (flat + sitemapindex) → Route[]
- url-diff.ts: symmetric set diff (A △ B = ∅) hard gate for T18
- snapshot.ts: per-URL HTML capture (fetch mode); MCP screenshots documented
- dom-diff.ts: heading/links/codeblocks/components Jaccard ≥ 0.95
- visual-diff.ts: odiff-bin wrapper, 0.1% pixel threshold
- interaction-assertions.ts: theme/search/sidebar/copy/component checks (MCP ref)
- report.ts: aggregate results → dist-diff/report.md + report.json
- README.md: 5 layers + MCP-driven canonical flow

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-17 19:46:49 +08:00

297 lines
9.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
/**
* dom-diff.ts — Structural DOM comparison between old and new page HTML.
*
* Uses node-html-parser (Bun-compatible; no native deps).
* Compares heading sequence, internal link set, code block count, and
* data-lbus-component values. Passes if Jaccard similarity ≥ 0.95.
*
* Usage:
* bun run scripts/opencli/dom-diff.ts <old.html> <new.html> [options]
* bun run scripts/opencli/dom-diff.ts --help
*
* Exit codes:
* 0 similarity ≥ threshold (pass)
* 1 similarity below threshold (fail)
*/
import { parse, type HTMLElement } from "node-html-parser";
export const DEFAULT_THRESHOLD = 0.95;
export interface DomDiffIssue {
type: "heading" | "links" | "codeblocks" | "components";
description: string;
similarity: number;
}
export interface DomDiffResult {
score: number;
pass: boolean;
issues: DomDiffIssue[];
details: {
headings: { old: string[]; new: string[]; jaccard: number };
links: { old: string[]; new: string[]; jaccard: number };
codeBlocks: { old: number; new: number };
components: { old: string[]; new: string[]; jaccard: number };
};
}
// ---- Normalization helpers ----
/** Strip data-astro-* attributes and normalize class hash suffixes. */
function standardizeHtml(root: HTMLElement): void {
const all = root.querySelectorAll("*");
for (const el of all) {
// Remove Astro-specific attributes that differ between builds
for (const attr of Object.keys(el.attributes)) {
if (attr.startsWith("data-astro-")) el.removeAttribute(attr);
}
// Normalize class: strip Astro hash suffixes like "astro-XXXX"
const cls = el.getAttribute("class");
if (cls) {
const normalized = cls
.split(/\s+/)
.filter((c) => !/^astro-[A-Z0-9]{6,}$/.test(c))
.join(" ")
.trim();
if (normalized) el.setAttribute("class", normalized);
else el.removeAttribute("class");
}
}
}
/** Extract main content element or fall back to body. */
function getContent(root: HTMLElement): HTMLElement {
return (root.querySelector("main") ?? root.querySelector("body") ?? root) as HTMLElement;
}
/** Extract heading sequence with text content (h1-h6). */
function extractHeadings(content: HTMLElement): string[] {
return content
.querySelectorAll("h1,h2,h3,h4,h5,h6")
.map((h) => `${h.tagName}:${h.text.replace(/\s+/g, " ").trim()}`);
}
/** Extract internal link href values. */
function extractInternalLinks(content: HTMLElement, baseOrigin?: string): string[] {
return content
.querySelectorAll("a[href]")
.map((a) => a.getAttribute("href") ?? "")
.filter((href) => {
if (!href) return false;
if (href.startsWith("#")) return false;
if (href.startsWith("/")) return true;
if (baseOrigin && href.startsWith(baseOrigin)) return true;
return !href.startsWith("http"); // relative
})
.map((href) => href.replace(/\/$/, "") || "/");
}
/** Count code blocks (pre > code or code[class*="language-"]). */
function countCodeBlocks(content: HTMLElement): number {
const preCode = content.querySelectorAll("pre code").length;
const langCode = content.querySelectorAll("code[class]").length;
return Math.max(preCode, langCode);
}
/** Extract data-lbus-component values. */
function extractComponents(content: HTMLElement): string[] {
return content
.querySelectorAll("[data-lbus-component]")
.map((el) => el.getAttribute("data-lbus-component") ?? "")
.filter(Boolean);
}
// ---- Jaccard similarity ----
function jaccardSets(a: string[], b: string[]): number {
if (a.length === 0 && b.length === 0) return 1;
const setA = new Set(a);
const setB = new Set(b);
let intersection = 0;
for (const item of setA) {
if (setB.has(item)) intersection++;
}
const union = new Set([...setA, ...setB]).size;
return union === 0 ? 1 : intersection / union;
}
/** Sequence similarity: ordered (LCS-based proportion). */
function sequenceSimilarity(a: string[], b: string[]): number {
if (a.length === 0 && b.length === 0) return 1;
// LCS length via DP
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
const lcs = dp[m][n];
return (2 * lcs) / (m + n);
}
// ---- Main export ----
/**
* Compare two page HTML strings structurally.
* Returns a score (01) and detailed issues list.
*/
export function domSimilarity(
oldHtml: string,
newHtml: string,
threshold = DEFAULT_THRESHOLD,
): DomDiffResult {
const oldRoot = parse(oldHtml);
const newRoot = parse(newHtml);
standardizeHtml(oldRoot);
standardizeHtml(newRoot);
const oldContent = getContent(oldRoot);
const newContent = getContent(newRoot);
const oldHeadings = extractHeadings(oldContent);
const newHeadings = extractHeadings(newContent);
const headingJaccard = sequenceSimilarity(oldHeadings, newHeadings);
const oldLinks = extractInternalLinks(oldContent);
const newLinks = extractInternalLinks(newContent);
const linkJaccard = jaccardSets(oldLinks, newLinks);
const oldCodeCount = countCodeBlocks(oldContent);
const newCodeCount = countCodeBlocks(newContent);
const codeRatio =
oldCodeCount === 0 && newCodeCount === 0
? 1
: oldCodeCount === 0 || newCodeCount === 0
? 0
: Math.min(oldCodeCount, newCodeCount) / Math.max(oldCodeCount, newCodeCount);
const oldComponents = extractComponents(oldContent);
const newComponents = extractComponents(newContent);
const componentJaccard = jaccardSets(oldComponents, newComponents);
// Composite score: weighted average
const score =
headingJaccard * 0.4 +
linkJaccard * 0.3 +
codeRatio * 0.15 +
componentJaccard * 0.15;
const issues: DomDiffIssue[] = [];
if (headingJaccard < threshold) {
issues.push({
type: "heading",
description: `Heading sequence mismatch: ${oldHeadings.length} old vs ${newHeadings.length} new`,
similarity: headingJaccard,
});
}
if (linkJaccard < threshold) {
issues.push({
type: "links",
description: `Internal link set mismatch: ${oldLinks.length} old vs ${newLinks.length} new`,
similarity: linkJaccard,
});
}
if (codeRatio < threshold) {
issues.push({
type: "codeblocks",
description: `Code block count mismatch: ${oldCodeCount} old vs ${newCodeCount} new`,
similarity: codeRatio,
});
}
if (componentJaccard < threshold) {
issues.push({
type: "components",
description: `data-lbus-component mismatch: [${oldComponents.join(",")}] vs [${newComponents.join(",")}]`,
similarity: componentJaccard,
});
}
return {
score,
pass: score >= threshold,
issues,
details: {
headings: { old: oldHeadings, new: newHeadings, jaccard: headingJaccard },
links: { old: oldLinks, new: newLinks, jaccard: linkJaccard },
codeBlocks: { old: oldCodeCount, new: newCodeCount },
components: { old: oldComponents, new: newComponents, jaccard: componentJaccard },
},
};
}
// ---- CLI main ----
function printHelp() {
console.log(`
dom-diff.ts — structural DOM comparison between two page HTML files.
Usage:
bun run scripts/opencli/dom-diff.ts <old.html> <new.html> [options]
bun run scripts/opencli/dom-diff.ts --help
Arguments:
<old.html> Baseline HTML snapshot (vitepress)
<new.html> Candidate HTML snapshot (astro)
Options:
--threshold <n> Jaccard pass threshold 01 (default: ${DEFAULT_THRESHOLD})
--json Output machine-readable JSON
--help Show this help
Exit codes:
0 Pass (score ≥ threshold)
1 Fail (score < threshold)
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes("--help") || args.length === 0) {
printHelp();
process.exit(0);
}
const jsonMode = args.includes("--json");
const threshIdx = args.indexOf("--threshold");
const threshold = threshIdx !== -1 ? parseFloat(args[threshIdx + 1]) : DEFAULT_THRESHOLD;
const positional = args.filter((a) => !a.startsWith("--"));
if (positional.length < 2) {
console.error("Error: two HTML file paths required. Run --help for usage.");
process.exit(1);
}
const [oldPath, newPath] = positional;
const oldHtml = await Bun.file(oldPath).text();
const newHtml = await Bun.file(newPath).text();
const result = domSimilarity(oldHtml, newHtml, threshold);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`DOM similarity score: ${(result.score * 100).toFixed(1)}%`);
console.log(`Threshold: ${(threshold * 100).toFixed(0)}% — ${result.pass ? "PASS" : "FAIL"}`);
if (result.issues.length > 0) {
console.log("\nIssues:");
for (const issue of result.issues) {
console.log(` [${issue.type}] ${issue.description} (similarity: ${(issue.similarity * 100).toFixed(1)}%)`);
}
}
}
process.exit(result.pass ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});