wip
This commit is contained in:
381
scripts/dom-export/buildDomDiffs.ts
Normal file
381
scripts/dom-export/buildDomDiffs.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
// Developer-only script: rewrite compact DOM dumps into step-to-step DIFFs.
|
||||
// NOT for production automation; intended to help understand UI transitions between numbered steps.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// 1. Generate compact DOM exports from HTML dumps (full DOM per step) using:
|
||||
// npx ts-node scripts/dom-export/exportHtmlDumps.ts
|
||||
//
|
||||
// 2. Then run THIS script to REPLACE those per-step JSON exports with DIFF objects:
|
||||
// npx ts-node scripts/dom-export/buildDomDiffs.ts html-dumps-optimized/iracing-hosted-sessions
|
||||
//
|
||||
// After step 2, each *.json in the target directory no longer contains the full DOM,
|
||||
// but only the diff relative to its predecessor.
|
||||
|
||||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
|
||||
type DomElement = {
|
||||
el: string;
|
||||
x?: string;
|
||||
t?: string;
|
||||
l?: string;
|
||||
p?: string;
|
||||
n?: string;
|
||||
i?: string;
|
||||
d?: string;
|
||||
r?: string;
|
||||
h?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type StepFile = {
|
||||
stepId: string;
|
||||
num: number;
|
||||
suffix: string;
|
||||
fileName: string;
|
||||
fullPath: string;
|
||||
};
|
||||
|
||||
type MatchedPair = {
|
||||
before: DomElement;
|
||||
after: DomElement;
|
||||
};
|
||||
|
||||
type DiffObject = {
|
||||
from: string | null;
|
||||
to: string;
|
||||
added: DomElement[];
|
||||
removed: DomElement[];
|
||||
modified: { before: DomElement; after: DomElement }[];
|
||||
};
|
||||
|
||||
type IdentityProp = "i" | "t" | "l" | "p" | "n";
|
||||
const identityProps: IdentityProp[] = ["i", "t", "l", "p", "n"];
|
||||
|
||||
function parseStepId(fileName: string): StepFile | null {
|
||||
const base = fileName.replace(/\.json$/i, "");
|
||||
const m = /^(\d+)([a-z]?)/i.exec(base);
|
||||
if (!m) return null;
|
||||
const num = parseInt(m[1], 10);
|
||||
const suffix = (m[2] || "").toLowerCase();
|
||||
return {
|
||||
stepId: m[1] + suffix,
|
||||
num,
|
||||
suffix,
|
||||
fileName,
|
||||
fullPath: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sortSteps(steps: StepFile[]): StepFile[] {
|
||||
return [...steps].sort((a, b) => {
|
||||
if (a.num !== b.num) return a.num - b.num;
|
||||
const sa = a.suffix || "";
|
||||
const sb = b.suffix || "";
|
||||
if (sa === sb) return 0;
|
||||
if (!sa) return -1;
|
||||
if (!sb) return 1;
|
||||
return sa.localeCompare(sb);
|
||||
});
|
||||
}
|
||||
|
||||
function getPropValue(e: DomElement, prop: IdentityProp | "r"): string | null {
|
||||
const raw = e[prop];
|
||||
if (typeof raw !== "string") return null;
|
||||
const value = raw.trim();
|
||||
return value.length ? value : null;
|
||||
}
|
||||
|
||||
function getIdentityKey(e: DomElement): string | null {
|
||||
for (const prop of identityProps) {
|
||||
const val = getPropValue(e, prop);
|
||||
if (val) return `${prop}:${val}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove repeated entries that have the same identity + role.
|
||||
function dedupeElements(elems: DomElement[]): DomElement[] {
|
||||
const seen = new Set<string>();
|
||||
const result: DomElement[] = [];
|
||||
for (const elem of elems) {
|
||||
const key = getIdentityKey(elem);
|
||||
if (!key) {
|
||||
result.push(elem);
|
||||
continue;
|
||||
}
|
||||
const role = getPropValue(elem, "r") || "";
|
||||
const composite = `${key}|role=${role}`;
|
||||
if (seen.has(composite)) continue;
|
||||
seen.add(composite);
|
||||
result.push(elem);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isSameElement(a: DomElement, b: DomElement): boolean {
|
||||
const fields: (keyof DomElement)[] = [
|
||||
"el",
|
||||
"t",
|
||||
"l",
|
||||
"p",
|
||||
"n",
|
||||
"i",
|
||||
"d",
|
||||
"r",
|
||||
];
|
||||
for (const f of fields) {
|
||||
const av = (a[f] as string | undefined) ?? null;
|
||||
const bv = (b[f] as string | undefined) ?? null;
|
||||
if (av !== bv) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function computePairs(
|
||||
before: DomElement[],
|
||||
after: DomElement[]
|
||||
): {
|
||||
matches: MatchedPair[];
|
||||
remainingBefore: DomElement[];
|
||||
remainingAfter: DomElement[];
|
||||
} {
|
||||
const beforeIdx = new Set(before.map((_, i) => i));
|
||||
const afterIdx = new Set(after.map((_, i) => i));
|
||||
const matches: MatchedPair[] = [];
|
||||
|
||||
const matchUsingProp = (prop: IdentityProp) => {
|
||||
const afterMap = new Map<string, number[]>();
|
||||
for (const ai of afterIdx) {
|
||||
const val = getPropValue(after[ai], prop);
|
||||
if (!val) continue;
|
||||
const arr = afterMap.get(val) || [];
|
||||
arr.push(ai);
|
||||
afterMap.set(val, arr);
|
||||
}
|
||||
|
||||
for (const bi of [...beforeIdx]) {
|
||||
const val = getPropValue(before[bi], prop);
|
||||
if (!val) continue;
|
||||
const candidates = afterMap.get(val);
|
||||
if (!candidates || candidates.length !== 1) continue;
|
||||
const ai = candidates[0];
|
||||
if (!afterIdx.has(ai)) continue;
|
||||
|
||||
matches.push({ before: before[bi], after: after[ai] });
|
||||
beforeIdx.delete(bi);
|
||||
afterIdx.delete(ai);
|
||||
}
|
||||
};
|
||||
|
||||
for (const prop of identityProps) {
|
||||
matchUsingProp(prop);
|
||||
}
|
||||
|
||||
const remainingBefore = [...beforeIdx].map((i) => before[i]);
|
||||
const remainingAfter = [...afterIdx].map((i) => after[i]);
|
||||
|
||||
return { matches, remainingBefore, remainingAfter };
|
||||
}
|
||||
|
||||
function reconcileDiff(diff: DiffObject): DiffObject {
|
||||
const modified = [...diff.modified];
|
||||
const remainingAdded: DomElement[] = [];
|
||||
const remainingRemoved: DomElement[] = [];
|
||||
|
||||
const addedMap = new Map<string, DomElement[]>();
|
||||
const removedMap = new Map<string, DomElement[]>();
|
||||
|
||||
for (const elem of diff.added) {
|
||||
const key = getIdentityKey(elem);
|
||||
if (!key) {
|
||||
remainingAdded.push(elem);
|
||||
continue;
|
||||
}
|
||||
const arr = addedMap.get(key) || [];
|
||||
arr.push(elem);
|
||||
addedMap.set(key, arr);
|
||||
}
|
||||
|
||||
for (const elem of diff.removed) {
|
||||
const key = getIdentityKey(elem);
|
||||
if (!key) {
|
||||
remainingRemoved.push(elem);
|
||||
continue;
|
||||
}
|
||||
const arr = removedMap.get(key) || [];
|
||||
arr.push(elem);
|
||||
removedMap.set(key, arr);
|
||||
}
|
||||
|
||||
for (const [key, addList] of addedMap.entries()) {
|
||||
const remList = removedMap.get(key);
|
||||
if (!remList) {
|
||||
remainingAdded.push(...addList);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pairCount = Math.min(addList.length, remList.length);
|
||||
for (let i = 0; i < pairCount; i++) {
|
||||
const addedElem = addList[i];
|
||||
const removedElem = remList[i];
|
||||
if (isSameElement(removedElem, addedElem)) {
|
||||
continue;
|
||||
}
|
||||
modified.push({ before: removedElem, after: addedElem });
|
||||
}
|
||||
|
||||
if (addList.length > pairCount) {
|
||||
remainingAdded.push(...addList.slice(pairCount));
|
||||
}
|
||||
if (remList.length > pairCount) {
|
||||
remainingRemoved.push(...remList.slice(pairCount));
|
||||
}
|
||||
removedMap.delete(key);
|
||||
}
|
||||
|
||||
for (const leftovers of removedMap.values()) {
|
||||
remainingRemoved.push(...leftovers);
|
||||
}
|
||||
|
||||
return {
|
||||
from: diff.from,
|
||||
to: diff.to,
|
||||
added: remainingAdded,
|
||||
removed: remainingRemoved,
|
||||
modified,
|
||||
};
|
||||
}
|
||||
|
||||
function computeDiffObject(
|
||||
fromId: string | null,
|
||||
toId: string,
|
||||
before: DomElement[] | null,
|
||||
after: DomElement[]
|
||||
): DiffObject {
|
||||
const normalizedAfter = dedupeElements(after);
|
||||
|
||||
if (!before) {
|
||||
return reconcileDiff({
|
||||
from: null,
|
||||
to: toId,
|
||||
added: normalizedAfter,
|
||||
removed: [],
|
||||
modified: [],
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedBefore = dedupeElements(before);
|
||||
const { matches, remainingBefore, remainingAfter } = computePairs(
|
||||
normalizedBefore,
|
||||
normalizedAfter
|
||||
);
|
||||
|
||||
const modified: { before: DomElement; after: DomElement }[] = [];
|
||||
for (const m of matches) {
|
||||
if (!isSameElement(m.before, m.after)) {
|
||||
modified.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
return reconcileDiff({
|
||||
from: fromId,
|
||||
to: toId,
|
||||
added: remainingAfter,
|
||||
removed: remainingBefore,
|
||||
modified,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDomArray(filePath: string): Promise<DomElement[]> {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as DomElement[];
|
||||
}
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
Array.isArray((parsed as any).added) &&
|
||||
Array.isArray((parsed as any).removed) &&
|
||||
Array.isArray((parsed as any).modified)
|
||||
) {
|
||||
throw new Error(
|
||||
`File already looks like a diff, not a raw DOM array: ${filePath}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unexpected JSON structure in ${filePath}; expected an array of DOM elements.`
|
||||
);
|
||||
}
|
||||
|
||||
async function rewriteDomExportsAsDiffs(dir: string): Promise<void> {
|
||||
const entries = await fs.readdir(dir);
|
||||
const stepFiles: StepFile[] = [];
|
||||
|
||||
for (const name of entries) {
|
||||
if (!name.endsWith(".json")) continue;
|
||||
const parsed = parseStepId(name);
|
||||
if (!parsed) continue;
|
||||
parsed.fullPath = path.join(dir, name);
|
||||
stepFiles.push(parsed);
|
||||
}
|
||||
|
||||
if (stepFiles.length === 0) {
|
||||
console.log("No JSON files with step-like names found in", dir);
|
||||
return;
|
||||
}
|
||||
|
||||
const ordered = sortSteps(stepFiles);
|
||||
|
||||
const domByStep = new Map<string, DomElement[]>();
|
||||
for (const step of ordered) {
|
||||
const arr = await loadDomArray(step.fullPath);
|
||||
domByStep.set(step.stepId, arr);
|
||||
}
|
||||
|
||||
const first = ordered[0];
|
||||
const firstDom = domByStep.get(first.stepId)!;
|
||||
const firstDiff = computeDiffObject(null, first.stepId, null, firstDom);
|
||||
await fs.writeFile(
|
||||
first.fullPath,
|
||||
JSON.stringify(firstDiff, null, 2),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
for (let i = 0; i < ordered.length - 1; i++) {
|
||||
const from = ordered[i];
|
||||
const to = ordered[i + 1];
|
||||
const before = domByStep.get(from.stepId)!;
|
||||
const after = domByStep.get(to.stepId)!;
|
||||
|
||||
const diff = computeDiffObject(from.stepId, to.stepId, before, after);
|
||||
await fs.writeFile(to.fullPath, JSON.stringify(diff, null, 2), "utf8");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Rewrote ${ordered.length} JSON exports in ${dir} as step-to-step diffs.`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argDir = process.argv[2];
|
||||
const dir = argDir
|
||||
? path.resolve(process.cwd(), argDir)
|
||||
: path.join(
|
||||
process.cwd(),
|
||||
"html-dumps-optimized",
|
||||
"iracing-hosted-sessions"
|
||||
);
|
||||
|
||||
await rewriteDomExportsAsDiffs(dir);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
403
scripts/dom-export/exportHtmlDumps.ts
Normal file
403
scripts/dom-export/exportHtmlDumps.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
// Developer-only scripts moved out of infrastructure: DOM exporter for local HTML dumps.
|
||||
// NOT for production automation; intended as a developer utility to generate compact DOM exports
|
||||
// for manual inspection and to aid writing Playwright automations.
|
||||
//
|
||||
// Usage (from repo root):
|
||||
// npm install -D playwright ts-node typescript @types/node
|
||||
// npx playwright install
|
||||
// npx ts-node scripts/dom-export/exportHtmlDumps.ts
|
||||
//
|
||||
// Output: ./html-dumps-optimized/*.json
|
||||
//
|
||||
// This file intentionally contains both the in-page extractor string (exported) and the
|
||||
// Playwright runner that iterates ./html-dumps/*.html and writes .json files into
|
||||
// ./html-dumps-optimized. These artifacts are developer helpers and must not be imported
|
||||
// into production automation code.
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
|
||||
const INPUT_DIR = path.join(process.cwd(), "html-dumps");
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "html-dumps-optimized");
|
||||
|
||||
// Developer helper: in-page DOM extractor string (for console or page.evaluate).
|
||||
// Kept as a plain const to avoid ES module import/export issues when running with ts-node in CJS mode.
|
||||
// This version compresses output aggressively using short tags/fields and a semantic short DOM path.
|
||||
const domExportScript = `(() => {
|
||||
const MAX_TEXT = 60;
|
||||
|
||||
const clean = t =>
|
||||
t ? t.replace(/\\s+/g, " ").trim().slice(0, MAX_TEXT) : null;
|
||||
|
||||
const isDynamicId = id =>
|
||||
id && (id.includes(":-") || /:[a-z0-9]+:/i.test(id));
|
||||
|
||||
const shortTag = t => ({
|
||||
BUTTON: "bu",
|
||||
A: "a",
|
||||
INPUT: "in",
|
||||
SELECT: "s",
|
||||
TEXTAREA: "ta",
|
||||
DIV: "d",
|
||||
SPAN: "sp"
|
||||
}[t] || t.toLowerCase());
|
||||
|
||||
const isNoiseClass = c =>
|
||||
!c ||
|
||||
c.length < 3 ||
|
||||
/^css-/.test(c) ||
|
||||
/^[a-z0-9]{6,}$/i.test(c) ||
|
||||
/^\\w{1,3}-\\w{4,}$/.test(c);
|
||||
|
||||
const siblingIndex = node => {
|
||||
const sib = [...node.parentNode.children]
|
||||
.filter(n => n.tagName === node.tagName);
|
||||
return { idx: sib.indexOf(node), count: sib.length };
|
||||
};
|
||||
|
||||
const getSemSiblingPath = el => {
|
||||
const parts = [];
|
||||
let node = el;
|
||||
let depth = 0;
|
||||
|
||||
while (node && node.nodeType === 1 && node !== document.body && depth < 5) {
|
||||
const { idx, count } = siblingIndex(node);
|
||||
const isTarget = node === el;
|
||||
const tag = shortTag(node.tagName);
|
||||
|
||||
const targetSuffix = isTarget && idx >= 0 ? ":" + idx : "";
|
||||
const parentSuffix = !isTarget && count > 1 && idx >= 0 ? "@" + idx : "";
|
||||
const sibSuffix = targetSuffix || parentSuffix;
|
||||
|
||||
let cls = [...node.classList].filter(c => !isNoiseClass(c));
|
||||
if (cls.length > 2) cls = cls.slice(0, 2);
|
||||
if (!cls.length) cls = ["c0"];
|
||||
|
||||
const attrs = [];
|
||||
|
||||
const id = node.id;
|
||||
if (id && !isDynamicId(id)) attrs.push("#" + id);
|
||||
|
||||
const attrNames = node.getAttributeNames ? node.getAttributeNames() : [];
|
||||
let hasDataAttr = false;
|
||||
for (const a of attrNames) {
|
||||
if (a.startsWith("data-")) {
|
||||
attrs.push("[" + a + "=" + node.getAttribute(a) + "]");
|
||||
hasDataAttr = true;
|
||||
}
|
||||
}
|
||||
|
||||
const role = node.getAttribute ? node.getAttribute("role") : null;
|
||||
if (role) attrs.push("[r=" + role + "]");
|
||||
|
||||
const chunk = tag + "." + cls.join(".") + attrs.join("") + (sibSuffix ? sibSuffix : "");
|
||||
parts.unshift(chunk);
|
||||
|
||||
depth += 1;
|
||||
|
||||
const hasStrongAnchor =
|
||||
(id && !isDynamicId(id)) || hasDataAttr || !!role;
|
||||
|
||||
if (depth >= 5 || hasStrongAnchor) break;
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
return parts.join(">");
|
||||
};
|
||||
|
||||
const items = [];
|
||||
const seen = new Map();
|
||||
|
||||
const addItem = o => {
|
||||
const keyParts = [o.el, o.x];
|
||||
if (o.t) keyParts.push("t=" + o.t);
|
||||
if (o.l) keyParts.push("l=" + o.l);
|
||||
if (o.p) keyParts.push("p=" + o.p);
|
||||
if (o.n) keyParts.push("n=" + o.n);
|
||||
if (o.i) keyParts.push("i=" + o.i);
|
||||
if (o.d) keyParts.push("d=" + o.d);
|
||||
if (o.r) keyParts.push("r=" + o.r);
|
||||
const key = keyParts.join("|");
|
||||
const prev = seen.get(key) || 0;
|
||||
if (prev > 0) {
|
||||
let h = 0;
|
||||
const str = key + "#" + prev;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const hex = (h & 0xfff).toString(16).padStart(3, "0");
|
||||
o.h = hex;
|
||||
}
|
||||
seen.set(key, prev + 1);
|
||||
items.push(o);
|
||||
};
|
||||
|
||||
const elements = [...document.querySelectorAll("button,a,input,select,textarea")];
|
||||
|
||||
for (const e of elements) {
|
||||
const t = clean(e.innerText);
|
||||
const l = clean(e.getAttribute("aria-label"));
|
||||
const p = clean(e.getAttribute("placeholder"));
|
||||
const n = e.getAttribute("name");
|
||||
const r = e.getAttribute("role");
|
||||
const id = e.id;
|
||||
const stableId = isDynamicId(id) ? null : id;
|
||||
const d = e.getAttribute("data-testid");
|
||||
|
||||
// skip menuitems with no meaningful text/label/placeholder
|
||||
if (r === "menuitem" && !t && !l && !p) continue;
|
||||
|
||||
// keep only meaningful ones
|
||||
if (!(t || l || p || n || stableId || d || r)) continue;
|
||||
|
||||
const o = { el: shortTag(e.tagName), x: getSemSiblingPath(e) };
|
||||
|
||||
if (t) o.t = t;
|
||||
if (l && l !== t) o.l = l;
|
||||
if (p && p !== t && p !== l) o.p = p;
|
||||
if (n) o.n = n;
|
||||
if (stableId) o.i = stableId;
|
||||
if (d) o.d = d;
|
||||
if (r) o.r = r;
|
||||
|
||||
addItem(o);
|
||||
}
|
||||
|
||||
const json = JSON.stringify(items, null, 2);
|
||||
console.log("chars:", json.length);
|
||||
console.log("elements:", items.length);
|
||||
console.log(items);
|
||||
|
||||
return items;
|
||||
})();`;
|
||||
|
||||
const domExtractor = `() => {
|
||||
const MAX_TEXT = 60;
|
||||
|
||||
const clean = t =>
|
||||
t ? t.replace(/\\s+/g, " ").trim().slice(0, MAX_TEXT) : null;
|
||||
|
||||
const isDynamicId = id =>
|
||||
id && (id.includes(":-") || /:[a-z0-9]+:/i.test(id));
|
||||
|
||||
const shortTag = t => ({
|
||||
BUTTON: "bu",
|
||||
A: "a",
|
||||
INPUT: "in",
|
||||
SELECT: "s",
|
||||
TEXTAREA: "ta",
|
||||
DIV: "d",
|
||||
SPAN: "sp"
|
||||
}[t] || t.toLowerCase());
|
||||
|
||||
const isNoiseClass = c =>
|
||||
!c ||
|
||||
c.length < 3 ||
|
||||
/^css-/.test(c) ||
|
||||
/^[a-z0-9]{6,}$/i.test(c) ||
|
||||
/^\\w{1,3}-\\w{4,}$/.test(c);
|
||||
|
||||
const siblingIndex = node => {
|
||||
const sib = [...node.parentNode.children]
|
||||
.filter(n => n.tagName === node.tagName);
|
||||
return { idx: sib.indexOf(node), count: sib.length };
|
||||
};
|
||||
|
||||
const getSemSiblingPath = el => {
|
||||
const parts = [];
|
||||
let node = el;
|
||||
let depth = 0;
|
||||
|
||||
while (node && node.nodeType === 1 && node !== document.body && depth < 5) {
|
||||
const { idx, count } = siblingIndex(node);
|
||||
const isTarget = node === el;
|
||||
const tag = shortTag(node.tagName);
|
||||
|
||||
const targetSuffix = isTarget && idx >= 0 ? ":" + idx : "";
|
||||
const parentSuffix = !isTarget && count > 1 && idx >= 0 ? "@" + idx : "";
|
||||
const sibSuffix = targetSuffix || parentSuffix;
|
||||
|
||||
let cls = [...node.classList].filter(c => !isNoiseClass(c));
|
||||
if (cls.length > 2) cls = cls.slice(0, 2);
|
||||
if (!cls.length) cls = ["c0"];
|
||||
|
||||
const attrs = [];
|
||||
|
||||
const id = node.id;
|
||||
if (id && !isDynamicId(id)) attrs.push("#" + id);
|
||||
|
||||
const attrNames = node.getAttributeNames ? node.getAttributeNames() : [];
|
||||
let hasDataAttr = false;
|
||||
for (const a of attrNames) {
|
||||
if (a.startsWith("data-")) {
|
||||
attrs.push("[" + a + "=" + node.getAttribute(a) + "]");
|
||||
hasDataAttr = true;
|
||||
}
|
||||
}
|
||||
|
||||
const role = node.getAttribute ? node.getAttribute("role") : null;
|
||||
if (role) attrs.push("[r=" + role + "]");
|
||||
|
||||
const chunk = tag + "." + cls.join(".") + attrs.join("") + (sibSuffix ? sibSuffix : "");
|
||||
parts.unshift(chunk);
|
||||
|
||||
depth += 1;
|
||||
|
||||
const hasStrongAnchor =
|
||||
(id && !isDynamicId(id)) || hasDataAttr || !!role;
|
||||
|
||||
if (depth >= 5 || hasStrongAnchor) break;
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
return parts.join(">");
|
||||
};
|
||||
|
||||
const items = [];
|
||||
const seen = new Map();
|
||||
|
||||
const addItem = o => {
|
||||
const keyParts = [o.el, o.x];
|
||||
if (o.t) keyParts.push("t=" + o.t);
|
||||
if (o.l) keyParts.push("l=" + o.l);
|
||||
if (o.p) keyParts.push("p=" + o.p);
|
||||
if (o.n) keyParts.push("n=" + o.n);
|
||||
if (o.i) keyParts.push("i=" + o.i);
|
||||
if (o.d) keyParts.push("d=" + o.d);
|
||||
if (o.r) keyParts.push("r=" + o.r);
|
||||
const key = keyParts.join("|");
|
||||
const prev = seen.get(key) || 0;
|
||||
if (prev > 0) {
|
||||
let h = 0;
|
||||
const str = key + "#" + prev;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
const hex = (h & 0xfff).toString(16).padStart(3, "0");
|
||||
o.h = hex;
|
||||
}
|
||||
seen.set(key, prev + 1);
|
||||
items.push(o);
|
||||
};
|
||||
|
||||
const elements = [...document.querySelectorAll("button,a,input,select,textarea")];
|
||||
|
||||
for (const e of elements) {
|
||||
const t = clean(e.innerText);
|
||||
const l = clean(e.getAttribute("aria-label"));
|
||||
const p = clean(e.getAttribute("placeholder"));
|
||||
const n = e.getAttribute("name");
|
||||
const r = e.getAttribute("role");
|
||||
const id = e.id;
|
||||
const stableId = isDynamicId(id) ? null : id;
|
||||
const d = e.getAttribute("data-testid");
|
||||
|
||||
// skip menuitems with no meaningful text/label/placeholder
|
||||
if (r === "menuitem" && !t && !l && !p) continue;
|
||||
|
||||
if (!(t || l || p || n || stableId || d || r)) continue;
|
||||
|
||||
const o = { el: shortTag(e.tagName), x: getSemSiblingPath(e) };
|
||||
|
||||
if (t) o.t = t;
|
||||
if (l && l !== t) o.l = l;
|
||||
if (p && p !== t && p !== l) o.p = p;
|
||||
if (n) o.n = n;
|
||||
if (stableId) o.i = stableId;
|
||||
if (d) o.d = d;
|
||||
if (r) o.r = r;
|
||||
|
||||
addItem(o);
|
||||
}
|
||||
|
||||
return items;
|
||||
}`;
|
||||
|
||||
module.exports = { domExportScript };
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAll() {
|
||||
await ensureDir(OUTPUT_DIR);
|
||||
|
||||
async function collectHtmlFiles(dir: string): Promise<string[]> {
|
||||
const entries = (await fs.readdir(dir, { withFileTypes: true })) as any[];
|
||||
const results: string[] = [];
|
||||
for (const ent of entries) {
|
||||
const p = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
results.push(...(await collectHtmlFiles(p)));
|
||||
} else if (ent.isFile() && ent.name.endsWith(".html")) {
|
||||
results.push(path.relative(INPUT_DIR, p));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
let htmlFiles: string[] = [];
|
||||
try {
|
||||
htmlFiles = await collectHtmlFiles(INPUT_DIR);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Could not read input directory recursively:",
|
||||
INPUT_DIR,
|
||||
err
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (htmlFiles.length === 0) {
|
||||
console.log("No .html files found in", INPUT_DIR);
|
||||
return;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const file of htmlFiles) {
|
||||
const abs = path.join(INPUT_DIR, file);
|
||||
const url = "file://" + abs;
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
const items = await page.evaluate(
|
||||
new Function("return (" + domExtractor + ")()") as any
|
||||
);
|
||||
const outPath = path.join(OUTPUT_DIR, file.replace(/\.html$/, ".json"));
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
||||
await fs.writeFile(outPath, JSON.stringify(items, null, 2), "utf8");
|
||||
console.log(
|
||||
"exported " +
|
||||
file +
|
||||
" -> " +
|
||||
path.relative(process.cwd(), outPath) +
|
||||
" (elements: " +
|
||||
(Array.isArray(items) ? items.length : 0) +
|
||||
")"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed processing", file, e);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
exportAll().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
51
scripts/dom-export/processWorkflows.js
Normal file
51
scripts/dom-export/processWorkflows.js
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const HTML_DUMPS_DIR = path.join(ROOT, "html-dumps");
|
||||
const EXPORTS_DIR = path.join(ROOT, "html-dumps-optimized");
|
||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
|
||||
async function removeExportsDir() {
|
||||
await fs.rm(EXPORTS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runStep(cmd, args, options = {}) {
|
||||
const result = spawnSync(cmd, args, { stdio: "inherit", ...options });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`${cmd} ${args.join(" ")} failed with code ${result.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function processWorkflows() {
|
||||
await removeExportsDir();
|
||||
|
||||
runStep(npxCmd, ["ts-node", "scripts/dom-export/exportHtmlDumps.ts"]);
|
||||
|
||||
const entries = await fs.readdir(HTML_DUMPS_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const exportWorkflowDir = path.join(EXPORTS_DIR, entry.name);
|
||||
try {
|
||||
await fs.access(exportWorkflowDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
runStep(npxCmd, [
|
||||
"ts-node",
|
||||
"scripts/dom-export/buildDomDiffs.ts",
|
||||
exportWorkflowDir,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
processWorkflows().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user