#!/usr/bin/env node /** * Android 模拟器页面遍历工具(加速版, TypeScript 版) * 功能:BFS 遍历每个页面,保存截图、View XML、Activity/Window 信息,生成 index.json + report.html。 * * 零第三方依赖,仅使用 Node 内置模块,可独立分发。 * 等价于原 android_parse_fast.py(functional equivalence)。 * * 用法: * node android_parse_fast.ts --package --output [--device serial] [--adb-path adb] * [--max-pages 500] [--max-scrolls 10] [--manual] [--page "<描述>"] */ import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as readline from 'node:readline'; import { createHash } from 'node:crypto'; import { pathToFileURL } from 'node:url'; // ───────────────────────────────────────────── // 默认配置值 // ───────────────────────────────────────────── const ADB_PATH_DEFAULT = 'adb'; const DEVICE_SERIAL_DEFAULT = 'emulator-5554'; const IDLE_POLL_INTERVAL_DEFAULT = 0.2; const IDLE_TIMEOUT_DEFAULT = 5; const MAX_PAGES_DEFAULT = 500; const MAX_SCROLLS_DEFAULT = 10; const SCROLL_DURATION_MS_DEFAULT = 300; const SKIP_RESOURCE_ID_PATTERNS_DEFAULT = [ 'statusBarBackground', 'navigationBarBackground', 'action_bar_container', 'status_bar', 'navigation_bar', ]; const HORIZONTAL_SCROLL_CLASSES = new Set(['android.widget.HorizontalScrollView']); function sleep(seconds: number): void { const end = Date.now() + seconds * 1000; const ia = new Int32Array(new SharedArrayBuffer(4)); while (Date.now() < end) Atomics.wait(ia, 0, 0, Math.min(50, end - Date.now())); } // ───────────────────────────────────────────── // 最小 XML 解析器(等价 xml.etree 的 root.iter("node") + get 属性) // ───────────────────────────────────────────── interface XmlNode { tag: string; attrs: Record; children: XmlNode[]; } const XML_ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }; function decodeEntities(s: string): string { return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent: string) => { if (ent[0] === '#') { const code = ent[1] === 'x' || ent[1] === 'X' ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10); return Number.isNaN(code) ? m : String.fromCodePoint(code); } return ent in XML_ENTITIES ? XML_ENTITIES[ent] : m; }); } class XmlParseError extends Error {} function parseXml(xml: string): XmlNode { let i = 0; const n = xml.length; const stack: XmlNode[] = []; let root: XmlNode | null = null; const readAttrs = (raw: string): Record => { const attrs: Record = {}; const re = /([^\s=/>]+)\s*=\s*"([^"]*)"|([^\s=/>]+)\s*=\s*'([^']*)'/g; let m: RegExpExecArray | null; while ((m = re.exec(raw)) !== null) { if (m[1] !== undefined) attrs[m[1]] = decodeEntities(m[2]); else if (m[3] !== undefined) attrs[m[3]] = decodeEntities(m[4]); } return attrs; }; while (i < n) { const lt = xml.indexOf('<', i); if (lt < 0) break; i = lt; if (xml.startsWith('', i); if (end < 0) throw new XmlParseError('unterminated declaration'); i = end + 2; continue; } if (xml.startsWith('', i); if (end < 0) throw new XmlParseError('unterminated comment'); i = end + 3; continue; } if (xml.startsWith('', i); if (end < 0) throw new XmlParseError('unterminated markup declaration'); i = end + 1; continue; } let j = i + 1; let quote: string | null = null; for (; j < n; j++) { const c = xml[j]; if (quote) { if (c === quote) quote = null; } else if (c === '"' || c === "'") quote = c; else if (c === '>') break; } if (j >= n) throw new XmlParseError('unterminated tag'); const inner = xml.slice(i + 1, j); i = j + 1; if (inner[0] === '/') { if (!stack.pop()) throw new XmlParseError('unexpected closing tag'); continue; } const selfClosing = inner.endsWith('/'); const body = selfClosing ? inner.slice(0, -1) : inner; const spaceIdx = body.search(/[\s/]/); const tag = spaceIdx < 0 ? body : body.slice(0, spaceIdx); const attrRaw = spaceIdx < 0 ? '' : body.slice(spaceIdx); const node: XmlNode = { tag, attrs: readAttrs(attrRaw), children: [] }; if (stack.length > 0) stack[stack.length - 1].children.push(node); else if (root === null) root = node; if (!selfClosing) stack.push(node); } if (root === null) throw new XmlParseError('no root element'); return root; } function* iterNodes(root: XmlNode, tag: string): Generator { if (root.tag === tag) yield root; for (const child of root.children) yield* iterNodes(child, tag); } function attr(node: XmlNode, key: string, dflt = ''): string { return key in node.attrs ? node.attrs[key] : dflt; } function extractInts(s: string): number[] { const m = s.match(/\d+/g); return m ? m.map((x) => parseInt(x, 10)) : []; } // ───────────────────────────────────────────── // 元素/签名 纯函数(等价 PageCrawler 的 @staticmethod) // ───────────────────────────────────────────── interface ClickableElement { class: string; text: string; 'resource-id': string; 'content-desc': string; bounds: string; center: [number, number] | null; 'long-clickable': boolean; scroll_index?: number; } interface ScrollContainer { bounds: [number, number, number, number]; class: string; 'resource-id': string; } function parseBoundsCenter(bounds: string): [number, number] | null { const nums = extractInts(bounds); if (nums.length === 4) { const [x1, y1, x2, y2] = nums; return [Math.trunc((x1 + x2) / 2), Math.trunc((y1 + y2) / 2)]; } return null; } function getClickableElements(xmlContent: string): ClickableElement[] { const elements: ClickableElement[] = []; if (!xmlContent) return elements; let root: XmlNode; try { root = parseXml(xmlContent); } catch (e) { console.log(` [WARN] XML 解析失败: ${(e as Error).message}`); return elements; } const listviewChildSet = new Set(); for (const node of iterNodes(root, 'node')) { const cls = attr(node, 'class'); if (cls.includes('ListView') || cls.includes('GridView')) { for (const child of node.children) listviewChildSet.add(child); } } const extractText = (node: XmlNode): string => { const t = attr(node, 'text'); if (t) return t; for (const child of iterNodes(node, 'node')) { const ct = attr(child, 'text'); if (ct) return ct; } return ''; }; for (const node of iterNodes(root, 'node')) { if (attr(node, 'enabled') !== 'true') continue; const isClickable = attr(node, 'clickable') === 'true'; const isLongClickable = attr(node, 'long-clickable') === 'true'; const isListviewItem = listviewChildSet.has(node); const isCheckable = attr(node, 'checkable') === 'true'; if (!(isClickable || isLongClickable || isListviewItem || isCheckable)) continue; const bounds = attr(node, 'bounds'); let text = attr(node, 'text'); if (!text) text = extractText(node); const baseInfo = { class: attr(node, 'class'), text, 'resource-id': attr(node, 'resource-id'), 'content-desc': attr(node, 'content-desc'), bounds, center: parseBoundsCenter(bounds), }; if (isClickable || isListviewItem || isCheckable) elements.push({ ...baseInfo, 'long-clickable': false }); if (isLongClickable) elements.push({ ...baseInfo, 'long-clickable': true }); } return elements; } function extractNodeKeys(xmlContent: string): Set { const keys = new Set(); if (!xmlContent) return keys; let root: XmlNode; try { root = parseXml(xmlContent); } catch { return keys; } for (const node of iterNodes(root, 'node')) { keys.add( [attr(node, 'class'), attr(node, 'resource-id'), attr(node, 'text'), attr(node, 'content-desc'), attr(node, 'bounds')].join(''), ); } return keys; } function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const v of a) if (!b.has(v)) return false; return true; } function findScrollableContainers(xmlContent: string): ScrollContainer[] { const containers: ScrollContainer[] = []; if (!xmlContent) return containers; let root: XmlNode; try { root = parseXml(xmlContent); } catch { return containers; } for (const node of iterNodes(root, 'node')) { if (attr(node, 'scrollable') !== 'true') continue; const cls = attr(node, 'class'); if (HORIZONTAL_SCROLL_CLASSES.has(cls)) continue; const nums = extractInts(attr(node, 'bounds')); if (nums.length !== 4) continue; const [x1, y1, x2, y2] = nums; if (y2 - y1 < 200) continue; containers.push({ bounds: [x1, y1, x2, y2], class: cls, 'resource-id': attr(node, 'resource-id') }); } containers.sort( (a, b) => (b.bounds[2] - b.bounds[0]) * (b.bounds[3] - b.bounds[1]) - (a.bounds[2] - a.bounds[0]) * (a.bounds[3] - a.bounds[1]), ); return containers; } function normalizeComponent(component: string): string { if (!component.includes('/')) return component; const idx = component.indexOf('/'); const pkg = component.slice(0, idx); let cls = component.slice(idx + 1); if (cls.startsWith('.')) cls = pkg + cls; return `${pkg}/${cls}`; } function stableActivityKey(activityInfo: Record): string { const focus = activityInfo['current_focus'] ?? ''; const resumed = activityInfo['resumed_activity'] ?? ''; const m = focus.match(/[\w.]+\/[\w.]+/); if (m) return normalizeComponent(m[0]); const actM = resumed.match(/[\w.]+\/[\w.]+/); const actPart = actM ? normalizeComponent(actM[0]) : 'unknown'; const winTypeM = focus.match(/(PopupWindow|Dialog|DecorView|Toast)/); const winType = winTypeM ? winTypeM[1] : 'overlay'; return `${actPart}|${winType}`; } function pageSignature(activityInfo: Record, xmlContent: string): string { const actKey = stableActivityKey(activityInfo); if (!xmlContent) return `${actKey}|empty`; const clickable = getClickableElements(xmlContent); if (clickable.length === 0) return `${actKey}|no_clickable`; const elemFeatures = clickable.map((e) => [e.class ?? '', e['resource-id'] ?? '', e['content-desc'] ?? '', e.bounds ?? ''].join('|'), ); const featuresStr = elemFeatures.join(';'); const featureHash = createHash('md5').update(featuresStr, 'utf-8').digest('hex').slice(0, 16); return `${actKey}|${featureHash}`; } type RootIdentity = [string, Set]; function rootIdentifiedSet(activityInfo: Record, xmlContent: string): RootIdentity { const resumed = activityInfo['resumed_activity'] ?? ''; const m = resumed.match(/[\w.]+\/[\w.]+/); const actKey = m ? normalizeComponent(m[0]) : 'unknown'; if (!xmlContent) return [actKey, new Set()]; const clickable = getClickableElements(xmlContent); const elems = new Set(); for (const e of clickable) { const desc = e['content-desc'] ?? ''; const rid = e['resource-id'] ?? ''; if (!desc && !rid) continue; elems.add([e.class ?? '', rid, desc].join('')); } return [actKey, elems]; } function isSameRootPage(rootInfo: RootIdentity, currentInfo: RootIdentity, threshold = 0.6): boolean { const [rootAct, rootElems] = rootInfo; const [curAct, curElems] = currentInfo; if (rootAct !== curAct) return false; if (rootElems.size === 0 && curElems.size === 0) return true; if (rootElems.size === 0 || curElems.size === 0) return false; let inter = 0; for (const v of rootElems) if (curElems.has(v)) inter++; const union = rootElems.size + curElems.size - inter; return union > 0 && inter / union >= threshold; } /** current_focus 是否含 `package/activity` 分量(弹窗/overlay 焦点没有)。 */ function focusHasActivityComponent(activityInfo: Record): boolean { return /[\w.]+\/[\w.]+/.test(activityInfo['current_focus'] ?? ''); } /** * 从 activity 信息提取启动组件 [package, activity]。 * 优先 current_focus(通常是全限定 Activity),回退 resumed_activity(可能是相对名 `.ui.Main`, * am start 同样接受)。都取不到返回 null。 */ function extractLaunchComponent(activityInfo: Record): [string, string] | null { for (const key of ['current_focus', 'resumed_activity']) { const s = activityInfo[key] ?? ''; const m = s.match(/([\w.]+)\/([\w.]+)/); if (m) return [m[1], m[2]]; } return null; } type ElemId = [string, string, string, string, boolean]; function findElementOnScreen(xmlContent: string, elemId: ElemId | [string, string, string, string]): ClickableElement | null { const rid = elemId[0]; const text = elemId[1]; const desc = elemId[2]; const bounds = elemId[3]; const isLong = elemId.length >= 5 ? (elemId[4] as boolean) : null; const clickableElements = getClickableElements(xmlContent); if (bounds) { for (const e of clickableElements) { if ( e['resource-id'] === rid && e.text === text && e['content-desc'] === desc && e.bounds === bounds && (isLong === null || e['long-clickable'] === isLong) ) { return e; } } } for (const e of clickableElements) { if ( e['resource-id'] === rid && e.text === text && e['content-desc'] === desc && (isLong === null || e['long-clickable'] === isLong) ) { return e; } } return null; } // ───────────────────────────────────────────── // ADBHelper:封装所有 ADB 设备通信与交互操作 // ───────────────────────────────────────────── class ADBHelper { adbPath: string; deviceSerial: string | null; idlePollInterval: number; idleTimeout: number; scrollDurationMs: number; /** 目标 App 包名;多屏设备(车机等)上用于从多条 focus/resumed 行中挑出正确的那条。 */ targetPackage: string | null; /** screencap -d 用的物理 display id 缓存;undefined=未解析,null=解析失败(退回无 -d)。 */ private cachedPhysicalDisplayId: string | null | undefined = undefined; constructor(opts: { adbPath?: string; deviceSerial?: string | null; idlePollInterval?: number; idleTimeout?: number; scrollDurationMs?: number; targetPackage?: string | null; } = {}) { this.adbPath = opts.adbPath ?? ADB_PATH_DEFAULT; this.deviceSerial = opts.deviceSerial ?? DEVICE_SERIAL_DEFAULT; this.targetPackage = opts.targetPackage ?? null; this.idlePollInterval = opts.idlePollInterval ?? IDLE_POLL_INTERVAL_DEFAULT; this.idleTimeout = opts.idleTimeout ?? IDLE_TIMEOUT_DEFAULT; this.scrollDurationMs = opts.scrollDurationMs ?? SCROLL_DURATION_MS_DEFAULT; } private buildArgs(args: string[]): string[] { const full: string[] = []; if (this.deviceSerial) full.push('-s', this.deviceSerial); full.push(...args); return full; } private spawn(args: string[], timeout: number, binary: boolean): SpawnSyncReturns { return spawnSync(this.adbPath, this.buildArgs(args), { timeout: timeout * 1000, maxBuffer: 128 * 1024 * 1024, windowsHide: true, }) as SpawnSyncReturns; } run(args: string[], timeout = 15): string { const res = this.spawn(args, timeout, false); if (res.error) { const code = (res.error as NodeJS.ErrnoException).code; if (code === 'ETIMEDOUT') { console.log(` [WARN] adb 命令超时: ${args.join(' ')}`); return ''; } if (code === 'ENOENT') { throw new Error('找不到 adb,请确认已安装并配置 PATH,或修改 ADB_PATH'); } } const out = res.stdout ?? Buffer.alloc(0); return out.toString('utf-8').trim(); } shell(args: string[], timeout = 15): string { return this.run(['shell', ...args], timeout); } waitForIdle(): void { const deadline = Date.now() + this.idleTimeout * 1000; let prevFocus: string | null = null; let interval = this.idlePollInterval; while (Date.now() < deadline) { sleep(interval); interval = Math.min(interval * 1.5, 0.5); const transitionOut = this.shell(['dumpsys', 'window', 'animator']); if (transitionOut.includes('Transition ready') || transitionOut.includes('Running animations')) continue; const focusOut = this.shell(['dumpsys', 'window']); // 多屏设备上每个 display 各有一条 mCurrentFocus,全部拼起来做稳定性比较, // 避免只看副屏那条(常为 null,永远"稳定")而误判 UI 已空闲。 const currentFocus = focusOut .split(/\r?\n/) .filter((line) => line.includes('mCurrentFocus')) .map((line) => line.trim()) .join(' | '); if (currentFocus && currentFocus === prevFocus) return; prevFocus = currentFocus; } console.log(` [WARN] 等待 UI 空闲超时 (${this.idleTimeout}s),继续执行`); } checkDevice(): boolean { const out = this.run(['devices']); const lines = out.split(/\r?\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((line) => line.split(/\s+/)[0]); console.log(` 在线设备: [${onlineDevices.map((d) => `'${d}'`).join(', ')}]`); 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; } /** * 从多条候选行中挑一条最可信的。 * 多屏设备(车机 / 折叠屏 / cluster+主屏)上,`dumpsys window` 与 * `dumpsys activity activities` 会为**每个 display 各输出一条** * mCurrentFocus / topResumedActivity,其中副屏那条常常是 `null` 或系统 * launcher。因此不能取第一条就 break:优先取包含目标包名的那条, * 其次取任意带 `pkg/activity` 组件名的非 null 行,最后兜底第一条。 */ private pickBestLine(lines: string[]): string | undefined { if (lines.length === 0) return undefined; if (this.targetPackage) { const hit = lines.find((l) => l.includes(this.targetPackage!)); if (hit) return hit; } const withComponent = lines.find((l) => !/=null\b/.test(l) && /[\w.]+\/[\w.]+/.test(l)); return withComponent ?? lines[0]; } getCurrentActivity(): Record { const info: Record = {}; const actOut = this.shell(['dumpsys', 'activity', 'activities']); const winOut = this.shell(['dumpsys', 'window']); const resumedLines: string[] = []; for (const line of actOut.split(/\r?\n/)) { if (/ResumedActivity|topActivity/i.test(line)) resumedLines.push(line.trim()); } const resumed = this.pickBestLine(resumedLines); if (resumed !== undefined) info['resumed_activity'] = resumed; const focusLines: string[] = []; const focusedAppLines: string[] = []; for (const line of winOut.split(/\r?\n/)) { if (line.includes('mCurrentFocus')) focusLines.push(line.trim()); else if (line.includes('mFocusedApp')) focusedAppLines.push(line.trim()); } const focus = this.pickBestLine(focusLines); if (focus !== undefined) info['current_focus'] = focus; const focusedApp = this.pickBestLine(focusedAppLines); if (focusedApp !== undefined) info['focused_app'] = focusedApp; return info; } tap(x: number, y: number): void { this.shell(['input', 'tap', String(x), String(y)]); this.waitForIdle(); } longPress(x: number, y: number, durationMs = 1000): void { this.shell(['input', 'swipe', String(x), String(y), String(x), String(y), String(durationMs)]); this.waitForIdle(); } tapOrLongPress(x: number, y: number, isLongClick = false): void { if (isLongClick) this.longPress(x, y); else this.tap(x, y); } pressBack(): void { this.shell(['input', 'keyevent', 'KEYCODE_BACK']); this.waitForIdle(); } dumpXmlQuick(): string { const remotePath = '/sdcard/ui_dump.xml'; this.shell(['rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml'], 20); for (let k = 0; k < 6; k++) { const check = this.shell(['ls', remotePath]); if (check.includes(remotePath)) break; sleep(0.2); } return this.shell(['cat', remotePath], 15); } getViewXmlFast(pageDir: string): string { const xmlContent = this.dumpXmlQuick(); if (xmlContent) fs.writeFileSync(path.join(pageDir, 'view.xml'), xmlContent, 'utf-8'); return xmlContent; } /** * 解析目标 App 所在屏幕的 SurfaceFlinger **物理** display id(screencap -d 只认物理 id, * 逻辑 id 如 0/2/3 会直接 "Failed to take screenshot. Status: -2")。 * * 多屏设备(车机 cluster + 主屏、折叠屏等)上不传 -d 时,screencap 会先往 stdout * 打一段 "[Warning] Multiple displays were found..." 文本,把 PNG 二进制整体前移 * 导致图片打不开;且它"默认取第一个 display",不保证就是 App 所在那块屏。 * * 流程:dumpsys activity activities 找到目标包所在的 `Display #N` * → dumpsys display 里用 `displayId N,` 所在行的 uniqueId "local:" 取物理 id。 * 单屏设备解析失败时返回 null,退回不带 -d 的老行为(此时也不会有警告)。 */ private resolveTargetPhysicalDisplayId(): string | null { if (this.cachedPhysicalDisplayId !== undefined) return this.cachedPhysicalDisplayId; let result: string | null = null; try { if (this.targetPackage) { const actOut = this.shell(['dumpsys', 'activity', 'activities']); let curDisplay: string | null = null; let logicalId: string | null = null; for (const line of actOut.split(/\r?\n/)) { const m = line.match(/^Display #(\d+)/); if (m) curDisplay = m[1]; else if (curDisplay && line.includes(this.targetPackage)) { logicalId = curDisplay; break; } } if (logicalId !== null) { const dispOut = this.shell(['dumpsys', 'display']); // 逐行扫描:DisplayInfo 是单行长串,`displayId ,` 与 uniqueId 同行。 for (const line of dispOut.split(/\r?\n/)) { if (!line.includes(`displayId ${logicalId},`)) continue; const m = line.match(/uniqueId "local:(\d+)"/); if (m) { result = m[1]; break; } } } } } catch { result = null; } if (result) console.log(` [INFO] 截图目标 display 物理 id: ${result}`); this.cachedPhysicalDisplayId = result; return result; } screencapBytes(): Buffer | null { const physId = this.resolveTargetPhysicalDisplayId(); const capArgs = physId ? ['exec-out', 'screencap', '-d', physId, '-p'] : ['exec-out', 'screencap', '-p']; const res = spawnSync(this.adbPath, this.buildArgs(capArgs), { timeout: 15 * 1000, maxBuffer: 128 * 1024 * 1024, windowsHide: true, }) as SpawnSyncReturns; if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { console.log(' [WARN] 截图超时'); return null; } const out = res.stdout ?? Buffer.alloc(0); if (out.length === 0) return null; // 兜底:无论如何都从 PNG magic 处截断,剥掉 screencap 可能前置到 stdout 的警告文本。 const magic = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const at = out.indexOf(magic); if (at < 0) { console.log(` [WARN] 截图输出不含 PNG 数据: ${out.subarray(0, 120).toString('utf-8').trim()}`); return null; } return at === 0 ? out : out.subarray(at); } takeScreenshot(pageDir: string): void { const localPath = path.join(pageDir, 'screenshot.png'); const data = this.screencapBytes(); if (data) { fs.writeFileSync(localPath, data); } else { const remotePath = '/sdcard/screenshot.png'; const physId = this.resolveTargetPhysicalDisplayId(); // 落盘到设备再 pull 时同样要指定 display,否则多屏下可能抓到副屏。 if (physId) this.shell(['screencap', '-d', physId, '-p', remotePath]); else this.shell(['screencap', '-p', remotePath]); sleep(0.3); this.run(['pull', remotePath, localPath], 15); } } scrollToPosition(containerBounds: [number, number, number, number], scrollIndex: number): void { if (scrollIndex <= 0) return; const [x1, y1, , y2] = containerBounds; const x2 = containerBounds[2]; const cx = Math.trunc((x1 + x2) / 2); const height = y2 - y1; const swipeFromY = y1 + Math.trunc(height * 0.7); const swipeToY = y1 + Math.trunc(height * 0.3); for (let k = 0; k < scrollIndex; k++) { this.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.scrollDurationMs)]); this.waitForIdle(); } } flushPendingPulls(): void { // TS 版截图为同步写入,无异步 pull 队列,保留空实现以对齐调用点。 } } // ───────────────────────────────────────────── // 队列项 / 记录类型 // ───────────────────────────────────────────── interface ScrollInfo { scroll_index: number; container_bounds: number[]; } interface ClickStep { from: string; element: string; center: number[]; 'resource-id': string; text: string; 'content-desc': string; 'long-clickable': boolean; scroll_info: ScrollInfo | null; } type QueueItem = [ClickStep[], ElemId, string[], ScrollInfo | null]; interface PageRecord { page_id: string | null; label: string; timestamp: string; activity_info: Record; clickable_count: number; clickable_elements: ClickableElement[]; scroll_container_bounds: number[] | null; _tmp_dir?: string; _screenshots?: string[]; _view_xmls?: string[]; screenshot?: string; view_xml?: string; screenshots?: string[]; view_xmls?: string[]; click_path?: ClickStep[] | 'manual'; came_from?: string; trigger_element?: string; description?: string; } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ───────────────────────────────────────────── // PageCrawler:UI 遍历逻辑、页面采集、断点续跑、报告生成 // ───────────────────────────────────────────── class PageCrawler { adb: ADBHelper; package: string; outputRoot: string; maxPages: number; maxScrolls: number; skipPatterns: string[]; // 运行时状态 pagesIndex: PageRecord[] = []; visitedSignatures = new Set(); pathSignatures: Record = {}; blockedElements = new Set(); queue: QueueItem[] = []; pageCounter = 0; stats = { skipped_elements: 0, probe_skipped: 0 }; constructor(opts: { adb: ADBHelper; package: string; outputDir: string; maxPages?: number; maxScrolls?: number; skipPatterns?: string[]; }) { 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.skipPatterns = opts.skipPatterns ?? SKIP_RESOURCE_ID_PATTERNS_DEFAULT; } static elemIdOf(elem: ClickableElement): ElemId { return [ elem['resource-id'] ?? '', elem.text ?? '', elem['content-desc'] ?? '', elem.bounds ?? '', elem['long-clickable'] ?? false, ]; } shouldSkipElement(elem: ClickableElement): boolean { const rid = elem['resource-id'] ?? ''; const text = elem.text ?? ''; const desc = elem['content-desc'] ?? ''; const cls = elem.class ?? ''; for (const pattern of this.skipPatterns) if (rid.includes(pattern)) return true; if (!text && !desc && !rid && cls.includes('ImageView')) return true; return false; } isInTargetApp(activityInfo: Record): boolean { const focus = activityInfo['current_focus'] ?? ''; const resumed = activityInfo['resumed_activity'] ?? ''; console.log(focus, resumed); return focus.includes(this.package) || resumed.includes(this.package); } scrollAndDump( pageDir: string, containerBounds: [number, number, number, number], initialXml = '', ): { xmlList: string[]; xmlPaths: string[]; screenshotPaths: string[] } { const [x1, y1, , y2b] = containerBounds; const x2 = containerBounds[2]; const y2 = y2b; const cx = Math.trunc((x1 + x2) / 2); const height = y2 - y1; const swipeFromY = y1 + Math.trunc(height * 0.7); const swipeToY = y1 + Math.trunc(height * 0.3); const xmlList: string[] = []; const xmlPaths: string[] = []; const screenshotPaths: string[] = []; let prevKeys: Set | null = initialXml ? extractNodeKeys(initialXml) : null; for (let i = 0; i < this.maxScrolls; i++) { this.adb.shell(['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(this.adb.scrollDurationMs)]); this.adb.waitForIdle(); const newXml = this.adb.dumpXmlQuick(); const newKeys = extractNodeKeys(newXml); if (prevKeys !== null && setsEqual(newKeys, prevKeys)) { console.log(` 📜 滚动 ${i + 1} 次后到达底部,跳过截图`); break; } prevKeys = newKeys; xmlList.push(newXml); const xmlPath = path.join(pageDir, `view_scroll_${i + 1}.xml`); fs.writeFileSync(xmlPath, newXml, 'utf-8'); xmlPaths.push(xmlPath); const ssPath = path.join(pageDir, `screenshot_scroll_${i + 1}.png`); const data = this.adb.screencapBytes(); if (data) { fs.writeFileSync(ssPath, data); screenshotPaths.push(ssPath); console.log(` 📸 滚动 ${i + 1}: XML + 截图已保存`); } } return { xmlList, xmlPaths, screenshotPaths }; } capturePage(label: string): { record: PageRecord; xmlContent: string; clickable: ClickableElement[]; scrollContainerBounds: [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 actInfo = this.adb.getCurrentActivity(); console.log(` Focus : ${actInfo['current_focus'] ?? 'N/A'}`); console.log(` Activity: ${actInfo['resumed_activity'] ?? 'N/A'}`); const xmlContent = this.adb.getViewXmlFast(tmpDir); console.log(` View XML 已保存 (${Buffer.byteLength(xmlContent, 'utf-8')} bytes)`); this.adb.takeScreenshot(tmpDir); console.log(' 截图已保存'); const allScreenshots = [path.join(tmpDir, 'screenshot.png')]; const allViewXmls = [path.join(tmpDir, 'view.xml')]; const allXmlContents = [xmlContent]; const scrollContainers = findScrollableContainers(xmlContent); if (scrollContainers.length > 0) { const container = scrollContainers[0]; console.log(` 📜 检测到可滚动容器: ${container.class} ${container['resource-id']}`); const { xmlList, xmlPaths, screenshotPaths } = this.scrollAndDump(tmpDir, container.bounds, xmlContent); if (xmlList.length > 0) { allXmlContents.push(...xmlList); allViewXmls.push(...xmlPaths); allScreenshots.push(...screenshotPaths); console.log(` 📜 滚动采集完成:${xmlList.length} 次滚动,共 ${allScreenshots.length} 张截图`); } } const seenClickable = new Set(); const clickable: ClickableElement[] = []; allXmlContents.forEach((xc, idx) => { for (const elem of getClickableElements(xc)) { const key = [ elem['resource-id'] ?? '', elem.text ?? '', elem['content-desc'] ?? '', elem.class ?? '', elem.bounds ?? '', String(elem['long-clickable'] ?? false), ].join(' '); if (!seenClickable.has(key)) { seenClickable.add(key); elem.scroll_index = idx; clickable.push(elem); } } }); console.log(` 可点击元素: ${clickable.length} 个`); const scrollContainerBounds = scrollContainers.length > 0 ? scrollContainers[0].bounds : null; const record: PageRecord = { page_id: null, label, timestamp: new Date().toISOString(), activity_info: actInfo, clickable_count: clickable.length, clickable_elements: clickable, scroll_container_bounds: scrollContainerBounds ? [...scrollContainerBounds] : null, _tmp_dir: tmpDir, _screenshots: allScreenshots, _view_xmls: allViewXmls, }; return { record, xmlContent, clickable, scrollContainerBounds }; } restartToRoot( launchPackage: string | null, launchActivity: string | null, rootActivityKeyword: string | null = null, rootIdentity: RootIdentity | null = null, ): boolean { if (!launchPackage || !launchActivity) { console.log(' [WARN] 包名或 Activity 名为空,无法重启'); return false; } const component = `${launchPackage}/${launchActivity}`; const isAtRoot = (): boolean => { const activityInfo = this.adb.getCurrentActivity(); const focus = activityInfo['current_focus'] ?? ''; const resumed = activityInfo['resumed_activity'] ?? ''; if (rootActivityKeyword) { if (!focus.includes(rootActivityKeyword) && !resumed.includes(rootActivityKeyword)) return false; } if (rootIdentity) { const xml = this.adb.dumpXmlQuick(); const curIdentity = rootIdentifiedSet(activityInfo, xml); if (!isSameRootPage(rootIdentity, curIdentity)) { const [, curElems] = curIdentity; const [, rootElems] = rootIdentity; let inter = 0; for (const v of rootElems) if (curElems.has(v)) inter++; const union = rootElems.size + curElems.size - inter; const sim = union ? inter / union : 0; console.log(` [DEBUG] 根页面相似度: ${sim.toFixed(2)} (交集${inter}/并集${union})`); return false; } } return true; }; this.adb.shell(['am', 'force-stop', launchPackage]); sleep(1.0); this.adb.shell(['am', 'start', '-n', component, '--activity-clear-task', '--activity-clear-top']); this.adb.waitForIdle(); for (let retry = 0; retry < 4; retry++) { if (isAtRoot()) return true; console.log(` [INFO] 等待页面加载完成... (重试 ${retry + 1}/4)`); sleep(2); } console.log(' [WARN] force-stop 后未到达根页面(App 恢复了上次的 Tab 状态)'); console.log(' [INFO] 尝试 back + 点击路径恢复...'); this.adb.pressBack(); if (isAtRoot()) return true; console.log(' [WARN] 所有恢复方式均失败,跳过此次导航'); return false; } navigateViaRestart( clickPath: ClickStep[], launchPackage: string | null, launchActivity: string | null, rootActivityKeyword: string | null = null, stepSignatures: string[] | null = null, rootIdentity: RootIdentity | null = null, ): boolean { if (!launchPackage || !launchActivity) { console.log(' [WARN] 包名或 Activity 名为空,无法导航'); return false; } if (clickPath.length === 0) return true; if (!this.restartToRoot(launchPackage, launchActivity, rootActivityKeyword, rootIdentity)) { console.log(' [ERR] 无法回到根页面,导航失败'); return false; } for (let i = 0; i < clickPath.length; i++) { const step = clickPath[i]; let xml = this.adb.dumpXmlQuick(); const stepScroll = step.scroll_info; if (stepScroll && stepScroll.scroll_index > 0) { this.adb.scrollToPosition(stepScroll.container_bounds as [number, number, number, number], stepScroll.scroll_index); xml = this.adb.dumpXmlQuick(); } const elemId: [string, string, string, string] = [step['resource-id'] ?? '', step.text ?? '', step['content-desc'] ?? '', '']; const found = findElementOnScreen(xml, elemId); const isLong = step['long-clickable'] ?? false; if (found && found.center) { this.adb.tapOrLongPress(found.center[0], found.center[1], isLong); } else if (step.center) { console.log(` [WARN] 未找到元素 ${step.element ?? '?'},使用记录坐标`); this.adb.tapOrLongPress(step.center[0], step.center[1], isLong); } else { console.log(` [ERR] 无法导航到步骤: ${step.element ?? '?'}`); return false; } if (stepSignatures && i < stepSignatures.length && stepSignatures[i]) { const verifyXml = this.adb.dumpXmlQuick(); const verifyInfo = this.adb.getCurrentActivity(); const actualSig = pageSignature(verifyInfo, verifyXml); if (actualSig !== stepSignatures[i]) { const expectedAct = stepSignatures[i].split('|')[0]; const actualAct = actualSig.split('|')[0]; if (expectedAct !== actualAct) { console.log(` [WARN] 导航步骤 ${i + 1} 后 Activity 不匹配 (期望=${expectedAct}, 实际=${actualAct})`); return false; } } } } return true; } // ── 断点续跑 ── private saveQueue(): void { const items = this.queue.map(([clickPath, elemId, stepSigs, scrollInfo]) => [clickPath, [...elemId], stepSigs, scrollInfo]); try { fs.writeFileSync(path.join(this.outputRoot, 'queue_checkpoint.json'), JSON.stringify(items), 'utf-8'); } catch (e) { console.log(` [WARN] 保存队列检查点失败: ${(e as Error).message}`); } } private loadQueue(): QueueItem[] | null { const qfile = path.join(this.outputRoot, 'queue_checkpoint.json'); if (!fs.existsSync(qfile)) return null; try { const items = JSON.parse(fs.readFileSync(qfile, 'utf-8')) as [ClickStep[], unknown[], string[], ScrollInfo | null][]; return items.map(([clickPath, elemIdList, stepSigs, scrollInfo]) => [ clickPath, elemIdList as ElemId, stepSigs, scrollInfo, ]); } catch (e) { console.log(` [WARN] 加载队列检查点失败: ${(e as Error).message},将重建队列`); return null; } } private rebuildQueueFromPages(pagesIndex: PageRecord[]): QueueItem[] { const queue: QueueItem[] = []; for (const page of pagesIndex) { const clickPath = (Array.isArray(page.click_path) ? page.click_path : []) as ClickStep[]; const clickable = page.clickable_elements ?? []; const scrollContainerBounds = page.scroll_container_bounds; for (const elem of clickable) { if (this.shouldSkipElement(elem)) continue; const elemId = PageCrawler.elemIdOf(elem); const si = elem.scroll_index ?? 0; let scrollInfo: ScrollInfo | null = null; if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] }; queue.push([clickPath, elemId, [], scrollInfo]); } } return queue; } private tryLoadCheckpoint(): | { pagesIndex: PageRecord[]; visited: Set; pageCounter: number; queue: QueueItem[]; blocked: Set } | null { const indexFile = path.join(this.outputRoot, 'index.json'); if (!fs.existsSync(indexFile)) return null; const pageDirs = fs.existsSync(this.outputRoot) ? fs.readdirSync(this.outputRoot).filter((d) => d.startsWith('page_')) : []; if (pageDirs.length === 0) return null; let pagesIndex: PageRecord[]; try { pagesIndex = JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[]; } catch (e) { console.log(` [WARN] 读取 index.json 失败: ${(e as Error).message},重新开始`); return null; } if (!pagesIndex || pagesIndex.length === 0) return null; console.log(`\n⏩ 发现已有检查点:${pagesIndex.length} 个页面,尝试断点续跑...`); const visited = new Set(); let pageCounter = 0; for (const page of pagesIndex) { const pageId = page.page_id ?? ''; if (!pageId) continue; const m = pageId.match(/page_(\d+)/); if (m) pageCounter = Math.max(pageCounter, parseInt(m[1], 10)); const viewXmlPath = path.join(this.outputRoot, pageId, 'view.xml'); if (fs.existsSync(viewXmlPath)) { const xmlContent = fs.readFileSync(viewXmlPath, 'utf-8'); visited.add(pageSignature(page.activity_info ?? {}, xmlContent)); } } const savedQueue = this.loadQueue(); let queue: QueueItem[]; if (savedQueue !== null) { queue = savedQueue; console.log(` 队列已从检查点恢复:${queue.length} 个待处理元素(精确续跑)`); } else { queue = this.rebuildQueueFromPages(pagesIndex); console.log(` 队列已重建(fallback):${queue.length} 个元素(含已探索项,BFS 会自动跳过)`); } const blocked = new Set(); console.log(` 已恢复 ${visited.size} 个已访问签名,队列 ${queue.length} 个待处理元素`); return { pagesIndex, visited, pageCounter, queue, blocked }; } // ── 页面提交 ── private finalizePage(record: PageRecord, pageId: string): void { const pageDir = path.join(this.outputRoot, pageId); const tmpDir = record._tmp_dir!; 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 screenshotsList = [`${pageId}/screenshot.png`]; for (const ssPath of record._screenshots ?? []) { const ssName = path.basename(ssPath); if (ssName !== 'screenshot.png') screenshotsList.push(`${pageId}/${ssName}`); } record.screenshots = screenshotsList; const viewXmlsList = [`${pageId}/view.xml`]; for (const xmlPath of record._view_xmls ?? []) { const xmlName = path.basename(xmlPath); if (xmlName !== 'view.xml') viewXmlsList.push(`${pageId}/${xmlName}`); } record.view_xmls = viewXmlsList; 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}]`); } private activityNameSuffix(record: PageRecord): string { const resumed = record.activity_info?.['resumed_activity'] ?? ''; let mAct = resumed.match(/[\w.]+\/([\w.]+)/); if (!mAct) { const f = record.activity_info?.['current_focus'] ?? ''; mAct = f.match(/[\w.]+\/([\w.]+)/); } let activityName = ''; if (mAct) { const parts = mAct[1].split('.'); activityName = parts[parts.length - 1]; } return activityName ? `_${activityName}` : ''; } commitPage(record: PageRecord): string { this.pageCounter += 1; const suffix = this.activityNameSuffix(record); const pageId = `page_${String(this.pageCounter).padStart(4, '0')}${suffix}`; this.finalizePage(record, pageId); return pageId; } commitManualPage(record: PageRecord, pageCounter: number): string { const suffix = this.activityNameSuffix(record); const pageId = `manual_${String(pageCounter).padStart(4, '0')}${suffix}`; this.finalizePage(record, pageId); return pageId; } // ── HTML 报告生成 ── generateHtmlReport(pages: PageRecord[]): void { let cards = ''; for (const p of pages) { const activity = escapeHtml(p.activity_info?.['current_focus'] ?? 'N/A'); const resumed = escapeHtml(p.activity_info?.['resumed_activity'] ?? 'N/A'); const clickPath = p.click_path ?? []; let pathDisplay: string; if (clickPath === 'manual') { pathDisplay = `(手动采集:${escapeHtml(p.description ?? '手动采集')})`; } else if (Array.isArray(clickPath) && clickPath.length > 0) { const longClickTag = '长按'; pathDisplay = clickPath .map( (s) => `${escapeHtml(s.from)} ${s['long-clickable'] ? longClickTag : '点击'} ${escapeHtml(s.element)}`, ) .join(' → '); } else { pathDisplay = '(初始页)'; } const screenshotPath = escapeHtml(p.screenshot ?? ''); const screenshots = p.screenshots ?? [p.screenshot ?? '']; const viewXmls = p.view_xmls ?? [p.view_xml ?? '']; let screenshotsHtml: string; if (screenshots.length > 1) { let pairsHtml = ''; screenshots.forEach((ss, idx) => { const xmlLink = idx < viewXmls.length ? viewXmls[idx] : viewXmls[viewXmls.length - 1]; const labelTag = idx === 0 ? '初始' : `滚动${idx}`; pairsHtml += `
` + `screenshot` + `${labelTag} XML` + `
`; }); screenshotsHtml = ``; } else { screenshotsHtml = `screenshot`; } const labelEscaped = escapeHtml(p.label); const pageIdEscaped = escapeHtml(p.page_id ?? ''); const viewXmlEscaped = escapeHtml(p.view_xml ?? ''); let xmlLinksHtml: string; if (viewXmls.length > 1) { xmlLinksHtml = viewXmls .map((vx, i) => `📄 XML${i === 0 ? '' : `_${i}`}`) .join(' '); } else { xmlLinksHtml = `📄 查看 View XML`; } cards += `
${pageIdEscaped} ${labelEscaped} ${escapeHtml((p.timestamp ?? '').slice(0, 19))}
${screenshotsHtml}
Focus: ${activity}
Activity: ${resumed}
点击路径: ${pathDisplay}
可点击元素: ${p.clickable_count} 个
`; } const now = new Date(); const pad = (x: number): string => String(x).padStart(2, '0'); const genTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; const html = ` Android 页面遍历报告(加速版)

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

生成时间:${genTime}  |  共采集页面:${pages.length} 个
${cards}
`; fs.writeFileSync(path.join(this.outputRoot, 'report.html'), html, 'utf-8'); } private flushReport(): void { try { fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(this.pagesIndex, null, 2), 'utf-8'); this.generateHtmlReport(this.pagesIndex); this.saveQueue(); } catch (e) { console.log(` [WARN] 写入报告失败: ${(e as Error).message}`); } } crawl(): void { if (!this.adb.checkDevice()) return; let activityInfo = this.adb.getCurrentActivity(); // 需要干净重启的两种情况: // 1) 不在目标 App 内(原逻辑)。 // 2) 在目标 App 内,但前台是弹窗/overlay —— current_focus 无 activity 分量,提取不到启动组件。 // 常见于上一轮遍历/续跑结束时停在弹窗上(resumed_activity 仍含包名,会被判定"在 App 内")。 // 此时若不重启,后续所有 force-stop 导航都会因缺少启动信息而中止。 const focusHasComponent = focusHasActivityComponent(activityInfo); const notInApp = !this.isInTargetApp(activityInfo); if (notInApp || !focusHasComponent) { if (notInApp) { console.log(`当前不在目标 App (${this.package}) 内,清空数据并启动...`); } else { console.log( `当前前台为弹窗/overlay(${activityInfo['current_focus'] ?? 'N/A'}),force-stop 后重新启动以回到干净根页面...`, ); } this.adb.shell(['am', 'force-stop', this.package]); sleep(0.5); this.adb.shell(['monkey', '-p', this.package, '-c', 'android.intent.category.LAUNCHER', '1']); this.adb.waitForIdle(); activityInfo = this.adb.getCurrentActivity(); if (!this.isInTargetApp(activityInfo)) { console.log(`[ERR] 启动后仍未进入目标 App (${this.package}),请检查包名是否正确`); return; } } let launchPackage: string | null = null; let launchActivity: string | null = null; const comp = extractLaunchComponent(activityInfo); if (comp) { [launchPackage, launchActivity] = comp; console.log(`启动 Activity: ${launchPackage}/${launchActivity}`); } else { console.log( `[WARN] 无法提取启动 Activity: focus=${activityInfo['current_focus'] ?? ''} resumed=${activityInfo['resumed_activity'] ?? ''}`, ); } let rootActivityKeyword: string | null = null; if (launchActivity) { const parts = launchActivity.split('.'); rootActivityKeyword = parts[parts.length - 1]; console.log(`根 Activity 关键字: ${rootActivityKeyword}`); } fs.mkdirSync(this.outputRoot, { recursive: true }); console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}\n`); console.log('='.repeat(60)); console.log('开始遍历(加速版:BFS + 轻量探测)...'); console.log('='.repeat(60)); let rootIdentity: RootIdentity | null = null; const checkpoint = this.tryLoadCheckpoint(); if (checkpoint) { this.pagesIndex = checkpoint.pagesIndex; this.visitedSignatures = checkpoint.visited; this.pageCounter = checkpoint.pageCounter; this.queue = checkpoint.queue; this.blockedElements = checkpoint.blocked; console.log(`⏩ 断点续跑:从第 ${this.pageCounter + 1} 个页面继续\n`); for (const p of this.pagesIndex) { if (!p.click_path || (Array.isArray(p.click_path) && p.click_path.length === 0)) { const rootXmlPath = path.join(this.outputRoot, p.page_id!, 'view.xml'); if (fs.existsSync(rootXmlPath)) { const rootXml = fs.readFileSync(rootXmlPath, 'utf-8'); rootIdentity = rootIdentifiedSet(p.activity_info ?? {}, rootXml); } break; } } } else { const { record, xmlContent, clickable, scrollContainerBounds } = 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 = pageSignature(record.activity_info, xmlContent); rootIdentity = rootIdentifiedSet(record.activity_info, xmlContent); this.visitedSignatures.add(sig); this.pathSignatures[JSON.stringify([])] = sig; this.pagesIndex.push(record); this.flushReport(); for (const elem of clickable) { if (this.shouldSkipElement(elem)) { this.stats.skipped_elements += 1; continue; } const elemId = PageCrawler.elemIdOf(elem); const si = elem.scroll_index ?? 0; let scrollInfo: ScrollInfo | null = null; if (si > 0 && scrollContainerBounds) scrollInfo = { scroll_index: si, container_bounds: [...scrollContainerBounds] }; this.queue.push([[], elemId, [], scrollInfo]); } } const failedPaths = new Set(); while (this.queue.length > 0 && this.pageCounter < this.maxPages) { const [clickPath, elemId, stepSignatures, scrollInfo] = this.queue.shift()!; const elemKey = JSON.stringify(elemId); if (this.blockedElements.has(elemKey)) continue; const pathKey = clickPath.length > 0 ? JSON.stringify(clickPath) : ''; let skip = false; for (const fp of failedPaths) { if (pathKey.startsWith(fp) && fp.length > 2) { skip = true; break; } } if (skip) continue; const pathDesc = clickPath.length > 0 ? clickPath.map((s) => s.element ?? '?').join(' → ') : '根页面'; console.log(`\n🧭 导航到: ${pathDesc}`); if (!launchPackage || !launchActivity) { console.log(' [ERR] 无法重启 App,缺少启动信息'); continue; } if (clickPath.length > 0) { if (!this.navigateViaRestart(clickPath, launchPackage, launchActivity, rootActivityKeyword, stepSignatures, rootIdentity)) { console.log(' [ERR] 导航失败,跳过'); failedPaths.add(pathKey); continue; } } else { if (!this.restartToRoot(launchPackage, launchActivity, rootActivityKeyword, rootIdentity)) { console.log(' [ERR] 无法回到根页面,跳过'); continue; } } let freshXml = this.adb.dumpXmlQuick(); if (scrollInfo && scrollInfo.scroll_index > 0) { console.log(` 📜 滚动 ${scrollInfo.scroll_index} 次到达元素位置`); this.adb.scrollToPosition(scrollInfo.container_bounds as [number, number, number, number], scrollInfo.scroll_index); freshXml = this.adb.dumpXmlQuick(); } const foundElem = findElementOnScreen(freshXml, elemId); if (!foundElem || !foundElem.center) { console.log(` [SKIP] 无法找到元素: ${JSON.stringify(elemId.slice(0, 3))}`); continue; } const center = foundElem.center; const labelText = foundElem.text || foundElem['content-desc'] || foundElem['resource-id'] || 'unknown'; console.log(`\n👆 点击: [${labelText}] @ ${JSON.stringify(center)}`); const isLong = foundElem['long-clickable'] ?? false; if (isLong) console.log(' 🔁 使用长按触发'); this.adb.tapOrLongPress(center[0], center[1], isLong); const probeXml = this.adb.dumpXmlQuick(); const probeInfo = this.adb.getCurrentActivity(); let newSig = pageSignature(probeInfo, probeXml); if (!this.isInTargetApp(probeInfo)) { console.log(' ⚠️ 跳转到其他 App,标记并跳过'); this.blockedElements.add(elemKey); continue; } if (this.visitedSignatures.has(newSig)) { console.log(' ♻️ 页面已访问过,跳过(省截图)'); this.stats.probe_skipped += 1; continue; } const { record: newRecord, xmlContent: newXml, clickable: newClickable, scrollContainerBounds: newScrollBounds } = this.capturePage(`点击'${labelText}'后`); newSig = pageSignature(newRecord.activity_info, newXml); this.visitedSignatures.add(newSig); const pageId = this.commitPage(newRecord); const newClickPath: ClickStep[] = [ ...clickPath, { from: pathDesc, element: labelText, center: [...center], 'resource-id': foundElem['resource-id'] ?? '', text: foundElem.text ?? '', 'content-desc': foundElem['content-desc'] ?? '', 'long-clickable': isLong, scroll_info: scrollInfo, }, ]; newRecord.came_from = pathDesc; newRecord.trigger_element = labelText; newRecord.click_path = newClickPath; fs.writeFileSync(path.join(this.outputRoot, pageId, 'meta.json'), JSON.stringify(newRecord, null, 2), 'utf-8'); this.pagesIndex.push(newRecord); this.flushReport(); this.pathSignatures[JSON.stringify(newClickPath)] = newSig; const newStepSigs = [...stepSignatures, newSig]; for (const ne of newClickable) { if (this.shouldSkipElement(ne)) { this.stats.skipped_elements += 1; continue; } const newEid = PageCrawler.elemIdOf(ne); const si = ne.scroll_index ?? 0; let neScrollInfo: ScrollInfo | null = null; if (si > 0 && newScrollBounds) neScrollInfo = { scroll_index: si, container_bounds: [...newScrollBounds] }; this.queue.push([newClickPath, newEid, newStepSigs, neScrollInfo]); } } this.adb.flushPendingPulls(); this.flushReport(); try { fs.rmSync(path.join(this.outputRoot, 'queue_checkpoint.json'), { force: true }); } catch { /* ignore */ } console.log('\n' + '='.repeat(60)); console.log(`✅ 遍历完成!共采集 ${this.pagesIndex.length} 个页面`); console.log(`📂 输出目录: ${path.resolve(this.outputRoot)}`); console.log(`📋 索引文件: ${path.join(this.outputRoot, 'index.json')}`); console.log(`🌐 HTML报告: ${path.join(this.outputRoot, 'report.html')}`); console.log('\n📊 加速统计:'); console.log(` 过滤低价值元素: ${this.stats.skipped_elements} 个`); console.log(` 轻量探测跳过(省截图): ${this.stats.probe_skipped} 次`); console.log('='.repeat(60)); } /** 读取已有 index.json(不存在或损坏时返回空数组)。手动/单页采集共用。 */ private loadExistingIndex(): PageRecord[] { const indexFile = path.join(this.outputRoot, 'index.json'); if (!fs.existsSync(indexFile)) return []; try { return JSON.parse(fs.readFileSync(indexFile, 'utf-8')) as PageRecord[]; } catch (e) { console.log(`[WARN] 读取 index.json 失败: ${(e as Error).message},将创建新索引`); return []; } } /** 已有页面里最大的 manual_NNNN 编号,用于续编号。 */ static lastManualCounter(pagesIndex: PageRecord[]): number { let counter = 0; for (const p of pagesIndex) { const mm = (p.page_id ?? '').match(/manual_(\d+)/); if (mm) counter = Math.max(counter, parseInt(mm[1], 10)); } return counter; } /** * 单页采集模式(--page "<描述>"):不做任何导航,直接把设备**当前**页面 * 当作待采集页抓一次(截图 + view.xml + 可滚动容器滚动采集),写入 index.json / * report.html 后退出。等价于 manualCrawl 里输入一次描述、再输入 q,但无需交互, * 便于外部脚本按自己的 UI 序列逐页调用。 */ capturePageOnce(description: string): void { if (!this.adb.checkDevice()) return; fs.mkdirSync(this.outputRoot, { recursive: true }); const pagesIndex = this.loadExistingIndex(); if (pagesIndex.length > 0) console.log(`📂 已加载 ${pagesIndex.length} 个已有页面`); const manualCounter = PageCrawler.lastManualCounter(pagesIndex) + 1; console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}`); console.log(`📝 单页采集模式:${description}`); const { record } = this.capturePage(description); record.click_path = 'manual'; record.description = description; const pageId = this.commitManualPage(record, manualCounter); pagesIndex.push(record); fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(pagesIndex, null, 2), 'utf-8'); this.pagesIndex = pagesIndex; this.generateHtmlReport(pagesIndex); console.log(`\n✅ 单页采集完成:[${pageId}](index.json 共 ${pagesIndex.length} 个页面)`); console.log(`📂 输出目录: ${path.resolve(this.outputRoot)}`); } async manualCrawl(): Promise { if (!this.adb.checkDevice()) return; fs.mkdirSync(this.outputRoot, { recursive: true }); const pagesIndex = this.loadExistingIndex(); if (pagesIndex.length > 0) console.log(`📂 已加载 ${pagesIndex.length} 个已有页面`); let manualCounter = PageCrawler.lastManualCounter(pagesIndex); console.log(`\n📂 输出目录: ${path.resolve(this.outputRoot)}`); console.log(`📝 手动采集模式已启动(manual 编号从 ${manualCounter + 1} 开始)`); console.log('='.repeat(60)); console.log('操作说明:'); console.log(' 1. 在设备上手动导航到目标页面'); console.log(' 2. 输入页面描述(如"设置页 > 关于"),然后按回车开始采集'); console.log(' 3. 输入 q 退出'); console.log('='.repeat(60)); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const question = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); try { for (;;) { console.log(); let desc = (await question('📝 输入页面描述(q 退出): ')).trim(); if (desc.toLowerCase() === 'q') { console.log('👋 退出手动采集模式'); break; } if (!desc) desc = '手动采集'; manualCounter += 1; const { record } = this.capturePage(desc); record.click_path = 'manual'; record.description = desc; this.commitManualPage(record, manualCounter); pagesIndex.push(record); fs.writeFileSync(path.join(this.outputRoot, 'index.json'), JSON.stringify(pagesIndex, null, 2), 'utf-8'); this.pagesIndex = pagesIndex; this.generateHtmlReport(pagesIndex); console.log(` 📋 index.json 和 report.html 已更新(共 ${pagesIndex.length} 个页面)`); } } finally { rl.close(); } console.log(`\n✅ 手动采集完成!共 ${pagesIndex.length} 个页面(含自动遍历)`); console.log(`📂 输出目录: ${path.resolve(this.outputRoot)}`); } } // ───────────────────────────────────────────── // CLI // ───────────────────────────────────────────── interface CliArgs { package: string; output: string; manual: boolean; page: string | null; device: string; adbPath: string; maxPages: number; maxScrolls: number; } function printHelp(): void { console.log( [ 'Android 模拟器页面遍历工具(加速版)', '', 'Usage: node android_parse_fast.ts --package --output [options]', '', ' --package, -p 目标 App 包名(必选)', ' --output, -o 输出目录(必选)', ' --manual 手动采集模式:手动导航到页面后按回车触发采集', ' --page "<描述>" 单页采集模式:直接用该描述采集设备当前页面,抓完一页即退出(不遍历)', ` --device ADB 设备 serial(默认: ${DEVICE_SERIAL_DEFAULT})`, ` --adb-path adb 可执行文件路径(默认: ${ADB_PATH_DEFAULT})`, ` --max-pages 最多遍历页面数(默认: ${MAX_PAGES_DEFAULT})`, ` --max-scrolls 每个可滚动容器最多滚动次数(默认: ${MAX_SCROLLS_DEFAULT})`, ].join('\n'), ); } function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { package: '', output: '', manual: false, page: null, device: DEVICE_SERIAL_DEFAULT, adbPath: ADB_PATH_DEFAULT, maxPages: MAX_PAGES_DEFAULT, maxScrolls: MAX_SCROLLS_DEFAULT, }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const next = (): string => { if (i + 1 >= argv.length) { console.error(`Missing value for ${a}`); process.exit(2); } return argv[++i]; }; if (a === '--package' || a === '-p') args.package = next(); else if (a === '--output' || a === '-o') args.output = next(); else if (a === '--manual') args.manual = true; else if (a === '--page') args.page = next(); else if (a === '--device') args.device = next(); else if (a === '--adb-path') args.adbPath = next(); else if (a === '--max-pages') args.maxPages = parseInt(next(), 10); else if (a === '--max-scrolls') args.maxScrolls = parseInt(next(), 10); else if (a === '-h' || a === '--help') { printHelp(); process.exit(0); } else { console.error(`Unknown argument: ${a}`); process.exit(2); } } if (!args.package) { console.error('error: --package/-p is required'); process.exit(2); } if (!args.output) { console.error('error: --output/-o is required'); process.exit(2); } if (args.page !== null && args.manual) { console.error('error: --page 与 --manual 互斥'); process.exit(2); } return args; } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); const adb = new ADBHelper({ adbPath: args.adbPath, deviceSerial: args.device, targetPackage: args.package }); const crawler = new PageCrawler({ adb, package: args.package, outputDir: args.output, maxPages: args.maxPages, maxScrolls: args.maxScrolls, }); if (args.page !== null) crawler.capturePageOnce(args.page.trim() || '单页采集'); else if (args.manual) await crawler.manualCrawl(); else crawler.crawl(); return 0; } const isEntry = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isEntry) { main().then( (code) => process.exit(code), (err) => { console.error(err instanceof Error ? err.message : err); process.exit(1); }, ); } export { parseXml, iterNodes, parseBoundsCenter, getClickableElements, extractNodeKeys, findScrollableContainers, normalizeComponent, stableActivityKey, pageSignature, rootIdentifiedSet, isSameRootPage, findElementOnScreen, focusHasActivityComponent, extractLaunchComponent, PageCrawler, ADBHelper, };