#!/usr/bin/env node /** * 统一页面采集工具 (TypeScript 版) —— 单次采集当前设备页面的 View Tree (XML)、截图,支持滚动采集。 * * 支持 Android (ADB) 和 HarmonyOS (HDC) 两种设备。 * 零第三方依赖,仅使用 Node 内置模块,可独立分发。 * * 用法: * # HarmonyOS * node page_capture.ts --device hdc -o ./output * * # Android * node page_capture.ts --device adb -o ./output --serial emulator-5554 * * 等价于原 page_capture.py(functional equivalence,非逐字节一致)。 */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const SCROLL_DURATION_MS = 300; const HDC_SWIPE_SPEED = 600; // pixels/second for hdc uitest uiInput swipe const IDLE_POLL_INTERVAL = 0.2; // seconds const IDLE_TIMEOUT = 5; // seconds const HMOS_SKIP_TYPES = new Set(['WindowScene', '__Common__', 'EffectComponent', 'metaballNode']); const HMOS_SKIP_BUNDLES = new Set([ 'com.huawei.systemui', 'com.ohos.systemui', 'com.huawei.android.launcher', ]); const HORIZONTAL_SCROLL_CLASSES = new Set([ 'android.widget.HorizontalScrollView', 'HorizontalScrollView', ]); function sleep(seconds: number): void { // 同步睡眠,保持与 Python time.sleep 一致的顺序执行语义。 const end = Date.now() + seconds * 1000; const sab = new SharedArrayBuffer(4); const ia = new Int32Array(sab); while (Date.now() < end) { Atomics.wait(ia, 0, 0, Math.min(50, end - Date.now())); } } // --------------------------------------------------------------------------- // 最小 XML 解析器 —— 仅覆盖 uiautomator dump / HarmonyOS 转换后 XML 的用法: // 遍历 元素并读取属性。等价于 Python xml.etree 的 root.iter("node")。 // --------------------------------------------------------------------------- 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 {} /** * 解析 XML 字符串为节点树。抛 XmlParseError 表示格式错误(等价 ET.ParseError)。 * 忽略文本内容(uiautomator 的信息全在属性上)。 */ 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] === '/') { // 闭合标签 const closed = stack.pop(); if (!closed) 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; } /** 等价 element.iter(tag):先序遍历自身及所有后代,产出 tag 匹配的节点。 */ function* iterNodes(root: XmlNode, tag: string): Generator { if (root.tag === tag) yield root; for (const child of root.children) { yield* iterNodes(child, tag); } } /** 遍历所有后代(含自身),不限 tag。 */ function* iterAll(root: XmlNode): Generator { yield root; for (const child of root.children) { yield* iterAll(child); } } function attr(node: XmlNode, key: string, dflt = ''): string { return key in node.attrs ? node.attrs[key] : dflt; } // --------------------------------------------------------------------------- // 子进程封装 // --------------------------------------------------------------------------- function runCapture( file: string, args: string[], opts: { timeout?: number; binary?: boolean } = {}, ): { stdout: string; stdoutBuf: Buffer; stderr: string; code: number | null } { const timeoutMs = (opts.timeout ?? 15) * 1000; const res = spawnSync(file, args, { timeout: timeoutMs, maxBuffer: 128 * 1024 * 1024, encoding: opts.binary ? 'buffer' : undefined, windowsHide: true, }); if (opts.binary) { const buf = (res.stdout as unknown as Buffer) ?? Buffer.alloc(0); return { stdout: '', stdoutBuf: buf, stderr: '', code: res.status }; } // 文本模式:spawnSync 默认返回 Buffer,需按 utf-8 解码(errors='replace' 语义) const outBuf = (res.stdout as unknown as Buffer) ?? Buffer.alloc(0); const errBuf = (res.stderr as unknown as Buffer) ?? Buffer.alloc(0); return { stdout: outBuf.toString('utf-8'), stdoutBuf: outBuf, stderr: errBuf.toString('utf-8'), code: res.status, }; } // --------------------------------------------------------------------------- // ADB helpers // --------------------------------------------------------------------------- function adb(args: string[], serial: string | null = null, timeout = 15): string { const full: string[] = []; if (serial) full.push('-s', serial); full.push(...args); const res = spawnSync('adb', full, { timeout: timeout * 1000, maxBuffer: 128 * 1024 * 1024, windowsHide: true, }); if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { console.log(` [WARN] adb timeout: ${args.join(' ')}`); return ''; } const out = (res.stdout as Buffer) ?? Buffer.alloc(0); return out.toString('utf-8').trim(); } function adbShell(args: string[], serial: string | null = null, timeout = 15): string { return adb(['shell', ...args], serial, timeout); } function adbWaitIdle(serial: string | null = null): void { const deadline = Date.now() + IDLE_TIMEOUT * 1000; let prevFocus: string | null = null; let interval = IDLE_POLL_INTERVAL; while (Date.now() < deadline) { sleep(interval); interval = Math.min(interval * 1.5, 0.5); const focusOut = adbShell(['dumpsys', 'window'], serial); let currentFocus = ''; for (const line of focusOut.split(/\r?\n/)) { if (line.includes('mCurrentFocus')) { currentFocus = line.trim(); break; } } if (currentFocus && currentFocus === prevFocus) return; prevFocus = currentFocus; } } // --------------------------------------------------------------------------- // HDC helpers // --------------------------------------------------------------------------- function hdc(args: string[], timeout = 15): string { // Python 用 shell=True,这里直接调用 hdc 可执行文件(等价,且避免 shell 注入/转义问题) const res = spawnSync('hdc', args, { timeout: timeout * 1000, maxBuffer: 128 * 1024 * 1024, windowsHide: true, }); if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { console.log(` [WARN] hdc timeout: ${args.join(' ')}`); return ''; } const errBuf = (res.stderr as Buffer) ?? Buffer.alloc(0); const errStr = errBuf.toString('utf-8').trim(); if (res.status !== 0 && errStr) { console.log(` [WARN] hdc stderr: ${errStr}`); } const out = (res.stdout as Buffer) ?? Buffer.alloc(0); return out.toString('utf-8').trim(); } function hdcShell(args: string[], timeout = 15): string { return hdc(['shell', ...args], timeout); } function hdcWaitIdle(): void { sleep(1); } /** * Return HarmonyOS display density: {dpi, density_factor}. * HarmonyOS vp uses the same 160-baseline as Android dp, so * density_factor = dpi / 160 and 1vp = density_factor px. * Falls back to 480dpi (3x) if the value cannot be read. */ function hdcGetDensity(): DensityInfo { let dpi: number | null = null; const out = hdcShell(['hidumper', '-s', '3601', '-a', '-a']); // DisplayManagerService const m = out.match(/(?:densityDpi|dpi)\D*(\d{2,4})/i); if (m) { const val = parseInt(m[1], 10); if (val >= 100 && val <= 1000) dpi = val; } if (dpi === null) { const param = hdcShell(['param', 'get', 'persist.sys.dpi']).trim(); if (/^\d+$/.test(param)) { const val = parseInt(param, 10); if (val >= 100 && val <= 1000) dpi = val; } } if (dpi === null) dpi = 480; // fallback: 3x return { dpi, density_factor: Math.round((dpi / 160.0) * 10000) / 10000 }; } // --------------------------------------------------------------------------- // XML utilities (shared) // --------------------------------------------------------------------------- interface ScrollContainer { bounds: [number, number, number, number]; class: string; 'resource-id': string; } function extractInts(s: string): number[] { const m = s.match(/\d+/g); return m ? m.map((x) => parseInt(x, 10)) : []; } 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 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 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; } interface ClickableElement { class: string; text: string; 'resource-id': string; 'content-desc': string; bounds: string; center: [number, number] | null; 'long-clickable': boolean; scroll_index?: number; } 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 parse error: ${(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') || cls === 'List') { 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; } // --------------------------------------------------------------------------- // HarmonyOS: JSON → XML conversion // --------------------------------------------------------------------------- interface HmosNode { attributes?: Record; children?: HmosNode[]; } /** 把 HarmonyOS layout JSON 节点转成 XmlNode(等价 hmos_to_xml)。返回 null 表示被过滤。 */ function hmosToXml(node: HmosNode): XmlNode | null { const attrs = (node.attributes ?? {}) as Record; const t = String(attrs['type'] ?? ''); const bundle = String(attrs['bundleName'] ?? ''); if (HMOS_SKIP_TYPES.has(t)) return null; if (HMOS_SKIP_BUNDLES.has(bundle)) return null; const get = (k: string, d = ''): string => (attrs[k] === undefined || attrs[k] === null ? d : String(attrs[k])); const xmlAttrs: Record = { class: t, 'resource-id': get('key'), text: get('text'), 'content-desc': get('description'), clickable: get('clickable', 'false'), 'long-clickable': get('longClickable', 'false'), scrollable: get('scrollable', 'false'), enabled: get('enabled', 'true'), focused: get('focused', 'false'), bounds: get('bounds'), package: bundle, }; const el: XmlNode = { tag: 'node', attrs: xmlAttrs, children: [] }; for (const child of node.children ?? []) { const childEl = hmosToXml(child); if (childEl !== null) el.children.push(childEl); } return el; } function escapeXmlAttr(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** 序列化为 XML 字符串(无缩进,等价 ET.tostring(encoding="unicode"))。 */ function serializeXml(node: XmlNode): string { const attrStr = Object.entries(node.attrs) .map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`) .join(''); if (node.children.length === 0) { return `<${node.tag}${attrStr} />`; } const inner = node.children.map((c) => serializeXml(c)).join(''); return `<${node.tag}${attrStr}>${inner}`; } /** 带缩进序列化并写文件(等价 ET.indent(space=" ") + tree.write(xml_declaration=True))。 */ function serializeXmlIndented(node: XmlNode, level = 0): string { const pad = ' '.repeat(level); const attrStr = Object.entries(node.attrs) .map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`) .join(''); if (node.children.length === 0) { return `${pad}<${node.tag}${attrStr} />`; } const inner = node.children.map((c) => serializeXmlIndented(c, level + 1)).join('\n'); return `${pad}<${node.tag}${attrStr}>\n${inner}\n${pad}`; } // --------------------------------------------------------------------------- // Android capture // --------------------------------------------------------------------------- function adbDumpXmlQuick(serial: string | null = null): string { const remotePath = '/sdcard/ui_dump.xml'; adbShell(['rm -f /sdcard/ui_dump.xml && uiautomator dump /sdcard/ui_dump.xml'], serial, 20); for (let k = 0; k < 6; k++) { const check = adbShell(['ls', remotePath], serial); if (check.includes(remotePath)) break; sleep(0.2); } return adbShell(['cat', remotePath], serial, 15); } function adbDumpXml(outputDir: string, serial: string | null = null): string { const xmlContent = adbDumpXmlQuick(serial); if (xmlContent) fs.writeFileSync(path.join(outputDir, 'view.xml'), xmlContent, 'utf-8'); return xmlContent; } function adbScreenshot(outputDir: string, filename = 'screenshot.png', serial: string | null = null): void { const localPath = path.join(outputDir, filename); const args: string[] = []; if (serial) args.push('-s', serial); args.push('exec-out', 'screencap', '-p'); const { stdoutBuf, code } = runCapture('adb', args, { timeout: 15, binary: true }); if (code === null) { console.log(' [WARN] screenshot timeout'); return; } if (stdoutBuf.length > 0) fs.writeFileSync(localPath, stdoutBuf); } interface DensityInfo { dpi: number; density_factor: number; } /** * Return device density info: {dpi, density_factor}. * density_factor = dpi / 160 (Android baseline). 1dp = density_factor px. * Used by the alignment pipeline to convert raw px bounds -> dp/vp correctly * instead of assuming a fixed 3x. Falls back to dpi=480 (3x) if unreadable. */ function adbGetDensity(serial: string | null = null): DensityInfo { let dpi: number | null = null; const out = adbShell(['wm', 'density'], serial); const mOver = out.match(/Override density:\s*(\d+)/); const mPhys = out.match(/Physical density:\s*(\d+)/); if (mOver) { dpi = parseInt(mOver[1], 10); } else if (mPhys) { dpi = parseInt(mPhys[1], 10); } if (dpi === null) { const prop = adbShell(['getprop', 'ro.sf.lcd_density'], serial).trim(); if (/^\d+$/.test(prop)) dpi = parseInt(prop, 10); } if (dpi === null) dpi = 480; // fallback: 3x return { dpi, density_factor: Math.round((dpi / 160.0) * 10000) / 10000 }; } function adbGetActivity(serial: string | null = null): Record { const info: Record = {}; const actOut = adbShell(['dumpsys', 'activity', 'activities'], serial); const winOut = adbShell(['dumpsys', 'window'], serial); for (const line of actOut.split(/\r?\n/)) { if (/ResumedActivity|topActivity/i.test(line)) { info['resumed_activity'] = line.trim(); break; } } for (const line of winOut.split(/\r?\n/)) { if (line.includes('mCurrentFocus')) { info['current_focus'] = line.trim(); break; } if (line.includes('mFocusedApp') && !('focused_app' in info)) { info['focused_app'] = line.trim(); } } return info; } function adbScrollAndCapture( outputDir: string, containerBounds: [number, number, number, number], maxScrolls: number, initialXml: string, serial: string | null = null, ): string[] { const [x1, y1, x2, y2] = containerBounds; 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[] = []; let prevKeys: Set | null = initialXml ? extractNodeKeys(initialXml) : null; let scrollCount = 0; for (let i = 0; i < maxScrolls; i++) { adbShell( ['input', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(SCROLL_DURATION_MS)], serial, ); adbWaitIdle(serial); const newXml = adbDumpXmlQuick(serial); const newKeys = extractNodeKeys(newXml); if (prevKeys !== null && setsEqual(newKeys, prevKeys)) { console.log(` Reached bottom after ${i + 1} scroll(s)`); break; } prevKeys = newKeys; scrollCount += 1; xmlList.push(newXml); fs.writeFileSync(path.join(outputDir, `view_scroll_${i + 1}.xml`), newXml, 'utf-8'); adbScreenshot(outputDir, `screenshot_scroll_${i + 1}.png`, serial); console.log(` Scroll ${i + 1}: XML + screenshot saved`); } if (scrollCount > 0) { console.log(` Restoring initial position: scrolling back ${scrollCount} time(s)...`); for (let i = 0; i < scrollCount; i++) { adbShell( ['input', 'swipe', String(cx), String(swipeToY), String(cx), String(swipeFromY), String(SCROLL_DURATION_MS)], serial, ); adbWaitIdle(serial); } } return xmlList; } function dedupeClickable(allXmlContents: string[]): ClickableElement[] { const seen = 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']), ].join(''); if (!seen.has(key)) { seen.add(key); elem.scroll_index = idx; clickable.push(elem); } } }); return clickable; } function captureAndroid(outputDir: string, serial: string | null = null, maxScrolls = 10): void { fs.mkdirSync(outputDir, { recursive: true }); console.log('[1/4] Dumping view XML...'); const xmlContent = adbDumpXml(outputDir, serial); if (!xmlContent) { console.log('[ERR] Failed to dump XML'); return; } console.log('[2/4] Taking screenshot...'); adbScreenshot(outputDir, 'screenshot.png', serial); console.log('[3/4] Checking scrollable containers...'); const containers = findScrollableContainers(xmlContent); const allXmlContents = [xmlContent]; if (containers.length > 0) { const container = containers[0]; console.log(` Found scrollable: ${container.class} ${container['resource-id']}`); const scrollXmls = adbScrollAndCapture(outputDir, container.bounds, maxScrolls, xmlContent, serial); allXmlContents.push(...scrollXmls); console.log(` Scroll capture done: ${scrollXmls.length} scroll(s)`); } else { console.log(' No scrollable container found'); } console.log('[4/4] Building meta.json...'); const density = adbGetDensity(serial); console.log(` Density: ${density.dpi}dpi (${density.density_factor}x)`); const activityInfo = adbGetActivity(serial); const clickable = dedupeClickable(allXmlContents); const meta = { timestamp: new Date().toISOString(), device: 'adb', dpi: density.dpi, density_factor: density.density_factor, activity_info: activityInfo, clickable_count: clickable.length, clickable_elements: clickable, }; fs.writeFileSync(path.join(outputDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8'); console.log(`\nDone! Files saved to ${outputDir}`); } // --------------------------------------------------------------------------- // HarmonyOS capture // --------------------------------------------------------------------------- function hdcDumpLayout(outputDir: string): { xml: string; pageName: string } { const out = hdcShell(['uitest dumpLayout']); const match = out.match(/saved to:(.+\.json)/); if (!match) throw new Error(`Cannot parse dumpLayout output: ${out}`); const remoteJson = match[1].trim(); const localJson = path.join(outputDir, '_tmp_layout.json'); hdc(['file', 'recv', remoteJson, localJson]); const data = JSON.parse(fs.readFileSync(localJson, 'utf-8')) as HmosNode; let pageName = ''; if (data.children && data.children.length > 0) { const pp = String(data.children[0].attributes?.['pagePath'] ?? ''); pageName = pp ? pp.split('/').pop() || 'unknown' : 'unknown'; } const rootEl = hmosToXml(data); const xmlPath = path.join(outputDir, 'view.xml'); if (rootEl) { const doc = `\n${serializeXmlIndented(rootEl)}`; fs.writeFileSync(xmlPath, doc, 'utf-8'); } const xmlStr = rootEl ? serializeXml(rootEl) : ''; fs.rmSync(localJson, { force: true }); return { xml: xmlStr, pageName }; } function hdcDumpLayoutQuick(outputDir: string): string { const out = hdcShell(['uitest dumpLayout']); const match = out.match(/saved to:(.+\.json)/); if (!match) return ''; const remoteJson = match[1].trim(); const localJson = path.join(outputDir, '_tmp_layout.json'); hdc(['file', 'recv', remoteJson, localJson]); try { const data = JSON.parse(fs.readFileSync(localJson, 'utf-8')) as HmosNode; const rootEl = hmosToXml(data); return rootEl ? serializeXml(rootEl) : ''; } finally { if (fs.existsSync(localJson)) fs.rmSync(localJson, { force: true }); } } function hdcScreenshot(outputDir: string, filename = 'screenshot.jpeg'): void { const remoteImg = '/data/local/tmp/screenshot.jpeg'; hdcShell([`snapshot_display -f ${remoteImg}`]); hdc(['file', 'recv', remoteImg, path.join(outputDir, filename)]); } function hdcScrollAndCapture( outputDir: string, containerBounds: [number, number, number, number], maxScrolls: number, initialXml: string, ): string[] { const [x1, y1, x2, y2] = containerBounds; 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[] = []; let prevKeys: Set | null = initialXml ? extractNodeKeys(initialXml) : null; let scrollCount = 0; for (let i = 0; i < maxScrolls; i++) { hdcShell(['uitest', 'uiInput', 'swipe', String(cx), String(swipeFromY), String(cx), String(swipeToY), String(HDC_SWIPE_SPEED)]); hdcWaitIdle(); const newXml = hdcDumpLayoutQuick(outputDir); const newKeys = extractNodeKeys(newXml); if (prevKeys !== null && setsEqual(newKeys, prevKeys)) { console.log(` Reached bottom after ${i + 1} scroll(s)`); break; } prevKeys = newKeys; scrollCount += 1; xmlList.push(newXml); fs.writeFileSync(path.join(outputDir, `view_scroll_${i + 1}.xml`), newXml, 'utf-8'); hdcScreenshot(outputDir, `screenshot_scroll_${i + 1}.jpeg`); console.log(` Scroll ${i + 1}: XML + screenshot saved`); } if (scrollCount > 0) { console.log(` Restoring initial position: scrolling back ${scrollCount} time(s)...`); for (let i = 0; i < scrollCount; i++) { hdcShell(['uitest', 'uiInput', 'swipe', String(cx), String(swipeToY), String(cx), String(swipeFromY), String(HDC_SWIPE_SPEED)]); hdcWaitIdle(); } } return xmlList; } function captureHdc(outputDir: string, maxScrolls = 10): void { fs.mkdirSync(outputDir, { recursive: true }); console.log('[1/3] Dumping layout (JSON -> XML)...'); const { xml: xmlContent, pageName } = hdcDumpLayout(outputDir); if (!xmlContent) { console.log('[ERR] Failed to dump layout'); return; } console.log(` Page: ${pageName}`); console.log('[2/3] Taking screenshot...'); hdcScreenshot(outputDir); console.log('[3/3] Checking scrollable containers...'); const containers = findScrollableContainers(xmlContent); if (containers.length > 0) { const container = containers[0]; console.log(` Found scrollable: ${container.class} ${container['resource-id']}`); const scrollXmls = hdcScrollAndCapture(outputDir, container.bounds, maxScrolls, xmlContent); console.log(` Scroll capture done: ${scrollXmls.length} scroll(s)`); } else { console.log(' No scrollable container found'); } const density = hdcGetDensity(); console.log(` Density: ${density.dpi}dpi (${density.density_factor}x)`); const meta = { timestamp: new Date().toISOString(), device: 'hdc', dpi: density.dpi, density_factor: density.density_factor, page_name: pageName, }; fs.writeFileSync(path.join(outputDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8'); console.log(`\nDone! Files saved to ${outputDir}`); } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- interface CliArgs { device: 'hdc' | 'adb'; outputDir: string; maxScrolls: number; serial: string | null; } function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { device: 'hdc', outputDir: '', maxScrolls: 10, serial: null }; 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 === '--device') { const v = next(); if (v !== 'hdc' && v !== 'adb') { console.error(`--device must be hdc or adb`); process.exit(2); } args.device = v; } else if (a === '--output-dir' || a === '-o') { args.outputDir = next(); } else if (a === '--max-scrolls') { args.maxScrolls = parseInt(next(), 10); } else if (a === '--serial') { args.serial = next(); } else if (a === '-h' || a === '--help') { printHelp(); process.exit(0); } else { console.error(`Unknown argument: ${a}`); process.exit(2); } } if (!args.outputDir) { console.error('error: --output-dir/-o is required'); process.exit(2); } return args; } function printHelp(): void { console.log( [ 'Capture current page: view tree XML + screenshots (Android / HarmonyOS)', '', 'Usage: node page_capture.ts --device -o [--max-scrolls N] [--serial S]', '', ' --device Device type: hdc=HarmonyOS, adb=Android (default: hdc)', ' --output-dir,-o Output directory for captured files (required)', ' --max-scrolls Max scroll count per container (default: 10)', ' --serial ADB device serial (optional, for multi-device)', ].join('\n'), ); } function main(): number { const args = parseArgs(process.argv.slice(2)); if (args.device === 'adb') { captureAndroid(args.outputDir, args.serial, args.maxScrolls); } else { captureHdc(args.outputDir, args.maxScrolls); } return 0; } // 仅当作为入口脚本直接运行时执行 main;被 import(测试)时不执行。 const isEntry = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isEntry) { process.exit(main()); } // 导出纯函数供测试(Node 直接 run .ts 时 import.meta 存在,import 本文件即可拿到) export { parseXml, iterNodes, iterAll, findScrollableContainers, extractNodeKeys, parseBoundsCenter, getClickableElements, hmosToXml, serializeXml, serializeXmlIndented, dedupeClickable, };