#!/usr/bin/env node /* * Android BFS view-tree crawler (zero-LLM, pure Node stdlib). * * 靠 Node 原生类型擦除运行(Node ≥ 22.18 / 23.6 直接 `node android_viewtree_bfs_crawler.ts`),无需编译、无依赖。 * 功能:遍历每个页面,保存截图、View XML、Activity/Window 信息 * * 策略:BFS + 重启导航 * 1. 轻量探测:点击后先快速 dump XML 判断是否新页面,重复页面跳过截图 * 2. 指数退避的 wait_for_idle(更短等待) * 3. 过滤低价值元素(减少无效点击) */ import * as fs from "node:fs"; import * as path from "node:path"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import * as readline from "node:readline"; type Dict = Record; // ---- config ---- const ADB_PATH_DEFAULT = "adb"; const DEVICE_SERIAL_DEFAULT = "emulator-5554"; const MUMU_EMULATOR_DEFAULT = false; const MUMU_ADB_ADDR_DEFAULT = "127.0.0.1:7555"; const IDLE_POLL_INTERVAL_DEFAULT = 0.2; const IDLE_TIMEOUT_DEFAULT = 5; const MAX_PAGES_DEFAULT = 500; const MAX_SCROLLS_DEFAULT = 10; const MAX_DEPTH_DEFAULT = 6; const SCROLL_DURATION_MS_DEFAULT = 300; const SKIP_RESOURCE_ID_PATTERNS_DEFAULT = [ "statusBarBackground", "navigationBarBackground", "action_bar_container", "status_bar", "navigation_bar", ]; const DESTRUCTIVE_TEXT_PATTERNS_DEFAULT = [ "删除", "移除", "清空", "清理", "清除", "清除全部", "全部清除", "全部删除", "重置", "恢复出厂", "卸载", "delete", "remove", "clear all", "clear queue", "clean", "reset", "erase", "trash", "discard", "uninstall", ]; const DESTRUCTIVE_RESOURCE_ID_PATTERNS_DEFAULT = [ "delete", "remove", "clear", "clean", "reset", "erase", "trash", "discard", ]; const LIST_ITEM_RID_PATTERNS = ["clBase", "clItem", "rvItem", "list_item"]; const ROOT_RECOVERY_CLICKS_DEFAULT: Dict[] = []; const BACK_UNWIND_MAX_DEFAULT = 4; const HORIZONTAL_SCROLL_CLASSES = new Set(["android.widget.HorizontalScrollView"]); const FOREIGN_FOCUS_MARKERS = ["TranslationWindow"]; const COLLAPSE_MIN = 2; const CRAWL_DEADLINE_SECONDS = 7200; const NEAR_DUP_MIN_JACCARD = 0.80; const NEAR_DUP_WEAK_ONLY_MIN_JACCARD = 0.60; const NEAR_DUP_MAX_STRONG_DIFFS = 0; const NEAR_DUP_MAX_WEAK_DIFFS = 4; const LEARNED_ROOT_PATH_LIMIT = 8; const LEARNED_ROOT_ATTEMPTS_PER_RECOVERY = 2; function sleepMs(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } const ENTITIES: Record = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" }; function unescapeHtml(s: string): string { return s.replace(/&(?:amp|lt|gt|quot|apos);|&#x?[0-9A-Fa-f]+;/g, (m) => { if (m in ENTITIES) return ENTITIES[m]; if (m[1] === "#") { const hex = m[2] === "x" || m[2] === "X"; const code = parseInt(m.slice(hex ? 3 : 2, -1), hex ? 16 : 10); return String.fromCodePoint(code); } return m; }); } function nowStr(): string { const d = new Date(); const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } // 本地时间 ISO(对齐 Python datetime.now().isoformat();toISOString() 是 UTC 会偏时区) function nowIso(): string { const d = new Date(); const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } interface XmlNode { tag: string; attrs: Record; depth: number; } function parseXmlNodes(xml: string): XmlNode[] { const nodes: XmlNode[] = []; const tagRe = /<(\/)?([A-Za-z_][\w:.\-]*)((?:\s+[\w:.\-]+\s*=\s*"[^"]*")*)\s*(\/)?>/g; const attrRe = /([\w:.\-]+)\s*=\s*"([^"]*)"/g; let depth = -1; let m: RegExpExecArray | null; while ((m = tagRe.exec(xml)) !== null) { const isClose = m[1] === "/"; const attrStr = m[3] || ""; const selfClose = m[4] === "/"; if (isClose) { depth -= 1; continue; } depth += 1; const attrs: Record = {}; let am: RegExpExecArray | null; attrRe.lastIndex = 0; while ((am = attrRe.exec(attrStr)) !== null) attrs[am[1]] = unescapeHtml(am[2]); nodes.push({ tag: m[2], attrs, depth }); if (selfClose) depth -= 1; } return nodes; } function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const x of a) if (!b.has(x)) return false; return true; } class ADBHelper { adbPath: string; deviceSerial: string | null; mumuEmulator: boolean; mumuAdbAddr: string; idlePollInterval: number; idleTimeout: number; scrollDurationMs: number; constructor(opts: { adbPath?: string; deviceSerial?: string | null; mumuEmulator?: boolean; mumuAdbAddr?: string; idlePollInterval?: number; idleTimeout?: number; scrollDurationMs?: number; } = {}) { this.adbPath = opts.adbPath ?? ADB_PATH_DEFAULT; this.deviceSerial = opts.deviceSerial ?? DEVICE_SERIAL_DEFAULT; this.mumuEmulator = opts.mumuEmulator ?? MUMU_EMULATOR_DEFAULT; this.mumuAdbAddr = opts.mumuAdbAddr ?? MUMU_ADB_ADDR_DEFAULT; this.idlePollInterval = opts.idlePollInterval ?? IDLE_POLL_INTERVAL_DEFAULT; this.idleTimeout = opts.idleTimeout ?? IDLE_TIMEOUT_DEFAULT; this.scrollDurationMs = opts.scrollDurationMs ?? SCROLL_DURATION_MS_DEFAULT; } _buildCmd(...args: string[]): string[] { const cmd = [this.adbPath]; if (this.deviceSerial) cmd.push("-s", this.deviceSerial); cmd.push(...args); return cmd; } run(args: string[], timeout: number = 15): string { const cmd = this._buildCmd(...args); const result = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf-8", timeout: timeout * 1000 }); if (result.error && (result.error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("找不到 adb,请确认已安装并配置 PATH,或修改 ADB_PATH"); if (result.signal === "SIGTERM") { console.log(` [WARN] adb 命令超时: ${args.join(" ")}`); return ""; } return (result.stdout || "").trim(); } shell(...args: string[]): string { return this.run(["shell", ...args]); } waitForIdle(): void { const deadline = Date.now() / 1000 + this.idleTimeout; let prevFocus = ""; let interval = this.idlePollInterval; while (Date.now() / 1000 < deadline) { sleepMs(interval * 1000); interval = Math.min(interval * 1.5, 0.5); const t = this.shell("dumpsys", "window", "animator"); if (t.includes("Transition ready") || t.includes("Running animations")) continue; const f = this.shell("dumpsys", "window"); let cf = ""; for (const line of f.split("\n")) { if (line.includes("mCurrentFocus")) { cf = line.trim(); break; } } if (cf && cf === prevFocus) return; prevFocus = cf; } console.log(` [WARN] 等待 UI 空闲超时 (${this.idleTimeout}s),继续执行`); } checkDevice(): boolean { if (this.mumuEmulator) { console.log(` 正在连接 MuMu 模拟器 (${this.mumuAdbAddr})...`); console.log(` adb connect: ${this.run(["connect", this.mumuAdbAddr])}`); sleepMs(1000); if (this.deviceSerial === "emulator-5554") this.deviceSerial = this.mumuAdbAddr; } const out = this.run(["devices"]); const lines = out.split("\n").filter(l => l.trim() && !l.includes("List of devices")); const online = lines.filter(l => l.includes("device") && !l.includes("offline")); if (online.length === 0) { console.log(" 未检测到在线设备,请先启动模拟器并确认 adb devices 有输出"); return false; } const onlineDevices = online.map(l => l.split(/\s+/)[0]); console.log(` 在线设备: ${JSON.stringify(onlineDevices)}`); if (this.deviceSerial === null) { this.deviceSerial = onlineDevices[0]; console.log(` 未指定设备,自动选择: ${this.deviceSerial}`); } else if (!onlineDevices.includes(this.deviceSerial)) { console.log(` 指定的设备 ${this.deviceSerial} 不存在,自动选择: ${onlineDevices[0]}`); this.deviceSerial = onlineDevices[0]; } else console.log(` 使用指定的设备: ${this.deviceSerial}`); return true; } restartAdbServer(): void { console.log(" [INFO] 重启 ADB server..."); this.run(["kill-server"]); sleepMs(1000); this.run(["start-server"]); sleepMs(2000); } getCurrentActivity(): Dict { const info: Dict = {}; const actOut = this.shell("dumpsys", "activity", "activities"); const winOut = this.shell("dumpsys", "window"); for (const line of actOut.split("\n")) { if (/ResumedActivity|topActivity/i.test(line)) { info["resumed_activity"] = line.trim(); break; } } for (const line of winOut.split("\n")) { if (line.includes("mCurrentFocus")) { info["current_focus"] = line.trim(); break; } if (line.includes("mFocusedApp")) { if (!info["focused_app"]) info["focused_app"] = line.trim(); } } return info; } tap(x: number, y: number): void { this.shell("input", "tap", String(x), String(y)); this.waitForIdle(); } longPress(x: number, y: number, d: number = 1000): void { this.shell("input", "swipe", String(x), String(y), String(x), String(y), String(d)); this.waitForIdle(); } tapOrLongPress(x: number, y: number, isLong: boolean = false): void { if (isLong) this.longPress(x, y); else this.tap(x, y); } pressBack(): void { this.shell("input", "keyevent", "KEYCODE_BACK"); this.waitForIdle(); } isSoftKeyboardShown(): boolean { try { const out = this.run(["shell", "dumpsys", "input_method"], 5); if (/(mInputShown|mIsInputViewShown|inputShown|isInputViewShown)=true/i.test(out)) return true; if (/mImeWindowVis=0x[1-9a-f]+/i.test(out)) return true; } catch { return false; } return false; } dumpXmlQuick(): string { const rp = "/sdcard/ui_dump.xml"; this.run(["shell", "rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml"], 20); for (let i = 0; i < 6; i++) { if (this.shell("ls", rp).includes(rp)) break; sleepMs(200); } return this.shell("cat", rp); } getViewXmlFast(pageDir: string): string { const rp = "/sdcard/ui_dump.xml"; const lp = path.join(pageDir, "view.xml"); this.run(["shell", "rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml"], 20); for (let i = 0; i < 6; i++) { if (this.shell("ls", rp).includes(rp)) break; sleepMs(200); } const xc = this.shell("cat", rp); if (xc) fs.writeFileSync(lp, xc, "utf-8"); return xc; } screencapBytes(): Buffer | null { const cmd = this._buildCmd("exec-out", "screencap", "-p"); const r = spawnSync(cmd[0], cmd.slice(1), { timeout: 15000 }); if (r.signal === "SIGTERM") { console.log(" [WARN] 截图超时"); return null; } if (r.stdout && (r.stdout as Buffer).length > 0) return r.stdout as Buffer; return null; } takeScreenshot(pageDir: string): void { const lp = path.join(pageDir, "screenshot.png"); const data = this.screencapBytes(); if (data) fs.writeFileSync(lp, data); else { const rp = "/sdcard/screenshot.png"; this.shell("screencap", "-p", rp); sleepMs(300); this.run(["pull", rp, lp]); } } scrollToPosition(cb: [number, number, number, number], si: number): void { if (si <= 0) return; const [x1, y1, x2, y2] = cb; const cx = Math.floor((x1 + x2) / 2); const h = y2 - y1; const fy = y1 + Math.floor(h * 0.7); const ty = y1 + Math.floor(h * 0.3); for (let i = 0; i < si; i++) { this.shell("input", "swipe", String(cx), String(fy), String(cx), String(ty), String(this.scrollDurationMs)); this.waitForIdle(); } } grantRuntimePermissions(pkg: string): void { const out = this.shell("dumpsys", "package", pkg); const perms: string[] = []; let inReq = false; for (const line of out.split("\n")) { const s = line.trim(); if (s.toLowerCase().includes("runtime permissions:")) { inReq = true; continue; } if (inReq) { if (!s || (!s.startsWith("android.permission") && !s.startsWith("com."))) { inReq = false; continue; } const p = s.split(":")[0].trim(); if (p) perms.push(p); } } for (const p of perms) this.shell("pm", "grant", pkg, p); this.shell("appops", "set", pkg, "MANAGE_EXTERNAL_STORAGE", "allow"); } } type TemplateKey = string | null; type QueueItem = [Dict[], unknown[], string[], Dict | null, TemplateKey]; type NearDuplicateStats = { jaccard: number; strongDiff: string[]; weakDiff: string[] }; type LearnedRootPath = { steps: Dict[]; successes: number; failures: number; }; class PageCrawler { adb: ADBHelper; package: string; outputRoot: string; maxPages: number; maxScrolls: number; maxDepth: number; skipSameActivityClicks: boolean; allowPmClear: boolean; skipPatterns: string[]; rootRecoveryClicks: Dict[]; pagesIndex: Dict[] = []; visitedSignatures: Set = new Set(); pathSignatures: Record = {}; blockedElements: Set = new Set(); clickResultCache: Map = new Map(); templateResults: Map = new Map(); templateCollapsed: Set = new Set(); failedPaths: Set = new Set(); learnedRootPaths: LearnedRootPath[] = []; gaps: Dict[] = []; gapKeys: Set = new Set(); rootSignature: string | null = null; rootStateSignature: string | null = null; lastRestartRootExact = true; queue: QueueItem[] = []; pageCounter: number = 0; terminatedBy = "normal"; stats: Record = { skipped_elements: 0, probe_skipped: 0, cache_skipped: 0, same_activity_skipped: 0, merged_variants: 0, nav_replay_failed: 0, prefix_pruned: 0, destructive_skipped: 0, external_blocked: 0, dequeued_total: 0, list_collapsed_skipped: 0, }; constructor(opts: { adb: ADBHelper; package: string; outputDir: string; maxPages?: number; maxScrolls?: number; maxDepth?: number; skipSameActivityClicks?: boolean; allowPmClear?: boolean; skipPatterns?: string[]; rootRecoveryClicks?: Dict[]; }) { this.adb = opts.adb; this.package = opts.package; this.outputRoot = opts.outputDir; this.maxPages = opts.maxPages ?? MAX_PAGES_DEFAULT; this.maxScrolls = opts.maxScrolls ?? MAX_SCROLLS_DEFAULT; this.maxDepth = opts.maxDepth ?? MAX_DEPTH_DEFAULT; this.skipSameActivityClicks = opts.skipSameActivityClicks ?? false; this.allowPmClear = opts.allowPmClear ?? false; this.skipPatterns = opts.skipPatterns ?? SKIP_RESOURCE_ID_PATTERNS_DEFAULT; this.rootRecoveryClicks = opts.rootRecoveryClicks ?? ROOT_RECOVERY_CLICKS_DEFAULT; } static parseBoundsCenter(bounds: string): [number, number] | null { const m = bounds.match(/\d+/g); if (m && m.length === 4) { const [x1, y1, x2, y2] = m.map(Number); return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)]; } return null; } static parseBoundsArr(bounds: string): [number, number, number, number] | null { const m = (bounds || "").match(/\d+/g); if (m && m.length === 4) return m.map(Number) as [number, number, number, number]; return null; } static pointInBounds(point: [number, number], bounds: [number, number, number, number]): boolean { const [x, y] = point; const [x1, y1, x2, y2] = bounds; return x1 <= x && x <= x2 && y1 <= y && y <= y2; } static boundsOverlap(a: [number, number, number, number], b: [number, number, number, number]): boolean { const [ax1, ay1, ax2, ay2] = a; const [bx1, by1, bx2, by2] = b; return ax1 < bx2 && bx1 < ax2 && ay1 < by2 && by1 < ay2; } static boundsArea(bounds: [number, number, number, number]): number { const [x1, y1, x2, y2] = bounds; return Math.max(0, x2 - x1) * Math.max(0, y2 - y1); } static sameClickableElement(a: Dict, b: Dict): boolean { return (a["bounds"] || "") === (b["bounds"] || "") && (a["resource-id"] || "") === (b["resource-id"] || "") && (a["text"] || "") === (b["text"] || "") && (a["content-desc"] || "") === (b["content-desc"] || "") && Boolean(a["long-clickable"]) === Boolean(b["long-clickable"]); } static chooseTapPoint(xmlContent: string, target: Dict): [number, number] | null { const center = (target["center"] as [number, number] | null) || PageCrawler.parseBoundsCenter((target["bounds"] as string) || ""); const targetBounds = PageCrawler.parseBoundsArr((target["bounds"] as string) || ""); if (!center || !targetBounds) return center; const targetArea = Math.max(1, PageCrawler.boundsArea(targetBounds)); const blockers: [number, number, number, number][] = []; for (const elem of PageCrawler.getClickableElements(xmlContent)) { if (PageCrawler.sameClickableElement(elem, target)) continue; const elemBounds = PageCrawler.parseBoundsArr((elem["bounds"] as string) || ""); if (!elemBounds || !PageCrawler.boundsOverlap(targetBounds, elemBounds)) continue; if (PageCrawler.boundsArea(elemBounds) <= targetArea * 0.9) blockers.push(elemBounds); } const blocked = (point: [number, number]) => blockers.some(b => PageCrawler.pointInBounds(point, b)); if (!blocked(center)) return center; const [x1, y1, x2, y2] = targetBounds; const w = Math.max(1, x2 - x1); const h = Math.max(1, y2 - y1); const candidates: [number, number][] = [ [x1 + Math.floor(w / 5), y1 + Math.floor(h / 4)], [x2 - Math.floor(w / 5), y1 + Math.floor(h / 4)], [x1 + Math.floor(w / 5), y1 + Math.floor(h / 2)], [x2 - Math.floor(w / 5), y1 + Math.floor(h / 2)], [x1 + Math.floor(w / 2), y1 + Math.floor(h / 4)], [x1 + Math.floor(w / 2), y2 - Math.floor(h / 4)], ]; for (const p of candidates) { if (PageCrawler.pointInBounds(p, targetBounds) && !blocked(p)) return p; } return center; } static getClickableElements(xmlContent: string): Dict[] { const elements: Dict[] = []; if (!xmlContent) return elements; try { const nodes = parseXmlNodes(xmlContent).filter(n => n.tag === "node"); const lvChildren = new Set(); for (let i = 0; i < nodes.length; i++) { const cls = nodes[i].attrs["class"] || ""; if (cls.includes("ListView") || cls.includes("GridView")) { const cd = nodes[i].depth + 1; for (let j = i + 1; j < nodes.length; j++) { if (nodes[j].depth < cd) break; if (nodes[j].depth === cd) lvChildren.add(j); } } } const extractText = (i: number): string => { const t = nodes[i].attrs["text"] || ""; if (t) return t; const d = nodes[i].depth; for (let j = i + 1; j < nodes.length; j++) { if (nodes[j].depth <= d) break; const t2 = nodes[j].attrs["text"] || ""; if (t2) return t2; } return ""; }; for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; if (n.attrs["enabled"] !== "true") continue; const isClk = n.attrs["clickable"] === "true"; const isLong = n.attrs["long-clickable"] === "true"; const isLV = lvChildren.has(i); const isChk = n.attrs["checkable"] === "true"; if (!(isClk || isLong || isLV || isChk)) continue; const bounds = n.attrs["bounds"] || ""; let text = n.attrs["text"] || ""; if (!text) text = extractText(i); const base: Dict = { "class": n.attrs["class"] || "", "text": text, "resource-id": n.attrs["resource-id"] || "", "content-desc": n.attrs["content-desc"] || "", "bounds": bounds, "center": PageCrawler.parseBoundsCenter(bounds), "focused": n.attrs["focused"] === "true", "checkable": n.attrs["checkable"] === "true", "checked": n.attrs["checked"] === "true", "selected": n.attrs["selected"] === "true", "enabled": n.attrs["enabled"] === "true", "clickable": n.attrs["clickable"] === "true", "scrollable": n.attrs["scrollable"] === "true", "list-item": isLV, }; if (isClk || isLV || isChk) elements.push({ ...base, "long-clickable": false }); if (isLong) elements.push({ ...base, "long-clickable": true }); } } catch (e) { console.log(` [WARN] XML 解析失败: ${e}`); } return elements; } static collectElementsDigest(xmlContent: string, scrollIndex: number = 0): Dict[] { const elements: Dict[] = []; if (!xmlContent) return elements; try { const nodes = parseXmlNodes(xmlContent).filter(n => n.tag === "node"); const extractText = (i: number): string => { const t = nodes[i].attrs["text"] || ""; if (t) return t; const d = nodes[i].depth; for (let j = i + 1; j < nodes.length; j++) { if (nodes[j].depth <= d) break; const childText = nodes[j].attrs["text"] || ""; if (childText) return childText; } return ""; }; for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; const cls = n.attrs["class"] || ""; const text = n.attrs["text"] || extractText(i); const desc = n.attrs["content-desc"] || ""; const rid = n.attrs["resource-id"] || ""; const clickable = n.attrs["clickable"] === "true"; const longClickable = n.attrs["long-clickable"] === "true"; const checkable = n.attrs["checkable"] === "true"; const scrollable = n.attrs["scrollable"] === "true"; if (!(text || desc || rid || clickable || longClickable || checkable || scrollable)) continue; const bounds = n.attrs["bounds"] || ""; elements.push({ "class": cls, "text": text, "resource-id": rid, "content-desc": desc, "bounds": bounds, "center": PageCrawler.parseBoundsCenter(bounds), "clickable": clickable, "long-clickable": longClickable, "checkable": checkable, "checked": n.attrs["checked"] === "true", "selected": n.attrs["selected"] === "true", "focused": n.attrs["focused"] === "true", "enabled": n.attrs["enabled"] !== "false", "scrollable": scrollable, "scroll_index": scrollIndex, }); } } catch (e) { console.log(` [WARN] XML 解析失败: ${e}`); } return elements; } static extractNodeKeys(xmlContent: string): Set { const keys = new Set(); if (!xmlContent) return keys; for (const n of parseXmlNodes(xmlContent).filter(n => n.tag === "node")) { keys.add(JSON.stringify([n.attrs["class"] || "", n.attrs["resource-id"] || "", n.attrs["text"] || "", n.attrs["content-desc"] || "", n.attrs["bounds"] || ""])); } return keys; } static findScrollableContainers(xmlContent: string): Dict[] { const cs: Dict[] = []; if (!xmlContent) return cs; for (const n of parseXmlNodes(xmlContent).filter(n => n.tag === "node")) { if (n.attrs["scrollable"] !== "true") continue; const cls = n.attrs["class"] || ""; if (HORIZONTAL_SCROLL_CLASSES.has(cls)) continue; const m = (n.attrs["bounds"] || "").match(/\d+/g); if (!m || m.length !== 4) continue; const [x1, y1, x2, y2] = m.map(Number); if (y2 - y1 < 200) continue; cs.push({ bounds: [x1, y1, x2, y2], class: cls, "resource-id": n.attrs["resource-id"] || "" }); } cs.sort((a, b) => { const ba = a["bounds"] as number[]; const bb = b["bounds"] as number[]; return (bb[2] - bb[0]) * (bb[3] - bb[1]) - (ba[2] - ba[0]) * (ba[3] - ba[1]); }); return cs; } static normalizeComponent(c: string): string { if (!c.includes("/")) return c; const [pkg, ...rest] = c.split("/"); let cls = rest.join("/"); if (cls.startsWith(".")) cls = pkg + cls; return `${pkg}/${cls}`; } static stableActivityKey(ai: Dict): string { const focus = (ai["current_focus"] as string) || ""; const resumed = (ai["resumed_activity"] as string) || ""; const m = focus.match(/[\w.]+\/[\w.]+/); if (m) return PageCrawler.normalizeComponent(m[0]); const am = resumed.match(/[\w.]+\/[\w.]+/); const act = am ? PageCrawler.normalizeComponent(am[0]) : "unknown"; const wm = focus.match(/(PopupWindow|Dialog|DecorView|Toast)/); return `${act}|${wm ? wm[1] : "overlay"}`; } static boundsSize(bounds: string): string { const b = PageCrawler.parseBoundsArr(bounds); return b ? `${b[2] - b[0]}x${b[3] - b[1]}` : ""; } static inBounds(inner: [number, number, number, number] | null, outer: [number, number, number, number] | null): boolean { if (!inner || !outer) return false; const [x1, y1, x2, y2] = inner; const [ox1, oy1, ox2, oy2] = outer; const cx = Math.floor((x1 + x2) / 2); const cy = Math.floor((y1 + y2) / 2); return ox1 <= cx && cx <= ox2 && oy1 <= cy && cy <= oy2; } static xmlScreenBounds(xmlContent: string): [number, number, number, number] | null { let maxX = 0; let maxY = 0; let minX = 0; let minY = 0; for (const n of parseXmlNodes(xmlContent).filter(n => n.tag === "node")) { const b = PageCrawler.parseBoundsArr(n.attrs["bounds"] || ""); if (!b) continue; const [x1, y1, x2, y2] = b; minX = Math.min(minX, x1); minY = Math.min(minY, y1); maxX = Math.max(maxX, x2); maxY = Math.max(maxY, y2); } return maxX > minX && maxY > minY ? [minX, minY, maxX, maxY] : null; } static inBottomBand(bounds: [number, number, number, number] | null, screenBounds: [number, number, number, number] | null): boolean { if (!bounds || !screenBounds) return false; const [, sy1, , sy2] = screenBounds; const [, y1, , y2] = bounds; const h = Math.max(1, sy2 - sy1); const cy = (y1 + y2) / 2; return cy >= sy1 + h * 0.70; } static looksClippedAgainstExpected(elem: Dict, expectedBounds: string): boolean { const current = PageCrawler.parseBoundsArr(String(elem["bounds"] || "")); const expected = PageCrawler.parseBoundsArr(expectedBounds); if (!current || !expected || String(elem["bounds"] || "") === expectedBounds) return false; const [, y1, , y2] = current; const [, ey1, , ey2] = expected; const currentH = Math.max(0, y2 - y1); const expectedH = Math.max(1, ey2 - ey1); return y1 < 60 || currentH < expectedH * 0.5; } static looksLikeDynamicBottomContent(e: Dict, screenBounds: [number, number, number, number] | null): boolean { if (!screenBounds) return false; if (e["resource-id"] || e["content-desc"] || !e["text"]) return false; const b = PageCrawler.parseBoundsArr(String(e["bounds"] || "")); if (!b) return false; const [sx1, sy1, sx2, sy2] = screenBounds; const [x1, y1, x2, y2] = b; const screenW = Math.max(1, sx2 - sx1); const screenH = Math.max(1, sy2 - sy1); const elemW = x2 - x1; const elemH = y2 - y1; const cy = (y1 + y2) / 2; return cy >= sy1 + screenH * 0.75 && elemW >= screenW * 0.45 && elemH >= 64; } static normTextForSignature(text: string): string { return (text || "").trim().replace(/\d+/g, "#").replace(/\s+/g, " ").slice(0, 40); } static stateFeature(e: Dict): string { const flags: string[] = []; for (const k of ["checked", "selected", "focused"]) if (e[k]) flags.push(k); if (e["enabled"] === false) flags.push("disabled"); if (e["checkable"]) flags.push("checkable"); if (e["scrollable"]) flags.push("scrollable"); if (e["long-clickable"]) flags.push("long"); return flags.join(","); } static templateKey(elem: Dict): string { return JSON.stringify([ elem["class"] || "", elem["resource-id"] || "", PageCrawler.boundsSize(String(elem["bounds"] || "")), Boolean(elem["long-clickable"]), Boolean(elem["checkable"]), ]); } static repeatedTemplates(clickable: Dict[]): Set { const buckets = new Map }>(); for (const e of clickable) { const key = PageCrawler.templateKey(e); const b = buckets.get(key) || { count: 0, labels: new Set() }; b.count += 1; b.labels.add(JSON.stringify([e["text"] || "", e["content-desc"] || ""])); buckets.set(key, b); } const out = new Set(); for (const [k, b] of buckets) if (b.count >= 2 && b.labels.size >= 2) out.add(k); return out; } static pageStateSignature(ai: Dict, xml: string): string { const ak = PageCrawler.stableActivityKey(ai); if (!xml) return `${ak}|empty`; const cl = PageCrawler.getClickableElements(xml); if (cl.length === 0) return `${ak}|no_clickable`; const features = cl.map(e => [ e["class"] || "", e["resource-id"] || "", e["content-desc"] || "", String(e["text"] || "").replace(/\d+/g, "#"), PageCrawler.boundsSize(String(e["bounds"] || "")), ].join("|")).sort().join(";"); return `${ak}|${createHash("md5").update(features, "utf-8").digest("hex").slice(0, 16)}`; } static pageIdentityFeatureMap(ai: Dict, xml: string): [string, Map] { const ak = PageCrawler.stableActivityKey(ai); if (!xml) return [ak, new Map([["empty", false]])]; const clickable = PageCrawler.getClickableElements(xml); if (clickable.length === 0) return [ak, new Map([["no_clickable", false]])]; const scrolls = PageCrawler.findScrollableContainers(xml); const primary = scrolls.length ? scrolls[0]["bounds"] as [number, number, number, number] : null; const screen = PageCrawler.xmlScreenBounds(xml); const repeated = PageCrawler.repeatedTemplates(clickable); const features = new Map(); const addFeature = (feature: string, weak: boolean) => features.set(feature, (features.get(feature) ?? true) && weak); for (const e of clickable) { const cls = String(e["class"] || ""); const rid = String(e["resource-id"] || ""); const desc = String(e["content-desc"] || ""); const bounds = String(e["bounds"] || ""); const size = PageCrawler.boundsSize(bounds); const state = PageCrawler.stateFeature(e); const inScroll = PageCrawler.inBounds(PageCrawler.parseBoundsArr(bounds), primary); const isListLike = Boolean(e["list-item"]) || (Boolean(rid) && LIST_ITEM_RID_PATTERNS.some(p => rid.includes(p))) || (inScroll && repeated.has(PageCrawler.templateKey(e))); const isDynamicBottom = PageCrawler.looksLikeDynamicBottomContent(e, screen); const isAnonymous = !rid && !desc && !e["text"]; const isBottomNoId = !rid && PageCrawler.inBottomBand(PageCrawler.parseBoundsArr(bounds), screen) && !e["selected"] && !e["checkable"]; if (isListLike || isDynamicBottom) { addFeature([isDynamicBottom ? "dynamic-bottom" : "item", cls, rid, size, state].join("|"), true); } else { addFeature(["ctrl", cls, rid, desc, PageCrawler.normTextForSignature(String(e["text"] || "")), state].join("|"), isAnonymous || isBottomNoId); } } for (const c of scrolls.slice(0, 2)) addFeature(`scroll|${c["class"] || ""}|${c["resource-id"] || ""}`, false); return [ak, features]; } static pageSignature(ai: Dict, xml: string): string { const [ak, features] = PageCrawler.pageIdentityFeatureMap(ai, xml); return `${ak}|${createHash("md5").update([...features.keys()].sort().join(";"), "utf-8").digest("hex").slice(0, 16)}`; } static rootIdentifiedSet(ai: Dict, xml: string): [string, Set] { const r = (ai["resumed_activity"] as string) || ""; const m = r.match(/[\w.]+\/[\w.]+/); const ak = m ? PageCrawler.normalizeComponent(m[0]) : "unknown"; if (!xml) return [ak, new Set()]; const cl = PageCrawler.getClickableElements(xml); const el = new Set(); for (const e of cl) { const d = (e["content-desc"] as string) || ""; const rid = (e["resource-id"] as string) || ""; if (!d && !rid) continue; el.add(JSON.stringify([(e["class"] as string) || "", rid, d])); } return [ak, el]; } static isSameRootPage(ri: [string, Set], ci: [string, Set], th: number = 0.6): boolean { const [ra, re] = ri; const [ca, ce] = ci; if (ra !== ca) return false; if (re.size === 0 && ce.size === 0) return true; if (re.size === 0 || ce.size === 0) return false; let inter = 0; for (const x of re) if (ce.has(x)) inter++; const uni = re.size + ce.size - inter; return inter / uni >= th; } static rootSimilarity(ri: [string, Set], ci: [string, Set]): [number, number, number] { const [ra, re] = ri; const [ca, ce] = ci; if (ra !== ca) return [0, 0, new Set([...re, ...ce]).size]; if (re.size === 0 && ce.size === 0) return [1, 0, 0]; let inter = 0; for (const x of re) if (ce.has(x)) inter++; const uni = re.size + ce.size - inter; return [uni ? inter / uni : 0, inter, uni]; } static isRootShell(ri: [string, Set] | null, ai: Dict, xml: string, requiredElemId: unknown[] | null = null): boolean { if (!ri) return false; const ci = PageCrawler.rootIdentifiedSet(ai, xml); if (ri[0] !== ci[0]) return false; if (requiredElemId) return PageCrawler.findElementOnScreen(xml, requiredElemId) !== null; const [similarity, intersectionCount] = PageCrawler.rootSimilarity(ri, ci); return similarity >= 0.5 || intersectionCount >= 2; } static findElementOnScreen(xml: string, eid: unknown[]): Dict | null { let rid: string, text: string, desc: string, bounds: string; let isLong: boolean | null; if (eid.length >= 5) { [rid, text, desc, bounds, isLong] = eid as [string, string, string, string, boolean]; } else { [rid, text, desc, bounds] = eid as [string, string, string, string]; isLong = null; } const els = PageCrawler.getClickableElements(xml); if (bounds) { for (const e of els) { if ((e["resource-id"] as string) === rid && (e["text"] as string) === text && (e["content-desc"] as string) === desc && (e["bounds"] as string) === bounds && (isLong === null || e["long-clickable"] === isLong)) return e; } } if (!rid && !text && !desc) return null; for (const e of els) { if ((e["resource-id"] as string) === rid && (e["text"] as string) === text && (e["content-desc"] as string) === desc && (isLong === null || e["long-clickable"] === isLong)) return e; } return null; } shouldSkipElement(elem: Dict): boolean { const rid = (elem["resource-id"] as string) || ""; const text = (elem["text"] as string) || ""; const desc = (elem["content-desc"] as string) || ""; const cls = (elem["class"] as string) || ""; for (const p of this.skipPatterns) { if (rid.includes(p)) return true; } if (PageCrawler.isDestructiveElement(elem, true)) return true; if (!text && !desc && !rid && cls.includes("ImageView")) return true; return false; } static isDestructiveElement(elem: Dict, log: boolean = false): boolean { const rid = String(elem["resource-id"] || ""); const text = String(elem["text"] || ""); const desc = String(elem["content-desc"] || ""); const semantic = `${text} ${desc}`.trim().toLowerCase(); const ridLower = rid.toLowerCase(); if (semantic) { for (const pattern of DESTRUCTIVE_TEXT_PATTERNS_DEFAULT) { if (semantic.includes(pattern.toLowerCase())) { if (log) console.log(` [SAFE-SKIP] skip possible destructive control: ${text || desc || rid}`); return true; } } } if (ridLower) { for (const pattern of DESTRUCTIVE_RESOURCE_ID_PATTERNS_DEFAULT) { if (ridLower.includes(pattern)) { if (log) console.log(` [SAFE-SKIP] skip possible destructive control: ${text || desc || rid}`); return true; } } } return false; } static stepLooksDestructive(step: Dict): boolean { return PageCrawler.isDestructiveElement({ "resource-id": step["resource-id"] || "", "text": step["text"] || step["element"] || "", "content-desc": step["content-desc"] || "", }); } static isInputElement(elem: Dict): boolean { const cls = String(elem["class"] || "").toLowerCase(); const rid = String(elem["resource-id"] || "").toLowerCase(); const desc = String(elem["content-desc"] || "").toLowerCase(); const text = String(elem["text"] || "").toLowerCase(); if (cls.includes("edittext") || cls.includes("autocompletetext")) return true; if (elem["focused"] && /search|input|edit|搜索|输入/.test(`${rid} ${desc} ${text}`)) return true; return false; } static hasFocusedInput(xmlContent: string): boolean { return PageCrawler.getClickableElements(xmlContent).some(e => Boolean(e["focused"]) && PageCrawler.isInputElement(e)); } static elementLabel(elem: Dict): string { return String(elem["text"] || elem["content-desc"] || elem["resource-id"] || elem["class"] || "unknown"); } static elementSummary(elem: Dict): Dict { return { class: elem["class"] || "", text: elem["text"] || "", resource_id: elem["resource-id"] || "", content_desc: elem["content-desc"] || "", bounds: elem["bounds"] || "", clickable: elem["clickable"], long_clickable: elem["long-clickable"], checkable: elem["checkable"], checked: elem["checked"], selected: elem["selected"], enabled: elem["enabled"], scrollable: elem["scrollable"], }; } recordGap(gap: Dict): void { const elem = (gap["element"] as Dict | undefined) || {}; const key = JSON.stringify({ type: gap["type"], page_id: gap["page_id"], variant_id: gap["variant_id"], label: gap["label"], bounds: elem["bounds"], reason: gap["reason"], }); if (this.gapKeys.has(key)) return; this.gapKeys.add(key); this.gaps.push(gap); } loadGaps(): void { this.gaps = []; this.gapKeys = new Set(); const gapFile = path.join(this.outputRoot, "gaps.json"); if (!fs.existsSync(gapFile)) return; try { const loaded = JSON.parse(fs.readFileSync(gapFile, "utf-8")); if (Array.isArray(loaded)) for (const gap of loaded) if (gap && typeof gap === "object") this.recordGap(gap as Dict); } catch (e) { console.log(` [WARN] failed to load gaps.json: ${e}; gaps will be rebuilt`); } } recordPageGaps(record: Dict, variantId: string | null = null): void { const pageId = record["page_id"]; if (!pageId) return; for (const elem of ((record["elements_digest"] as Dict[]) || [])) { const label = PageCrawler.elementLabel(elem); const base: Dict = { page_id: pageId, variant_id: variantId, label, element: PageCrawler.elementSummary(elem), click_path: record["click_path"], }; if (PageCrawler.isInputElement(elem)) { this.recordGap({ ...base, type: "input_required", reason: "text input/search requires a semantic value; BFS click-only traversal will not fill it", suggested_resolution: "manual_crawl or a small configured input flow", }); } if (PageCrawler.isDestructiveElement(elem)) { this.recordGap({ ...base, type: "destructive_action_skipped", reason: "control may modify, delete, clear, reset, or remove user data; crawler will not click it", suggested_resolution: "only enable through an explicit test fixture or manual confirmation", }); } } for (const elem of ((record["clickable_elements"] as Dict[]) || [])) { if (elem["resource-id"] || elem["text"] || elem["content-desc"]) continue; this.recordGap({ type: "weak_locator", page_id: pageId, variant_id: variantId, label: PageCrawler.elementLabel(elem), element: PageCrawler.elementSummary(elem), click_path: record["click_path"], reason: "clickable element has no resource-id/text/content-desc; mapping can only fall back to bounds", suggested_resolution: "prefer app-side stable id/content-desc when possible", }); } } shouldSkipSameActivity(stepSigs: string[], elem: Dict): boolean { if (!this.skipSameActivityClicks || stepSigs.length < 2) return false; const parentSig = stepSigs[stepSigs.length - 1] || ""; const grandparentSig = stepSigs[stepSigs.length - 2] || ""; const parentAct = parentSig.includes("|") ? parentSig.split("|")[0] : parentSig; const grandparentAct = grandparentSig.includes("|") ? grandparentSig.split("|")[0] : grandparentSig; const parentCls = parentAct.includes("/") ? parentAct.split("/").pop() || parentAct : parentAct; const grandparentCls = grandparentAct.includes("/") ? grandparentAct.split("/").pop() || grandparentAct : grandparentAct; if (parentCls !== grandparentCls) return false; const rid = String(elem["resource-id"] || ""); return Boolean(rid) && LIST_ITEM_RID_PATTERNS.some(p => rid.includes(p)); } isInTargetApp(ai: Dict): boolean { const f = (ai["current_focus"] as string) || ""; const r = (ai["resumed_activity"] as string) || ""; const m = f.match(/([\w.]+)\/[\w.]+/); if (m && m[1] !== this.package) return false; for (const marker of FOREIGN_FOCUS_MARKERS) if (f.includes(marker)) return false; return f.includes(this.package) || r.includes(this.package); } scrollAndDump(pd: string, cb: [number, number, number, number], initXml: string = ""): { xmlList: string[]; xmlPaths: string[]; ssPaths: string[] } { const [x1, y1, x2, y2] = cb; const cx = Math.floor((x1 + x2) / 2); const h = y2 - y1; const fy = y1 + Math.floor(h * 0.7); const ty = y1 + Math.floor(h * 0.3); const xmlList: string[] = []; const xmlPaths: string[] = []; const ssPaths: string[] = []; let prevKeys: Set | null = initXml ? PageCrawler.extractNodeKeys(initXml) : null; for (let i = 0; i < this.maxScrolls; i++) { this.adb.shell("input", "swipe", String(cx), String(fy), String(cx), String(ty), String(this.adb.scrollDurationMs)); this.adb.waitForIdle(); const nx = this.adb.dumpXmlQuick(); if (PageCrawler.hasFocusedInput(nx)) { console.log(" [INFO] 滚动后仍处于输入态,停止滚动采集"); if (this.adb.isSoftKeyboardShown()) { console.log(" [INFO] 软键盘可见,收起键盘"); this.adb.pressBack(); } break; } const nk = PageCrawler.extractNodeKeys(nx); if (prevKeys && setsEqual(prevKeys, nk)) { console.log(` 📜 滚动 ${i + 1} 次后到达底部,跳过截图`); break; } prevKeys = nk; xmlList.push(nx); const xp = path.join(pd, `view_scroll_${i + 1}.xml`); fs.writeFileSync(xp, nx, "utf-8"); xmlPaths.push(xp); const sp = path.join(pd, `screenshot_scroll_${i + 1}.png`); const d = this.adb.screencapBytes(); if (d) { fs.writeFileSync(sp, d); ssPaths.push(sp); console.log(` 📸 滚动 ${i + 1}: XML + 截图已保存`); } } return { xmlList, xmlPaths, ssPaths: ssPaths }; } capturePage(label: string): { record: Dict; xmlContent: string; clickable: Dict[]; scrollBounds: [number, number, number, number] | null } { const tmpDir = path.join(this.outputRoot, "_tmp_capture"); if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); fs.mkdirSync(tmpDir, { recursive: true }); console.log(`\n📄 [采集中] ${label}`); const ai = this.adb.getCurrentActivity(); console.log(` Focus : ${(ai["current_focus"] as string) || "N/A"}`); console.log(` Activity: ${(ai["resumed_activity"] as string) || "N/A"}`); let xc = this.adb.getViewXmlFast(tmpDir); if (PageCrawler.hasFocusedInput(xc) && this.adb.isSoftKeyboardShown()) { console.log(" [INFO] 检测到输入框聚焦,先收起键盘再采集"); this.adb.pressBack(); xc = this.adb.getViewXmlFast(tmpDir); } console.log(` View XML 已保存 (${xc.length} bytes)`); this.adb.takeScreenshot(tmpDir); console.log(` 截图已保存`); const allSs = [path.join(tmpDir, "screenshot.png")]; const allVx = [path.join(tmpDir, "view.xml")]; const allXc = [xc]; const sc = PageCrawler.findScrollableContainers(xc); let sb: [number, number, number, number] | null = null; if (sc.length > 0) { const c = sc[0]; console.log(` 📜 检测到可滚动容器: ${c["class"]} ${(c["resource-id"] as string) || ""}`); sb = c["bounds"] as [number, number, number, number]; const sr = this.scrollAndDump(tmpDir, sb, xc); if (sr.xmlList.length) { allXc.push(...sr.xmlList); allVx.push(...sr.xmlPaths); allSs.push(...sr.ssPaths); console.log(` 📜 滚动采集完成:${sr.xmlList.length} 次滚动,共 ${allSs.length} 张截图`); } } const seen = new Set(); const cl: Dict[] = []; const seenDigest = new Set(); const elementsDigest: Dict[] = []; for (let idx = 0; idx < allXc.length; idx++) { for (const e of PageCrawler.getClickableElements(allXc[idx])) { const k = JSON.stringify([(e["resource-id"] as string) || "", (e["text"] as string) || "", (e["content-desc"] as string) || "", (e["class"] as string) || "", (e["bounds"] as string) || "", e["long-clickable"] ?? false]); if (!seen.has(k)) { seen.add(k); e["scroll_index"] = idx; cl.push(e); } } for (const e of PageCrawler.collectElementsDigest(allXc[idx], idx)) { const k = JSON.stringify([(e["class"] as string) || "", (e["resource-id"] as string) || "", (e["text"] as string) || "", (e["content-desc"] as string) || "", (e["bounds"] as string) || "", e["clickable"] || false, e["long-clickable"] || false, e["checkable"] || false, e["scrollable"] || false]); if (!seenDigest.has(k)) { seenDigest.add(k); elementsDigest.push(e); } } } console.log(` 可点击元素: ${cl.length} 个`); return { record: { page_id: null, label, timestamp: nowIso(), activity_info: ai, page_identity_signature: PageCrawler.pageSignature(ai, xc), page_state_signature: PageCrawler.pageStateSignature(ai, xc), clickable_count: cl.length, clickable_elements: cl, elements_digest_count: elementsDigest.length, elements_digest: elementsDigest, scroll_container_bounds: sb ? Array.from(sb) : null, _tmp_dir: tmpDir, _screenshots: allSs, _view_xmls: allVx, }, xmlContent: xc, clickable: cl, scrollBounds: sb, }; } restartToRoot(lp: string | null, la: string | null, kw: string | null = null, ri: [string, Set] | null = null): boolean { if (!lp || !la) { console.log(" [WARN] 包名或 Activity 名为空,无法重启"); return false; } const comp = `${lp}/${la}`; const isAtRoot = (): boolean => { const ai = this.adb.getCurrentActivity(); const f = (ai["current_focus"] as string) || ""; const r = (ai["resumed_activity"] as string) || ""; if (kw && !f.includes(kw) && !r.includes(kw)) return false; if (ri) { const x = this.adb.dumpXmlQuick(); const ci = PageCrawler.rootIdentifiedSet(ai, x); if (!PageCrawler.isSameRootPage(ri, ci)) { let inter = 0; for (const x2 of ri[1]) if (ci[1].has(x2)) inter++; const uni = ri[1].size + ci[1].size - inter; console.log(` [DEBUG] 根页面相似度: ${(uni ? inter / uni : 0).toFixed(2)} (交集${inter}/并集${uni})`); return false; } } return true; }; const tryRec = (): boolean => { if (!this.rootRecoveryClicks.length) return false; console.log(` [INFO] 尝试通过点击路径恢复到根页面...`); for (const s of this.rootRecoveryClicks) { const x = this.adb.dumpXmlQuick(); const cl = PageCrawler.getClickableElements(x); let found: Dict | null = null; for (const e of cl) { if (s["content-desc"] && e["content-desc"] === s["content-desc"]) { found = e; break; } if (s["text"] && e["text"] === s["text"]) { found = e; break; } if (s["resource-id"] && e["resource-id"] === s["resource-id"]) { found = e; break; } } if (found && found["center"]) { const c = PageCrawler.chooseTapPoint(x, found) || found["center"] as [number, number]; console.log(` [INFO] 点击: ${(s["content-desc"] as string) || (s["text"] as string) || (s["resource-id"] as string)}`); this.adb.tap(c[0], c[1]); } else if (s["center"]) { const c = s["center"] as [number, number]; console.log(` [INFO] 使用固定坐标点击: ${JSON.stringify(c)}`); this.adb.tap(c[0], c[1]); } else { console.log(` [WARN] 恢复路径中未找到元素: ${JSON.stringify(s)}`); return false; } } return isAtRoot(); }; this.adb.shell("am", "force-stop", lp); sleepMs(1000); this.adb.shell("am", "start", "-n", comp, "--activity-clear-task", "--activity-clear-top"); this.adb.waitForIdle(); for (let i = 0; i < 4; i++) { if (isAtRoot()) return true; console.log(` [INFO] 等待页面加载完成... (重试 ${i + 1}/4)`); sleepMs(2000); } console.log(` [WARN] force-stop 后未到达根页面(App 恢复了上次的 Tab 状态)`); if (tryRec()) return true; console.log(` [INFO] 尝试 back + 点击路径恢复...`); this.adb.pressBack(); if (isAtRoot()) return true; if (tryRec()) return true; console.log(` [WARN] 所有恢复方式均失败,跳过此次导航`); return false; } static learnedRootPathKey(clickPath: Dict[]): string { return JSON.stringify(clickPath); } static learnedRootPathPriority(candidate: LearnedRootPath): number { const attempts = candidate.successes + candidate.failures; return attempts ? candidate.successes / attempts : 0.5; } static compareLearnedRootPaths(left: LearnedRootPath, right: LearnedRootPath): number { const priorityDiff = PageCrawler.learnedRootPathPriority(right) - PageCrawler.learnedRootPathPriority(left); if (priorityDiff !== 0) return priorityDiff; if (left.successes !== right.successes) return right.successes - left.successes; return left.failures - right.failures; } static isRootPageRecord(page: Dict): boolean { const clickPath = page["click_path"]; return clickPath == null || (Array.isArray(clickPath) && clickPath.length === 0); } isKnownRootSignature(signature: string): boolean { if (!signature) return false; if (signature === this.rootSignature) return true; const rootPage = this.pagesIndex.find(PageCrawler.isRootPageRecord); return ((rootPage?.["_merged_identity_signatures"] as string[] | undefined) || []).includes(signature); } loadLearnedRootPaths(): void { const statsPath = path.join(this.outputRoot, "crawl_stats.json"); if (!fs.existsSync(statsPath)) return; try { const payload = JSON.parse(fs.readFileSync(statsPath, "utf-8")) as Dict; const saved = payload["learned_root_paths"]; if (!Array.isArray(saved)) return; const restored: LearnedRootPath[] = []; const seen = new Set(); for (const item of saved as Dict[]) { if (!Array.isArray(item["steps"]) || item["steps"].length === 0) continue; const steps = item["steps"] as Dict[]; const key = PageCrawler.learnedRootPathKey(steps); if (seen.has(key)) continue; seen.add(key); restored.push({ steps, successes: Math.max(0, Math.trunc(Number(item["successes"]) || 0)), failures: Math.max(0, Math.trunc(Number(item["failures"]) || 0)), }); } this.learnedRootPaths = restored.sort(PageCrawler.compareLearnedRootPaths).slice(0, LEARNED_ROOT_PATH_LIMIT); if (this.learnedRootPaths.length) console.log(` [INFO] 恢复 ${this.learnedRootPaths.length} 条 learned root recovery paths`); } catch (e) { console.log(` [WARN] 恢复 learned root recovery paths 失败: ${e}`); } } recordLearnedRootPath(clickPath: Dict[]): void { if (!clickPath.length) return; const key = PageCrawler.learnedRootPathKey(clickPath); if (this.learnedRootPaths.some(candidate => PageCrawler.learnedRootPathKey(candidate.steps) === key)) return; const candidate: LearnedRootPath = { steps: JSON.parse(JSON.stringify(clickPath)) as Dict[], successes: 0, failures: 0, }; this.learnedRootPaths.push(candidate); this.learnedRootPaths.sort(PageCrawler.compareLearnedRootPaths); const evicted = this.learnedRootPaths.length > LEARNED_ROOT_PATH_LIMIT ? this.learnedRootPaths.pop()! : null; console.log(` [INFO] learned root recovery path: ${clickPath.map(s => s["element"] || "?").join(" -> ")}`); if (evicted) { console.log(` [INFO] 淘汰 learned root recovery path: ${evicted.steps.map(s => s["element"] || "?").join(" -> ")} (success=${evicted.successes}, failure=${evicted.failures})`); } this.writeCrawlStats(); } tryReplayLearnedPath(clickPath: Dict[], acceptRoot: () => boolean): boolean { if (!clickPath.length) return false; console.log(` [INFO] try learned root recovery path: ${clickPath.map(s => s["element"] || "?").join(" -> ")}`); const elemIdForStep = (step: Dict): unknown[] => [ String(step["resource-id"] || ""), String(step["text"] || ""), String(step["content-desc"] || ""), String(step["bounds"] || ""), Boolean(step["long-clickable"]), ]; let startIndex = 0; let startFound: Dict | null = null; let currentXml = this.adb.dumpXmlQuick(); for (let i = clickPath.length - 1; i >= 0; i--) { const found = PageCrawler.findElementOnScreen(currentXml, elemIdForStep(clickPath[i])); if (found && found["center"]) { startIndex = i; startFound = found; if (i > 0) console.log(` [INFO] learned path resumes from step ${i + 1}/${clickPath.length}`); break; } } for (let i = startIndex; i < clickPath.length; i++) { const step = clickPath[i]; if (PageCrawler.stepLooksDestructive(step)) return false; let xml = (i === startIndex) ? currentXml : this.adb.dumpXmlQuick(); const ssc = step["scroll_info"] as Dict | undefined; if (ssc && ((ssc["scroll_index"] as number) || 0) > 0) { this.adb.scrollToPosition(ssc["container_bounds"] as [number, number, number, number], ssc["scroll_index"] as number); xml = this.adb.dumpXmlQuick(); } const found = (i === startIndex && startFound) ? startFound : PageCrawler.findElementOnScreen(xml, elemIdForStep(step)); if (!found || !found["center"] || PageCrawler.isDestructiveElement(found)) return false; const isLong = Boolean(step["long-clickable"]); const c = PageCrawler.chooseTapPoint(xml, found) || found["center"] as [number, number]; this.adb.tapOrLongPress(c[0], c[1], isLong); currentXml = ""; } return acceptRoot(); } restartToRootForNavigation(lp: string | null, la: string | null, kw: string | null = null, ri: [string, Set] | null = null, requiredElemId: unknown[] | null = null): boolean { if (!lp || !la) { console.log(" [WARN] missing package/activity, cannot restart"); return false; } const comp = `${lp}/${la}`; const rootStatus = (): "exact" | "shell" | "off" => { const ai = this.adb.getCurrentActivity(); const f = String(ai["current_focus"] || ""); const r = String(ai["resumed_activity"] || ""); if (kw && !f.includes(kw) && !r.includes(kw)) return "off"; if (!ri) return "exact"; const x = this.adb.dumpXmlQuick(); const ci = PageCrawler.rootIdentifiedSet(ai, x); if (PageCrawler.isSameRootPage(ri, ci)) return "exact"; if (PageCrawler.isRootShell(ri, ai, x, requiredElemId)) return "shell"; const [sim, inter, uni] = PageCrawler.rootSimilarity(ri, ci); console.log(` [DEBUG] root status=off, similarity=${sim.toFixed(2)} (${inter}/${uni})`); return "off"; }; const acceptRoot = (allowShell: boolean = false): boolean => { const status = rootStatus(); if (status === "exact") { this.lastRestartRootExact = true; return true; } if (status === "shell" && allowShell) { this.lastRestartRootExact = false; if (ri) { const ai = this.adb.getCurrentActivity(); const x = this.adb.dumpXmlQuick(); const [sim, inter, uni] = PageCrawler.rootSimilarity(ri, PageCrawler.rootIdentifiedSet(ai, x)); console.log(` [WARN] 所有 exact root 恢复方式失败,最后兜底接受 root shell (similarity=${sim.toFixed(2)}, 交集${inter}/并集${uni})`); } else { console.log(" [WARN] 所有 exact root 恢复方式失败,最后兜底接受 root shell"); } return true; } return false; }; const isCurrentTargetApp = (): boolean => this.isInTargetApp(this.adb.getCurrentActivity()); const tryRecoveryClicks = (): boolean => { if (!this.rootRecoveryClicks.length) return false; if (!isCurrentTargetApp()) { console.log(" [INFO] 当前已不在目标 App,跳过点击路径恢复"); return false; } console.log(" [INFO] 尝试通过点击路径恢复到根页面..."); for (const s of this.rootRecoveryClicks) { if (PageCrawler.stepLooksDestructive(s)) { console.log(" [SAFE-SKIP] 恢复路径包含可能修改/清理数据的控件,放弃该恢复路径"); return false; } const x = this.adb.dumpXmlQuick(); const cl = PageCrawler.getClickableElements(x); let found: Dict | null = null; for (const e of cl) { if (s["content-desc"] && e["content-desc"] === s["content-desc"]) { found = e; break; } if (s["text"] && e["text"] === s["text"]) { found = e; break; } if (s["resource-id"] && e["resource-id"] === s["resource-id"]) { found = e; break; } } if (found && PageCrawler.isDestructiveElement(found)) { console.log(" [SAFE-SKIP] 恢复路径命中可能修改/清理数据的控件,放弃点击"); return false; } if (found && found["center"]) { const c = PageCrawler.chooseTapPoint(x, found) || found["center"] as [number, number]; console.log(` [INFO] 点击: ${String(s["content-desc"] || s["text"] || s["resource-id"] || "?")}`); this.adb.tap(c[0], c[1]); } else if (s["center"]) { const c = s["center"] as [number, number]; console.log(` [INFO] 使用固定坐标点击: ${JSON.stringify(c)}`); this.adb.tap(c[0], c[1]); } else { console.log(` [WARN] 恢复路径中未找到元素: ${JSON.stringify(s)}`); return false; } } return acceptRoot(); }; let learnedRecoveryAllowed = false; const tryLearned = (): boolean => { if (!learnedRecoveryAllowed) return false; if (!isCurrentTargetApp()) { console.log(" [INFO] 当前已不在目标 App,跳过 learned root recovery"); return false; } const candidates = this.learnedRootPaths.slice(0, LEARNED_ROOT_ATTEMPTS_PER_RECOVERY); const resetToLaunchState = (): boolean => { this.adb.shell("am", "force-stop", lp); sleepMs(1000); this.adb.shell("am", "start", "-n", comp, "--activity-clear-task", "--activity-clear-top"); this.adb.waitForIdle(); return isCurrentTargetApp(); }; for (let i = 0; i < candidates.length; i++) { if (i > 0) { console.log(" [INFO] 重置 App 状态后尝试下一条 learned root recovery path..."); if (!resetToLaunchState()) break; if (acceptRoot()) return true; } const candidate = candidates[i]; const success = this.tryReplayLearnedPath(candidate.steps, () => acceptRoot()); if (success) candidate.successes++; else candidate.failures++; this.learnedRootPaths.sort(PageCrawler.compareLearnedRootPaths); this.writeCrawlStats(); console.log(` [INFO] learned root path result: ${success ? "success" : "failure"} (${candidate.successes}/${candidate.successes + candidate.failures})`); if (success) return true; } if (!candidates.length) return false; console.log(" [INFO] learned root recovery 均失败,重置 App 状态后继续其他恢复方式..."); return resetToLaunchState() && acceptRoot(); }; if (acceptRoot()) { console.log(" [INFO] 当前已在 exact root"); return true; } console.log(" [INFO] 当前不在 exact root,先尝试 Back 回退..."); let backExitedTargetApp = false; for (let i = 0; i < BACK_UNWIND_MAX_DEFAULT; i++) { this.adb.pressBack(); sleepMs(300); if (!isCurrentTargetApp()) { backExitedTargetApp = true; console.log(" [INFO] Back 后已离开目标 App,停止继续 Back,改用重启恢复"); break; } if (acceptRoot()) { console.log(` [INFO] Back 回退成功 (${i + 1}/${BACK_UNWIND_MAX_DEFAULT})`); return true; } } console.log(" [INFO] Back 回退未恢复,执行 force-stop + start..."); this.adb.shell("am", "force-stop", lp); sleepMs(1000); this.adb.shell("am", "start", "-n", comp, "--activity-clear-task", "--activity-clear-top"); this.adb.waitForIdle(); if (isCurrentTargetApp() && acceptRoot()) { console.log(" [INFO] force-stop + start 恢复到 exact root"); return true; } console.log(" [INFO] 重启后暂未到达 exact root,等待 2 秒加载宽限..."); sleepMs(2000); if (isCurrentTargetApp() && acceptRoot()) { console.log(" [INFO] force-stop + start 在加载宽限后恢复到 exact root"); return true; } learnedRecoveryAllowed = true; console.log(" [WARN] force-stop 后未到达根页面(App 恢复了上次的 Tab 状态)"); if (tryLearned()) { console.log(" [INFO] learned root recovery 成功"); return true; } if (tryRecoveryClicks()) { console.log(" [INFO] 点击路径恢复成功"); return true; } if (backExitedTargetApp) { console.log(" [INFO] 本轮 Back 已退出过目标 App,跳过额外 back + 点击路径恢复"); if (requiredElemId && acceptRoot(true)) return true; console.log(" [WARN] 所有 exact root 恢复方式均失败,跳过此次导航"); return false; } console.log(" [INFO] 尝试 back + 点击路径恢复..."); this.adb.pressBack(); sleepMs(300); if (!isCurrentTargetApp()) { console.log(" [INFO] Back 后已离开目标 App,跳过点击路径恢复"); return false; } if (acceptRoot()) { console.log(" [INFO] 额外 Back 后恢复到 exact root"); return true; } if (tryRecoveryClicks()) { console.log(" [INFO] 点击路径恢复成功"); return true; } if (requiredElemId && acceptRoot(true)) return true; console.log(" [WARN] 所有 exact root 恢复方式均失败,跳过此次导航"); return false; } navigateViaRestart(cp: Dict[], lp: string | null, la: string | null, kw: string | null = null, ss: string[] | null = null, ri: [string, Set] | null = null): boolean { if (!lp || !la) { console.log(" [WARN] 包名或 Activity 名为空,无法导航"); return false; } if (cp.length === 0) return true; const first = cp[0]; const firstElemId = [(first["resource-id"] as string) || "", (first["text"] as string) || "", (first["content-desc"] as string) || "", (first["bounds"] as string) || "", Boolean(first["long-clickable"])]; if (!this.restartToRootForNavigation(lp, la, kw, ri, firstElemId)) { console.log(" [ERR] 无法回到根页面,导航失败"); return false; } for (let i = 0; i < cp.length; i++) { const s = cp[i]; let x = this.adb.dumpXmlQuick(); const ssc = s["scroll_info"] as Dict | undefined; if (ssc && ((ssc["scroll_index"] as number) || 0) > 0) { this.adb.scrollToPosition(ssc["container_bounds"] as [number, number, number, number], ssc["scroll_index"] as number); x = this.adb.dumpXmlQuick(); } const eid = [(s["resource-id"] as string) || "", (s["text"] as string) || "", (s["content-desc"] as string) || "", (s["bounds"] as string) || "", Boolean(s["long-clickable"])]; const f = PageCrawler.findElementOnScreen(x, eid); const isLong = (s["long-clickable"] as boolean) || false; if (f && f["center"]) { if (PageCrawler.isDestructiveElement(f)) return false; const c = PageCrawler.chooseTapPoint(x, f) || f["center"] as [number, number]; this.adb.tapOrLongPress(c[0], c[1], isLong); } else if (i === 0 && !this.lastRestartRootExact) { console.log(" [WARN] first step not found in root shell; skip coordinate fallback"); return false; } else if (s["center"]) { console.log(` [WARN] 未找到元素 ${s["element"] || "?"},使用记录坐标`); const c = s["center"] as [number, number]; this.adb.tapOrLongPress(c[0], c[1], isLong); } else { console.log(` [ERR] 无法导航到步骤: ${s["element"] || "?"}`); return false; } if (ss && i < ss.length && ss[i]) { const vx = this.adb.dumpXmlQuick(); const vi = this.adb.getCurrentActivity(); const asig = PageCrawler.pageSignature(vi, vx); if (asig !== ss[i]) { const ea = ss[i].split("|")[0]; const aa = asig.split("|")[0]; if (ea !== aa) { console.log(` [WARN] 导航步骤 ${i + 1} 后 Activity 不匹配 (期望=${ea}, 实际=${aa})`); return false; } } } } return true; } saveQueue(q: QueueItem[]): void { try { fs.writeFileSync(path.join(this.outputRoot, "queue_checkpoint.json"), JSON.stringify(q.map(([cp, eid, ss, si, tmpl]) => [cp, Array.from(eid), ss, si, tmpl])), "utf-8"); } catch (e) { console.log(` [WARN] 保存队列检查点失败: ${e}`); } } loadQueue(): QueueItem[] | null { const qf = path.join(this.outputRoot, "queue_checkpoint.json"); if (!fs.existsSync(qf)) return null; try { const items = JSON.parse(fs.readFileSync(qf, "utf-8")) as unknown[]; const q: QueueItem[] = []; for (const it of items) { const [cp, eid, ss, si, tmpl] = it as [Dict[], unknown[], string[], Dict, TemplateKey]; q.push([cp, eid, ss, si, tmpl ?? null]); } return q; } catch (e) { console.log(` [WARN] 加载队列检查点失败: ${e},将重建队列`); return null; } } rebuildQueueFromPages(pi: Dict[]): QueueItem[] { const q: QueueItem[] = []; for (const p of pi) { const cp = (p["click_path"] as Dict[]) || []; const cl = (p["clickable_elements"] as Dict[]) || []; const scb = p["scroll_container_bounds"] as number[] | null; const repeated = PageCrawler.repeatedTemplates(cl); for (const e of cl) { if (this.shouldSkipElement(e)) continue; const eid: unknown[] = [(e["resource-id"] as string) || "", (e["text"] as string) || "", (e["content-desc"] as string) || "", (e["bounds"] as string) || "", (e["long-clickable"] as boolean) || false]; const si = (e["scroll_index"] as number) || 0; let sInfo: Dict | null = null; if (si > 0 && scb) sInfo = { scroll_index: si, container_bounds: Array.from(scb) }; const tmpl = repeated.has(PageCrawler.templateKey(e)) ? PageCrawler.templateKey(e) : null; q.push([cp, eid, [], sInfo, tmpl]); } } return q; } tryLoadCheckpoint(): [Dict[], Set, number, QueueItem[], Set] | null { const idx = path.join(this.outputRoot, "index.json"); if (!fs.existsSync(idx)) return null; if (fs.readdirSync(this.outputRoot).filter(n => n.startsWith("page_")).length === 0) return null; let pi: Dict[]; try { pi = JSON.parse(fs.readFileSync(idx, "utf-8")) as Dict[]; } catch (e) { console.log(` [WARN] 读取 index.json 失败: ${e},重新开始`); return null; } if (!pi.length) return null; console.log(`\n⏩ 发现已有检查点:${pi.length} 个页面,尝试断点续跑...`); const vs = new Set(); let pc = 0; for (const p of pi) { const pid = (p["page_id"] as string) || ""; if (!pid) continue; const m = pid.match(/page_(\d+)/); if (m) pc = Math.max(pc, parseInt(m[1], 10)); const vp = path.join(this.outputRoot, pid, "view.xml"); if (fs.existsSync(vp)) vs.add(PageCrawler.pageSignature(p["activity_info"] as Dict, fs.readFileSync(vp, "utf-8"))); for (const mergedSig of ((p["_merged_identity_signatures"] as string[]) || [])) if (mergedSig) vs.add(mergedSig); } const sq = this.loadQueue(); let q: QueueItem[]; if (sq) { q = sq; console.log(` 队列已从检查点恢复:${q.length} 个待处理元素(精确续跑)`); } else { q = this.rebuildQueueFromPages(pi); console.log(` 队列已重建(fallback):${q.length} 个元素(含已探索项,BFS 会自动跳过)`); } console.log(` 已恢复 ${vs.size} 个已访问签名,队列 ${q.length} 个待处理元素`); return [pi, vs, pc, q, new Set()]; } _commitCommon(record: Dict, pageId: string): string { const pageDir = path.join(this.outputRoot, pageId); const tmpDir = record["_tmp_dir"] as string; if (fs.existsSync(tmpDir)) fs.renameSync(tmpDir, pageDir); else fs.mkdirSync(pageDir, { recursive: true }); record["page_id"] = pageId; record["screenshot"] = `${pageId}/screenshot.png`; record["view_xml"] = `${pageId}/view.xml`; const ssList = [`${pageId}/screenshot.png`]; for (const ss of (record["_screenshots"] as string[]) || []) { const n = path.basename(ss); if (n !== "screenshot.png") ssList.push(`${pageId}/${n}`); } record["screenshots"] = ssList; const vxList = [`${pageId}/view.xml`]; for (const vx of (record["_view_xmls"] as string[]) || []) { const n = path.basename(vx); if (n !== "view.xml") vxList.push(`${pageId}/${n}`); } record["view_xmls"] = vxList; this.recordPageGaps(record); delete record["_tmp_dir"]; delete record["_screenshots"]; delete record["_view_xmls"]; fs.writeFileSync(path.join(pageDir, "meta.json"), JSON.stringify(record, null, 2), "utf-8"); console.log(` ✅ 已保存为 [${pageId}]`); return pageId; } commitPage(record: Dict): string { this.pageCounter += 1; let an = ""; const ai = record["activity_info"] as Dict; const r = (ai["resumed_activity"] as string) || ""; let ma = r.match(/[\w.]+\/([\w.]+)/); if (!ma) { const f = (ai["current_focus"] as string) || ""; ma = f.match(/[\w.]+\/([\w.]+)/); } if (ma) an = ma[1].split(".").pop()!; return this._commitCommon(record, `page_${String(this.pageCounter).padStart(4, "0")}${an ? `_${an}` : ""}`); } commitManualPage(record: Dict, pc: number): string { let an = ""; const ai = record["activity_info"] as Dict; const r = (ai["resumed_activity"] as string) || ""; let ma = r.match(/[\w.]+\/([\w.]+)/); if (!ma) { const f = (ai["current_focus"] as string) || ""; ma = f.match(/[\w.]+\/([\w.]+)/); } if (ma) an = ma[1].split(".").pop()!; return this._commitCommon(record, `manual_${String(pc).padStart(4, "0")}${an ? `_${an}` : ""}`); } static mergeKey(elem: Dict): string { return JSON.stringify([ elem["class"] || "", elem["resource-id"] || "", elem["text"] || "", elem["content-desc"] || "", elem["bounds"] || "", elem["clickable"] ?? null, elem["long-clickable"] || false, elem["checkable"] ?? null, elem["checked"] ?? null, elem["selected"] ?? null, elem["enabled"] ?? null, elem["scrollable"] ?? null, ]); } static appendUniqueElements(dst: Dict[], src: Dict[]): number { const seen = new Set(dst.map(e => PageCrawler.mergeKey(e))); let added = 0; for (const e of src) { const k = PageCrawler.mergeKey(e); if (seen.has(k)) continue; seen.add(k); dst.push(e); added++; } return added; } static nearDuplicateStats(leftInfo: Dict, leftXml: string, rightInfo: Dict, rightXml: string): NearDuplicateStats | null { const [leftAct, leftFeatures] = PageCrawler.pageIdentityFeatureMap(leftInfo, leftXml); const [rightAct, rightFeatures] = PageCrawler.pageIdentityFeatureMap(rightInfo, rightXml); if (leftAct !== rightAct) return null; const leftSet = new Set(leftFeatures.keys()); const rightSet = new Set(rightFeatures.keys()); const union = new Set([...leftSet, ...rightSet]); if (union.size === 0) return null; const strongDiff: string[] = []; const weakDiff: string[] = []; for (const f of union) { if (leftSet.has(f) && rightSet.has(f)) continue; const isWeak = leftFeatures.get(f) ?? rightFeatures.get(f) ?? false; if (isWeak) weakDiff.push(f); else strongDiff.push(f); } let inter = 0; for (const f of leftSet) if (rightSet.has(f)) inter++; return { jaccard: inter / union.size, strongDiff: strongDiff.sort(), weakDiff: weakDiff.sort() }; } static isNearDuplicate(stats: NearDuplicateStats | null): boolean { if (!stats) return false; const strict = stats.jaccard >= NEAR_DUP_MIN_JACCARD && stats.strongDiff.length <= NEAR_DUP_MAX_STRONG_DIFFS && stats.weakDiff.length <= NEAR_DUP_MAX_WEAK_DIFFS; const weakOnly = stats.jaccard >= NEAR_DUP_WEAK_ONLY_MIN_JACCARD && stats.strongDiff.length === 0 && stats.weakDiff.length > 0 && stats.weakDiff.length <= NEAR_DUP_MAX_WEAK_DIFFS; return strict || weakOnly; } findNearDuplicatePage(record: Dict, xmlContent: string): [Dict, NearDuplicateStats] | null { let best: [Dict, NearDuplicateStats] | null = null; for (const page of this.pagesIndex) { const pageId = page["page_id"] as string; if (!pageId) continue; const viewXmlPath = path.join(this.outputRoot, pageId, "view.xml"); if (!fs.existsSync(viewXmlPath)) continue; const existingXml = fs.readFileSync(viewXmlPath, "utf-8"); const stats = PageCrawler.nearDuplicateStats(page["activity_info"] as Dict, existingXml, record["activity_info"] as Dict, xmlContent); if (!PageCrawler.isNearDuplicate(stats)) continue; if (!best || stats!.jaccard > best[1].jaccard) best = [page, stats!]; } return best; } mergePageVariant(canonical: Dict, variant: Dict, stats: NearDuplicateStats): void { const pageId = canonical["page_id"] as string; if (!pageId) return; const pageDir = path.join(this.outputRoot, pageId); fs.mkdirSync(pageDir, { recursive: true }); const variants = (canonical["_merged_variants"] as Dict[] | undefined) || []; canonical["_merged_variants"] = variants; let variantNo = variants.length + 1; let variantId = `variant_${String(variantNo).padStart(3, "0")}`; let variantDir = path.join(pageDir, variantId); while (fs.existsSync(variantDir)) { variantNo++; variantId = `variant_${String(variantNo).padStart(3, "0")}`; variantDir = path.join(pageDir, variantId); } const tmpDir = variant["_tmp_dir"] as string; if (tmpDir && fs.existsSync(tmpDir)) fs.renameSync(tmpDir, variantDir); else fs.mkdirSync(variantDir, { recursive: true }); const canonicalClickables = (canonical["clickable_elements"] as Dict[] | undefined) || []; canonical["clickable_elements"] = canonicalClickables; const clickableAdded = PageCrawler.appendUniqueElements(canonicalClickables, (variant["clickable_elements"] as Dict[]) || []); canonical["clickable_count"] = ((canonical["clickable_elements"] as Dict[]) || []).length; const canonicalDigest = (canonical["elements_digest"] as Dict[] | undefined) || []; canonical["elements_digest"] = canonicalDigest; const digestAdded = PageCrawler.appendUniqueElements(canonicalDigest, (variant["elements_digest"] as Dict[]) || []); canonical["elements_digest_count"] = ((canonical["elements_digest"] as Dict[]) || []).length; const sigs = new Set((canonical["_merged_identity_signatures"] as string[] | undefined) || []); if (variant["page_identity_signature"]) sigs.add(variant["page_identity_signature"] as string); canonical["_merged_identity_signatures"] = [...sigs].sort(); const tmpScreenshots = (variant["_screenshots"] as string[]) || []; const tmpViewXmls = (variant["_view_xmls"] as string[]) || []; variants.push({ variant_id: variantId, label: variant["label"], came_from: variant["came_from"], trigger_element: variant["trigger_element"], click_path: variant["click_path"], page_identity_signature: variant["page_identity_signature"], page_state_signature: variant["page_state_signature"], similarity: Math.round(stats.jaccard * 10000) / 10000, strong_diff: stats.strongDiff, weak_diff: stats.weakDiff, clickable_added: clickableAdded, digest_added: digestAdded, screenshots: tmpScreenshots.map(p => `${pageId}/${variantId}/${path.basename(p)}`), view_xmls: tmpViewXmls.map(p => `${pageId}/${variantId}/${path.basename(p)}`), }); this.recordPageGaps(canonical); this.recordPageGaps({ ...variant, page_id: pageId }, variantId); fs.writeFileSync(path.join(pageDir, "meta.json"), JSON.stringify(canonical, null, 2), "utf-8"); console.log(` ♻️ 近似重复页合并到 [${pageId}] (similarity=${stats.jaccard.toFixed(2)}, +clickable=${clickableAdded})`); } generateHtmlReport(pages: Dict[]): void { let cards = ""; for (const p of pages) { const activity = escapeHtml((p["activity_info"] as Dict)?.["current_focus"] as string || "N/A"); const resumed = escapeHtml((p["activity_info"] as Dict)?.["resumed_activity"] as string || "N/A"); const cp = p["click_path"]; let pathDisplay: string; if (cp === "manual") { pathDisplay = `(手动采集:${escapeHtml((p["description"] as string) || "手动采集")})`; } else if (Array.isArray(cp) && cp.length) { const lcTag = '长按'; pathDisplay = (cp as Dict[]).map(s => `${escapeHtml(s["from"] as string)} ${s["long-clickable"] ? lcTag : "点击"} ${escapeHtml(s["element"] as string)}`).join(" → "); } else pathDisplay = "(初始页)"; const ssPath = escapeHtml(p["screenshot"] as string); const sss = (p["screenshots"] as string[]) || [ssPath]; const vxs = (p["view_xmls"] as string[]) || [p["view_xml"] as string]; let ssHtml: string; if (sss.length > 1) { let pairs = ""; for (let i = 0; i < sss.length; i++) { const xl = vxs[i] || vxs[vxs.length - 1]; const lb = i === 0 ? "初始" : `滚动${i}`; pairs += ``; } ssHtml = ``; } else ssHtml = `screenshot`; const label = escapeHtml(p["label"] as string); const pid = escapeHtml(p["page_id"] as string); let xlHtml: string; if (vxs.length > 1) xlHtml = vxs.map((vx, i) => `📄 XML${i === 0 ? "" : `_${i}`}`).join(" "); else xlHtml = `📄 查看 View XML`; cards += `
${pid}${label}${escapeHtml((p["timestamp"] as string).slice(0, 19))}
${ssHtml}
Focus: ${activity}
Activity: ${resumed}
点击路径: ${pathDisplay}
可点击元素: ${p["clickable_count"]} 个
`; } const html = `Android 页面遍历报告(加速版)

📱 Android 页面遍历报告(加速版)

生成时间:${nowStr()}  | 共采集页面:${pages.length} 个
${cards}
`; fs.writeFileSync(path.join(this.outputRoot, "report.html"), html, "utf-8"); } normalizeElemKey(elemId: unknown[]): string { return JSON.stringify([ elemId[0] || "", elemId[1] || "", elemId[2] || "", Boolean(elemId[4]), ]); } clickCacheKey(parentSig: string, elemId: unknown[]): string { return JSON.stringify([parentSig || "", this.normalizeElemKey(elemId)]); } checkClickCache(parentSig: string, elemId: unknown[]): string | null { return this.clickResultCache.get(this.clickCacheKey(parentSig, elemId)) || null; } recordClickCache(parentSig: string, elemId: unknown[], sig: string): void { this.clickResultCache.set(this.clickCacheKey(parentSig, elemId), sig); } templateRecordKey(parentSig: string, tmpl: TemplateKey): string { return JSON.stringify([parentSig || "", tmpl || ""]); } recordTemplateResult(parentSig: string, tmpl: TemplateKey, sig: string): void { if (!tmpl) return; const key = this.templateRecordKey(parentSig, tmpl); const results = this.templateResults.get(key) || []; results.push(sig); this.templateResults.set(key, results); if (results.length >= COLLAPSE_MIN && new Set(results).size === 1) this.templateCollapsed.add(key); } logDecision(clickPath: Dict[], elemId: unknown[], decision: string): void { try { const pathDesc = clickPath.length ? clickPath.map(s => s["element"] || "?").join(" → ") : "根页面"; const elem = String(elemId[1] || elemId[2] || elemId[0] || "unknown"); fs.appendFileSync(path.join(this.outputRoot, "crawl_decisions.jsonl"), JSON.stringify({ path: pathDesc, elem, decision }) + "\n", "utf-8"); } catch { /* diagnostics only */ } } writeCrawlStats(): void { try { const payload = { package: this.package, max_depth: this.maxDepth, skip_same_activity: this.skipSameActivityClicks, pages_committed: this.pageCounter, queue_remaining_at_end: this.queue.length, terminated_by: this.terminatedBy, stats: this.stats, failed_paths: [...this.failedPaths].sort(), learned_root_paths: this.learnedRootPaths.map(candidate => ({ steps: candidate.steps, successes: candidate.successes, failures: candidate.failures, })), timestamp: nowIso(), }; fs.writeFileSync(path.join(this.outputRoot, "crawl_stats.json"), JSON.stringify(payload, null, 2), "utf-8"); } catch (e) { console.log(` [WARN] 写入 crawl_stats.json 失败: ${e}`); } } flushReport(): void { try { fs.writeFileSync(path.join(this.outputRoot, "index.json"), JSON.stringify(this.pagesIndex, null, 2), "utf-8"); fs.writeFileSync(path.join(this.outputRoot, "gaps.json"), JSON.stringify(this.gaps, null, 2), "utf-8"); this.writeCrawlStats(); this.generateHtmlReport(this.pagesIndex); this.saveQueue(this.queue); } catch (e) { console.log(` [WARN] 写入报告失败: ${e}`); } } crawl(): void { if (!this.adb.checkDevice()) return; let ai = this.adb.getCurrentActivity(); if (!this.isInTargetApp(ai)) { console.log(`当前不在目标 App (${this.package}) 内,停止并启动...`); this.adb.shell("am", "force-stop", this.package); sleepMs(500); if (this.allowPmClear) { console.log(` [INFO] clear app data: ${this.package}`); this.adb.shell("pm", "clear", this.package); sleepMs(1000); } this.adb.shell("monkey", "-p", this.package, "-c", "android.intent.category.LAUNCHER", "1"); this.adb.waitForIdle(); ai = this.adb.getCurrentActivity(); if (!this.isInTargetApp(ai)) { for (let retry = 0; retry < 3; retry++) { console.log(` [INFO] waiting for app launch (retry ${retry + 1}/3)...`); this.adb.restartAdbServer(); sleepMs(2000); this.adb.shell("monkey", "-p", this.package, "-c", "android.intent.category.LAUNCHER", "1"); this.adb.waitForIdle(); ai = this.adb.getCurrentActivity(); if (this.isInTargetApp(ai)) break; } } ai = this.adb.getCurrentActivity(); if (!this.isInTargetApp(ai)) { console.log(`[ERR] 启动后仍未进入目标 App (${this.package}),请检查包名是否正确`); return; } } let launchPkg: string | null = null; let launchAct: string | null = null; const focus = (ai["current_focus"] as string) || ""; const m = focus.match(/([\w.]+)\/([\w.]+)/); if (m) { launchPkg = m[1]; launchAct = m[2]; console.log(`启动 Activity: ${launchPkg}/${launchAct}`); } else console.log(`[WARN] 无法从 current_focus 提取启动 Activity: ${focus}`); let rootKw: string | null = null; if (launchAct) { rootKw = launchAct.split(".").pop()!; console.log(`根 Activity 关键字: ${rootKw}`); } fs.mkdirSync(this.outputRoot, { recursive: true }); this.pagesIndex = []; this.visitedSignatures = new Set(); this.pathSignatures = {}; this.blockedElements = new Set(); this.clickResultCache = new Map(); this.templateResults = new Map(); this.templateCollapsed = new Set(); this.failedPaths = new Set(); this.learnedRootPaths = []; this.gaps = []; this.gapKeys = new Set(); this.rootSignature = null; this.rootStateSignature = null; this.queue = []; this.pageCounter = 0; this.terminatedBy = "normal"; this.stats = { skipped_elements: 0, probe_skipped: 0, cache_skipped: 0, same_activity_skipped: 0, merged_variants: 0, nav_replay_failed: 0, prefix_pruned: 0, destructive_skipped: 0, external_blocked: 0, dequeued_total: 0, list_collapsed_skipped: 0 }; console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}\n${"=".repeat(60)}\n开始遍历(加速版:BFS + 轻量探测)...\n${"=".repeat(60)}`); let rootId: [string, Set] | null = null; const ck = this.tryLoadCheckpoint(); if (ck) { this.loadGaps(); this.loadLearnedRootPaths(); } if (ck) { for (const p of ck[0]) { if (!PageCrawler.isRootPageRecord(p)) continue; const rp = path.join(this.outputRoot, p["page_id"] as string, "view.xml"); if (fs.existsSync(rp)) { const rootXml = fs.readFileSync(rp, "utf-8"); rootId = PageCrawler.rootIdentifiedSet(p["activity_info"] as Dict, rootXml); this.rootSignature = PageCrawler.pageSignature(p["activity_info"] as Dict, rootXml); this.rootStateSignature = PageCrawler.pageStateSignature(p["activity_info"] as Dict, rootXml); } break; } } if (ck) { [this.pagesIndex, this.visitedSignatures, this.pageCounter, this.queue, this.blockedElements] = ck; console.log(`⏩ 断点续跑:从第 ${this.pageCounter + 1} 个页面继续\n`); for (const p of this.pagesIndex) { if (PageCrawler.isRootPageRecord(p)) { const rp = path.join(this.outputRoot, p["page_id"] as string, "view.xml"); if (fs.existsSync(rp)) rootId = PageCrawler.rootIdentifiedSet(p["activity_info"] as Dict, fs.readFileSync(rp, "utf-8")); break; } } } else { try { fs.rmSync(path.join(this.outputRoot, "crawl_decisions.jsonl"), { force: true }); } catch { /* */ } const { record, xmlContent, clickable, scrollBounds } = this.capturePage("初始页面"); const pageId = this.commitPage(record); record["click_path"] = []; fs.writeFileSync(path.join(this.outputRoot, pageId, "meta.json"), JSON.stringify(record, null, 2), "utf-8"); const sig = PageCrawler.pageSignature(record["activity_info"] as Dict, xmlContent); rootId = PageCrawler.rootIdentifiedSet(record["activity_info"] as Dict, xmlContent); this.rootSignature = sig; this.rootStateSignature = PageCrawler.pageStateSignature(record["activity_info"] as Dict, xmlContent); this.visitedSignatures.add(sig); this.pathSignatures[JSON.stringify([])] = sig; this.pagesIndex.push(record); const initRepeated = PageCrawler.repeatedTemplates(clickable); for (const elem of clickable) { if (this.shouldSkipElement(elem)) { if (PageCrawler.isDestructiveElement(elem)) this.stats.destructive_skipped++; else this.stats.skipped_elements++; continue; } const eid: unknown[] = [(elem["resource-id"] as string) || "", (elem["text"] as string) || "", (elem["content-desc"] as string) || "", (elem["bounds"] as string) || "", (elem["long-clickable"] as boolean) || false]; const si = (elem["scroll_index"] as number) || 0; let sInfo: Dict | null = null; if (si > 0 && scrollBounds) sInfo = { scroll_index: si, container_bounds: Array.from(scrollBounds) }; const tmpl = initRepeated.has(PageCrawler.templateKey(elem)) ? PageCrawler.templateKey(elem) : null; this.queue.push([[], eid, [], sInfo, tmpl]); } this.flushReport(); } const crawlDeadline = Date.now() + CRAWL_DEADLINE_SECONDS * 1000; while (this.queue.length > 0 && this.pageCounter < this.maxPages) { if (Date.now() > crawlDeadline) { this.terminatedBy = "deadline"; break; } const [clickPath, elemId, stepSigs, scrollInfo, tmpl] = this.queue.shift()!; this.stats.dequeued_total++; console.log(`\n📊 BFS 队列剩余: ${this.queue.length} 项, 已采集: ${this.pageCounter} 页`); const eidKey = JSON.stringify(elemId); if (this.blockedElements.has(eidKey)) continue; if (PageCrawler.isDestructiveElement({ "resource-id": String(elemId[0] || ""), "text": String(elemId[1] || ""), "content-desc": String(elemId[2] || ""), "bounds": String(elemId[3] || "") }, true)) { this.recordGap({ type: "destructive_action_skipped", page_id: null, label: String(elemId[1] || elemId[2] || elemId[0] || "unknown"), element: { resource_id: elemId[0] || "", text: elemId[1] || "", content_desc: elemId[2] || "", bounds: elemId[3] || "" }, click_path: clickPath, reason: "queued control may modify, delete, clear, reset, or remove user data; crawler skipped it before navigation", suggested_resolution: "only enable through an explicit test fixture or manual confirmation", }); this.stats.destructive_skipped++; this.logDecision(clickPath, elemId, "destructive_skipped"); continue; } if (clickPath.length >= this.maxDepth) continue; if (this.shouldSkipSameActivity(stepSigs, { "resource-id": String(elemId[0] || "") })) { this.stats.same_activity_skipped++; this.logDecision(clickPath, elemId, "same_activity_skipped"); continue; } const pathKey = clickPath.length ? JSON.stringify(clickPath) : ""; if (pathKey && [...this.failedPaths].some(fp => pathKey.startsWith(fp) && fp.length > 2)) { this.stats.prefix_pruned++; this.logDecision(clickPath, elemId, "prefix_pruned"); continue; } const parentSig = stepSigs.length ? stepSigs[stepSigs.length - 1] : ""; if (tmpl && this.templateCollapsed.has(this.templateRecordKey(parentSig, tmpl))) { this.stats.list_collapsed_skipped++; this.logDecision(clickPath, elemId, "list_collapsed_skipped"); continue; } const useCache = Boolean(parentSig) || clickPath.length === 0; if (useCache) { const cached = this.checkClickCache(parentSig, elemId); if (cached && (cached === "__other_app__" || this.visitedSignatures.has(cached))) { this.stats.cache_skipped++; this.logDecision(clickPath, elemId, "cache_skipped"); continue; } } const pathDesc = clickPath.length ? clickPath.map(s => s["element"] || "?").join(" → ") : "根页面"; console.log(`\n🧭 导航到: ${pathDesc}`); if (!launchPkg || !launchAct) { console.log(` [ERR] 无法重启 App,缺少启动信息`); continue; } if (clickPath.length) { if (!this.navigateViaRestart(clickPath, launchPkg, launchAct, rootKw, stepSigs, rootId)) { console.log(` [ERR] 导航失败,跳过`); this.failedPaths.add(pathKey); this.stats.nav_replay_failed++; this.logDecision(clickPath, elemId, "nav_replay_failed"); continue; } } else { if (!this.restartToRootForNavigation(launchPkg, launchAct, rootKw, rootId)) { console.log(` [ERR] 无法回到根页面,跳过`); continue; } } let freshXml = this.adb.dumpXmlQuick(); if (scrollInfo && (scrollInfo["scroll_index"] as number) > 0) { console.log(` 📜 滚动 ${scrollInfo["scroll_index"]} 次到达元素位置`); this.adb.scrollToPosition(scrollInfo["container_bounds"] as [number, number, number, number], scrollInfo["scroll_index"] as number); freshXml = this.adb.dumpXmlQuick(); } let foundElem = PageCrawler.findElementOnScreen(freshXml, elemId); if (!foundElem || !foundElem["center"]) { console.log(` [SKIP] 无法找到元素: ${JSON.stringify(elemId.slice(0, 3))}`); this.recordGap({ type: "target_not_found_after_replay", page_id: null, label: String(elemId[1] || elemId[2] || elemId[0] || "unknown"), element: { resource_id: elemId[0] || "", text: elemId[1] || "", content_desc: elemId[2] || "", bounds: elemId[3] || "" }, click_path: clickPath, reason: "target element was collected earlier but was not present after replaying the navigation path; likely weak locator or UI state drift", suggested_resolution: "prefer stable resource-id/content-desc, or inspect the replay path and scroll state for this page", }); this.logDecision(clickPath, elemId, "target_not_found_after_replay"); continue; } const expectedBounds = String(elemId[3] || ""); if (expectedBounds && PageCrawler.looksClippedAgainstExpected(foundElem, expectedBounds)) { console.log(` [WARN] matched element appears clipped/shifted: expected=${expectedBounds}, actual=${foundElem["bounds"]}; trying to restore scroll`); const expectedCenter = PageCrawler.parseBoundsCenter(expectedBounds); const containers = PageCrawler.findScrollableContainers(freshXml); const restoreContainer = containers.find(c => { const b = c["bounds"] as [number, number, number, number]; return expectedCenter ? b[0] <= expectedCenter[0] && expectedCenter[0] <= b[2] : true; }) || containers[0]; if (restoreContainer) { const [x1, y1, x2, y2] = restoreContainer["bounds"] as [number, number, number, number]; const cx = Math.floor((x1 + x2) / 2); const h = y2 - y1; this.adb.shell("input", "swipe", String(cx), String(y1 + Math.floor(h * 0.30)), String(cx), String(y1 + Math.floor(h * 0.70)), String(this.adb.scrollDurationMs)); this.adb.waitForIdle(); freshXml = this.adb.dumpXmlQuick(); foundElem = PageCrawler.findElementOnScreen(freshXml, elemId); } if (!foundElem || !foundElem["center"] || PageCrawler.looksClippedAgainstExpected(foundElem, expectedBounds)) { console.log(` [SKIP] element still clipped after restore: ${JSON.stringify(elemId.slice(0, 4))}`); this.logDecision(clickPath, elemId, "clipped_target_skipped"); continue; } } const originalCenter = foundElem["center"] as [number, number]; const center = PageCrawler.chooseTapPoint(freshXml, foundElem) || originalCenter; const labelText = (foundElem["text"] as string) || (foundElem["content-desc"] as string) || (foundElem["resource-id"] as string) || "unknown"; if (PageCrawler.isDestructiveElement(foundElem)) { this.recordGap({ type: "destructive_action_skipped", page_id: null, label: labelText, element: PageCrawler.elementSummary(foundElem), click_path: clickPath, reason: "control may modify, delete, clear, reset, or remove user data; crawler skipped it before tapping", suggested_resolution: "only enable through an explicit test fixture or manual confirmation", }); this.stats.destructive_skipped++; this.logDecision(clickPath, elemId, "destructive_skipped"); continue; } if (center[0] !== originalCenter[0] || center[1] !== originalCenter[1]) console.log(` [INFO] 调整点击点以避开遮挡: ${JSON.stringify(originalCenter)} -> ${JSON.stringify(center)}`); console.log(`\n👆 点击: [${labelText}] @ ${JSON.stringify(center)}`); const isLong = (foundElem["long-clickable"] as boolean) || false; const attemptedStep = { from: pathDesc, element: labelText, center: Array.from(center), "resource-id": (foundElem["resource-id"] as string) || "", text: (foundElem["text"] as string) || "", "content-desc": (foundElem["content-desc"] as string) || "", bounds: (foundElem["bounds"] as string) || "", "long-clickable": isLong, scroll_info: scrollInfo }; const newCp = [...clickPath, attemptedStep]; if (isLong) console.log(` 🔁 使用长按触发`); this.adb.tapOrLongPress(center[0], center[1], isLong); const probeXml = this.adb.dumpXmlQuick(); const probeInfo = this.adb.getCurrentActivity(); const newSig = PageCrawler.pageSignature(probeInfo, probeXml); const newStateSig = PageCrawler.pageStateSignature(probeInfo, probeXml); if (useCache) this.recordClickCache(parentSig, elemId, newSig); if (!this.isInTargetApp(probeInfo)) { console.log(` ⚠️ 跳转到其他 App,标记并跳过`); this.recordGap({ type: "external_app_blocked", page_id: null, label: labelText, element: PageCrawler.elementSummary(foundElem), click_path: newCp, reason: "click left the target app; crawler blocked further traversal", target_activity: probeInfo, suggested_resolution: "handle manually or add a scoped system/external-app flow if this surface is in scope", }); if (useCache) this.recordClickCache(parentSig, elemId, "__other_app__"); this.blockedElements.add(eidKey); this.stats.external_blocked++; this.logDecision(clickPath, elemId, "external_blocked"); continue; } if (this.visitedSignatures.has(newSig)) { console.log(` ♻️ 页面已访问过,跳过(省截图)`); const returnedToRoot = this.isKnownRootSignature(newSig) || Boolean(this.rootStateSignature && newStateSig === this.rootStateSignature); if (returnedToRoot && newCp.length) this.recordLearnedRootPath(newCp); this.recordTemplateResult(parentSig, tmpl, newSig); this.stats.probe_skipped++; this.logDecision(clickPath, elemId, returnedToRoot ? "probe_skipped_visited_root" : "probe_skipped_visited"); continue; } const { record: newRec, xmlContent: newXml, clickable: newClk, scrollBounds: newSb } = this.capturePage(`点击'${labelText}'后`); const newSig2 = PageCrawler.pageSignature(newRec["activity_info"] as Dict, newXml); newRec["came_from"] = pathDesc; newRec["trigger_element"] = labelText; newRec["click_path"] = newCp; const nearDup = this.findNearDuplicatePage(newRec, newXml); if (nearDup) { const [canonicalPage, mergeStats] = nearDup; this.visitedSignatures.add(newSig2); if (useCache) this.recordClickCache(parentSig, elemId, newSig2); this.mergePageVariant(canonicalPage, newRec, mergeStats); this.recordTemplateResult(parentSig, tmpl, (canonicalPage["page_identity_signature"] as string) || newSig2); this.stats.merged_variants++; this.logDecision(clickPath, elemId, `merged_variant->${canonicalPage["page_id"] || "?"}`); this.flushReport(); continue; } this.visitedSignatures.add(newSig2); if (useCache) this.recordClickCache(parentSig, elemId, newSig2); const pageId = this.commitPage(newRec); fs.writeFileSync(path.join(this.outputRoot, pageId, "meta.json"), JSON.stringify(newRec, null, 2), "utf-8"); this.pagesIndex.push(newRec); this.recordTemplateResult(parentSig, tmpl, newSig2); this.logDecision(clickPath, elemId, `navigated->${pageId}`); this.flushReport(); this.pathSignatures[JSON.stringify(newCp)] = newSig2; const newStepSigs = [...stepSigs, newSig2]; const newRepeated = PageCrawler.repeatedTemplates(newClk); for (const ne of newClk) { if (this.shouldSkipElement(ne)) { if (PageCrawler.isDestructiveElement(ne)) this.stats.destructive_skipped++; else this.stats.skipped_elements++; continue; } const neid: unknown[] = [(ne["resource-id"] as string) || "", (ne["text"] as string) || "", (ne["content-desc"] as string) || "", (ne["bounds"] as string) || "", (ne["long-clickable"] as boolean) || false]; const nsi = (ne["scroll_index"] as number) || 0; let nsi2: Dict | null = null; if (nsi > 0 && newSb) nsi2 = { scroll_index: nsi, container_bounds: Array.from(newSb) }; const neTmpl = newRepeated.has(PageCrawler.templateKey(ne)) ? PageCrawler.templateKey(ne) : null; this.queue.push([newCp, neid, newStepSigs, nsi2, neTmpl]); } } if (this.terminatedBy === "normal" && this.pageCounter >= this.maxPages) this.terminatedBy = "max_pages"; this.flushReport(); try { fs.rmSync(path.join(this.outputRoot, "queue_checkpoint.json"), { force: true }); } catch { /* */ } console.log(`\n${"=".repeat(60)}\n✅ 遍历完成!共采集 ${this.pagesIndex.length} 个页面\n📂 输出目录: ${path.resolve(this.outputRoot)}\n📋 索引文件: ${path.join(this.outputRoot, "index.json")}\n🌐 HTML报告: ${path.join(this.outputRoot, "report.html")}\n\n📊 加速统计:\n 过滤低价值元素: ${this.stats.skipped_elements} 个\n 轻量探测跳过(省截图): ${this.stats.probe_skipped} 次\n 缓存预过滤跳过: ${this.stats.cache_skipped} 次\n 同 Activity 跳过: ${this.stats.same_activity_skipped} 次\n 列表塌缩跳过: ${this.stats.list_collapsed_skipped} 次\n 近似重复合并: ${this.stats.merged_variants} 次\n 重放失败: ${this.stats.nav_replay_failed} 次\n 前缀剪枝跳过: ${this.stats.prefix_pruned} 次\n 危险元素跳过: ${this.stats.destructive_skipped} 次\n 外部页拦截: ${this.stats.external_blocked} 次\n 出队总数: ${this.stats.dequeued_total} 项\n 收尾原因: ${this.terminatedBy}\n${"=".repeat(60)}`); } async manualCrawl(): Promise { if (!this.adb.checkDevice()) return; fs.mkdirSync(this.outputRoot, { recursive: true }); this.loadGaps(); let pi: Dict[] = []; const idx = path.join(this.outputRoot, "index.json"); if (fs.existsSync(idx)) { try { pi = JSON.parse(fs.readFileSync(idx, "utf-8")) as Dict[]; console.log(`📂 已加载 ${pi.length} 个已有页面`); } catch (e) { console.log(`[WARN] 读取 index.json 失败: ${e},将创建新索引`); } } let mc = 0; for (const p of pi) { const m = ((p["page_id"] as string) || "").match(/manual_(\d+)/); if (m) mc = Math.max(mc, parseInt(m[1], 10)); } console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}\n📝 手动采集模式已启动(manual 编号从 ${mc + 1} 开始)\n${"=".repeat(60)}\n操作说明:\n 1. 在设备上手动导航到目标页面\n 2. 输入页面描述(如"设置页 > 关于"),然后按回车开始采集\n 3. 输入 q 退出\n${"=".repeat(60)}`); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (q: string) => new Promise(r => rl.question(q, r)); while (true) { const desc = ((await ask("\n📝 输入页面描述(q 退出): ")).trim()); if (desc.toLowerCase() === "q") { console.log("👋 退出手动采集模式"); break; } const d = desc || "手动采集"; mc++; const { record } = this.capturePage(d); record["click_path"] = "manual"; record["description"] = d; this.commitManualPage(record, mc); pi.push(record); fs.writeFileSync(idx, JSON.stringify(pi, null, 2), "utf-8"); fs.writeFileSync(path.join(this.outputRoot, "gaps.json"), JSON.stringify(this.gaps, null, 2), "utf-8"); this.generateHtmlReport(pi); console.log(` 📋 index.json 和 report.html 已更新(共 ${pi.length} 个页面)`); } rl.close(); console.log(`\n✅ 手动采集完成!共 ${pi.length} 个页面(含自动遍历)\n📂 输出目录: ${path.resolve(this.outputRoot)}`); } } function parseArgs(argv: string[]): { package: string; output: string; manual: boolean; device: string; adbPath: string; maxPages: number; maxScrolls: number; maxDepth: number; skipSameActivity: boolean; allowPmClear: boolean; } { const o = { package: "", output: "", manual: false, device: DEVICE_SERIAL_DEFAULT, adbPath: ADB_PATH_DEFAULT, maxPages: MAX_PAGES_DEFAULT, maxScrolls: MAX_SCROLLS_DEFAULT, maxDepth: MAX_DEPTH_DEFAULT, skipSameActivity: false, allowPmClear: false }; for (let i = 0; i < argv.length; i++) { switch (argv[i]) { case "--package": case "-p": o.package = argv[++i]; break; case "--output": case "-o": o.output = argv[++i]; break; case "--manual": o.manual = true; break; case "--device": o.device = argv[++i]; break; case "--adb-path": o.adbPath = argv[++i]; break; case "--max-pages": o.maxPages = parseInt(argv[++i], 10); break; case "--max-scrolls": o.maxScrolls = parseInt(argv[++i], 10); break; case "--max-depth": o.maxDepth = parseInt(argv[++i], 10); break; case "--skip-same-activity": o.skipSameActivity = true; break; case "--allow-pm-clear": o.allowPmClear = true; break; } } if (!o.package || !o.output) { console.error("android_viewtree_bfs_crawler.ts: error: --package and --output are required"); process.exit(2); } return o; } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); const adb = new ADBHelper({ adbPath: args.adbPath, deviceSerial: args.device }); const crawler = new PageCrawler({ adb, package: args.package, outputDir: args.output, maxPages: args.maxPages, maxScrolls: args.maxScrolls, maxDepth: args.maxDepth, skipSameActivityClicks: args.skipSameActivity, allowPmClear: args.allowPmClear }); if (args.manual) await crawler.manualCrawl(); else crawler.crawl(); } main();