返回 DeepSeek-Reasonix
snapshotScript.ts
根目录 / desktop / electron / src / main / browser / snapshotScript.ts
1 /// <reference lib="dom" />
2
3 // Page-side snapshot walker. It is serialised with Function.prototype.toString
4 // and executed inside the website, so it must stay self-contained: no imports,
5 // no references to module scope, plain ES2022 only.
6
7 export interface SnapshotInput {
8 key: string;
9 snapshotId: string;
10 prefix: string;
11 selector: string;
12 budget: number;
13 }
14
15 export interface SnapshotOutput {
16 docId: string;
17 tree: string;
18 refs: number;
19 nodes: number;
20 truncated: number;
21 }
22
23 export interface PageRegistry {
24 docId: string;
25 snapshotId: string;
26 refs: Map<string, Element>;
27 }
28
29 export function pageSnapshot(input: SnapshotInput): SnapshotOutput {
30 const host = window as unknown as Record<string, unknown>;
31 let registry = host[input.key] as PageRegistry | undefined;
32 if (!registry || typeof registry.docId !== "string") {
33 const random = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
34 registry = { docId: `${performance.timeOrigin}:${random}`, snapshotId: "", refs: new Map() };
35 Object.defineProperty(host, input.key, { value: registry, enumerable: false, configurable: true, writable: true });
36 }
37 registry.snapshotId = input.snapshotId;
38 registry.refs = new Map();
39 const refs = registry.refs;
40 const docId = registry.docId;
41
42 let root: Element | null = document.body ?? document.documentElement;
43 if (input.selector !== "") {
44 try {
45 root = document.querySelector(input.selector);
46 } catch {
47 root = null;
48 }
49 if (!root) return { docId, tree: `(no element matches selector ${JSON.stringify(input.selector)})`, refs: 0, nodes: 0, truncated: 0 };
50 }
51
52 const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "TITLE", "SVG", "CANVAS", "VIDEO", "AUDIO", "SOURCE", "TRACK", "MAP", "AREA", "PATH", "DATALIST"]);
53 const TAG_ROLES: Record<string, string> = {
54 BUTTON: "button", TEXTAREA: "textbox", OPTION: "option", OPTGROUP: "group", TABLE: "table", TR: "row", TD: "cell", TH: "columnheader",
55 UL: "list", OL: "list", LI: "listitem", NAV: "navigation", MAIN: "main", FORM: "form", DIALOG: "dialog", IMG: "img", HEADER: "banner",
56 FOOTER: "contentinfo", ASIDE: "complementary", ARTICLE: "article", SECTION: "region", SUMMARY: "button", DETAILS: "group", IFRAME: "iframe",
57 FRAME: "iframe", MENU: "list", FIELDSET: "group", PROGRESS: "progressbar", METER: "meter", HR: "separator", H1: "heading", H2: "heading",
58 H3: "heading", H4: "heading", H5: "heading", H6: "heading",
59 }
60 const INTERACTIVE = new Set(["button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox", "listbox", "option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "slider", "switch", "spinbutton", "treeitem", "iframe"]);
61 const CONTENT_NAMED = new Set(["button", "link", "heading", "option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "cell", "columnheader", "rowheader", "treeitem", "listitem", "summary"]);
62 const TEXT_INPUTS = new Set(["text", "email", "tel", "url", "number", "date", "datetime-local", "month", "week", "time", "color", ""]);
63
64 const lines: string[] = [];
65 let nodes = 0;
66 let truncated = 0;
67 let refCount = 0;
68 const active = document.activeElement;
69
70 function clip(value: string): string {
71 const text = value.replace(/\s+/g, " ").trim();
72 return text.length > 120 ? `${text.slice(0, 119)}…` : text;
73 }
74
75 function roleOf(el: Element): string {
76 const explicit = (el.getAttribute("role") ?? "").trim().toLowerCase();
77 if (explicit !== "") return explicit.split(/\s+/)[0];
78 const tag = el.tagName;
79 if (tag === "A") return el.hasAttribute("href") ? "link" : "generic";
80 if (tag === "INPUT") {
81 const type = ((el as HTMLInputElement).type || "text").toLowerCase();
82 if (type === "button" || type === "submit" || type === "reset" || type === "image" || type === "file") return "button";
83 if (type === "checkbox" || type === "radio") return type;
84 if (type === "range") return "slider";
85 if (type === "search") return "searchbox";
86 if (type === "hidden") return "hidden";
87 if (type === "password") return "textbox";
88 return TEXT_INPUTS.has(type) ? "textbox" : "textbox";
89 }
90 if (tag === "SELECT") return (el as HTMLSelectElement).multiple ? "listbox" : "combobox";
91 if (tag === "SECTION" && !el.hasAttribute("aria-label") && !el.hasAttribute("aria-labelledby")) return "generic";
92 const mapped = TAG_ROLES[tag];
93 if (mapped) return mapped;
94 const tabindex = el.getAttribute("tabindex");
95 if (tabindex !== null && Number(tabindex) >= 0) return "generic-clickable";
96 return "generic";
97 }
98
99 function labelledBy(el: Element): string {
100 const ids = (el.getAttribute("aria-labelledby") ?? "").split(/\s+/).filter((id) => id !== "");
101 return ids.map((id) => document.getElementById(id)?.textContent ?? "").join(" ");
102 }
103
104 function nameOf(el: Element, role: string): string {
105 const aria = el.getAttribute("aria-label");
106 if (aria && aria.trim() !== "") return clip(aria);
107 const byId = labelledBy(el);
108 if (byId.trim() !== "") return clip(byId);
109 const tag = el.tagName;
110 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || tag === "METER" || tag === "PROGRESS") {
111 const labels = (el as HTMLInputElement).labels;
112 if (labels && labels.length) return clip([...labels].map((label) => label.textContent ?? "").join(" "));
113 }
114 if (tag === "INPUT") {
115 const inputEl = el as HTMLInputElement;
116 const type = inputEl.type.toLowerCase();
117 if ((type === "button" || type === "submit" || type === "reset") && inputEl.value !== "") return clip(inputEl.value);
118 if (type === "image" && inputEl.alt !== "") return clip(inputEl.alt);
119 }
120 if (tag === "IMG") return clip((el as HTMLImageElement).alt);
121 if (tag === "IFRAME" || tag === "FRAME") return clip(el.getAttribute("title") ?? el.getAttribute("name") ?? "");
122 const title = el.getAttribute("title");
123 if (title && title.trim() !== "") return clip(title);
124 const placeholder = el.getAttribute("placeholder");
125 if (placeholder && placeholder.trim() !== "") return clip(placeholder);
126 if (CONTENT_NAMED.has(role) || role === "generic-clickable") return clip(el.textContent ?? "");
127 return "";
128 }
129
130 function statesOf(el: Element, role: string): string[] {
131 const states: string[] = [];
132 const tag = el.tagName;
133 const inputEl = el as HTMLInputElement;
134 if (tag === "INPUT" && (inputEl.type === "checkbox" || inputEl.type === "radio")) {
135 if (inputEl.indeterminate) states.push("mixed");
136 else if (inputEl.checked) states.push("checked");
137 } else if (el.getAttribute("aria-checked") === "true") states.push("checked");
138 else if (el.getAttribute("aria-checked") === "mixed") states.push("mixed");
139 if (el.getAttribute("aria-pressed") === "true") states.push("pressed");
140 if ((el as HTMLButtonElement).disabled === true || el.getAttribute("aria-disabled") === "true") states.push("disabled");
141 const expanded = el.getAttribute("aria-expanded");
142 if (expanded === "true" || (tag === "DETAILS" && (el as HTMLDetailsElement).open)) states.push("expanded");
143 else if (expanded === "false") states.push("collapsed");
144 if ((tag === "OPTION" && (el as HTMLOptionElement).selected) || el.getAttribute("aria-selected") === "true") states.push("selected");
145 if (el === active) states.push("focused");
146 if (role === "heading") {
147 const level = el.getAttribute("aria-level") ?? (/^H([1-6])$/.exec(tag)?.[1] ?? "2");
148 states.push(`level=${level}`);
149 }
150 if (tag === "INPUT" && inputEl.type === "password") states.push("password");
151 else if (tag === "INPUT" && inputEl.type === "file") states.push("file");
152 else if (tag === "INPUT" && inputEl.type !== "checkbox" && inputEl.type !== "radio" && inputEl.type !== "submit" && inputEl.type !== "button" && inputEl.type !== "reset" && inputEl.type !== "image") {
153 if (inputEl.value !== "") states.push(`value=${JSON.stringify(clip(inputEl.value))}`);
154 } else if (tag === "TEXTAREA") {
155 const value = (el as HTMLTextAreaElement).value;
156 if (value !== "") states.push(`value=${JSON.stringify(clip(value))}`);
157 } else if (tag === "SELECT") {
158 const select = el as HTMLSelectElement;
159 const chosen = [...select.selectedOptions].map((option) => option.label || option.text);
160 if (chosen.length) states.push(`value=${JSON.stringify(clip(chosen.join(", ")))}`);
161 if (select.multiple) states.push("multiple");
162 }
163 if (role === "generic-clickable") states.push("clickable");
164 if (tag === "A" && el.hasAttribute("href")) {
165 const href = el.getAttribute("href") ?? "";
166 if (href.startsWith("#")) states.push(`href=${JSON.stringify(clip(href))}`);
167 }
168 return states;
169 }
170
171 function hiddenSubtree(el: Element): boolean {
172 // Uploads target hidden file inputs, so they must keep their snapshot ref.
173 if (el.tagName === "INPUT" && (el as HTMLInputElement).type === "file") return false;
174 if (el.getAttribute("aria-hidden") === "true") return true;
175 if (el.tagName === "INPUT" && (el as HTMLInputElement).type === "hidden") return true;
176 const style = window.getComputedStyle(el);
177 return style.display === "none" || style.visibility === "hidden";
178 }
179
180 function zeroSize(el: Element): boolean {
181 if (el.tagName === "OPTION" || el.tagName === "OPTGROUP") return false;
182 if (el.tagName === "INPUT" && (el as HTMLInputElement).type === "file") return false;
183 const rect = el.getBoundingClientRect();
184 return rect.width === 0 && rect.height === 0 && el !== active;
185 }
186
187 function emit(line: string): void {
188 if (nodes >= input.budget) {
189 truncated += 1;
190 return;
191 }
192 nodes += 1;
193 lines.push(line);
194 }
195
196 function visit(node: Node, depth: number, suppressText: boolean): void {
197 if (node.nodeType === Node.TEXT_NODE) {
198 if (suppressText) return;
199 const text = clip(node.textContent ?? "");
200 if (text !== "") emit(`${" ".repeat(depth)}text ${JSON.stringify(text)}`);
201 return;
202 }
203 if (node.nodeType !== Node.ELEMENT_NODE) return;
204 const el = node as Element;
205 if (SKIP.has(el.tagName) || hiddenSubtree(el)) return;
206 const role = roleOf(el);
207 if (role === "hidden") return;
208 // A <label> surfaces as its control's name, and a zero-size box is
209 // invisible: neither contributes a subtree.
210 if (el.tagName === "LABEL" && (el as HTMLLabelElement).control !== null) return;
211 if (zeroSize(el)) return;
212 let childDepth = depth;
213 let childSuppress = suppressText;
214 if (role !== "generic") {
215 const name = nameOf(el, role);
216 const states = statesOf(el, role);
217 const displayRole = role === "generic-clickable" ? "generic" : role;
218 const interactive = INTERACTIVE.has(role) || role === "generic-clickable";
219 let line = `${" ".repeat(depth)}${displayRole}`;
220 if (name !== "") line += ` ${JSON.stringify(name)}`;
221 if (states.length) line += ` [${states.join(", ")}]`;
222 if ((interactive || name !== "") && role !== "iframe") {
223 refCount += 1;
224 const ref = `${input.prefix}e${refCount}`;
225 refs.set(ref, el);
226 line += ` ref=${ref}`;
227 }
228 emit(line);
229 childDepth = depth + 1;
230 if (CONTENT_NAMED.has(role) || role === "generic-clickable") childSuppress = true;
231 }
232 // A textarea's text child duplicates the value state.
233 if (el.tagName === "TEXTAREA") return;
234 if (el.tagName === "SELECT" && role !== "generic") {
235 for (const option of (el as HTMLSelectElement).options) visit(option, childDepth, false);
236 return;
237 }
238 const shadow = (el as HTMLElement).shadowRoot;
239 const children = shadow ? [...shadow.childNodes, ...el.childNodes] : [...el.childNodes];
240 for (const child of children) visit(child, childDepth, childSuppress);
241 }
242
243 // A selector scopes INTO the element: its own line is omitted and the tree
244 // starts at its children (including a shadow root's), all at depth zero.
245 if (input.selector !== "") {
246 const scopedShadow = (root as HTMLElement).shadowRoot;
247 const scopedChildren = scopedShadow ? [...scopedShadow.childNodes, ...root.childNodes] : [...root.childNodes];
248 for (const child of scopedChildren) visit(child, 0, false);
249 } else {
250 visit(root, 0, false);
251 }
252 if (truncated > 0) lines.push(`… (${truncated} more nodes)`);
253 return { docId, tree: lines.join("\n"), refs: refCount, nodes, truncated };
254 }
255
256 export const SNAPSHOT_SCRIPT_SOURCE = pageSnapshot.toString();
257
257 lines TYPESCRIPT