#!/usr/bin/env node /** * Android 定时连续采集工具 (TypeScript 版) —— 每隔1秒自动采集一次 view tree + 截图,持续 x 秒。 * * 用于捕获动态UI效果(动画中间态、Toast、瞬态弹窗等)。 * 零第三方依赖,仅使用 Node 内置模块。 * * 用法: * node page_capture_burst.ts --device adb -o ./output --duration 10 * node page_capture_burst.ts --device adb -o ./output --duration 5 --serial emulator-5554 * * 等价于原 page_capture_burst.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'; function sleep(seconds: number): void { 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())); } } 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 adbScreenshot(outputDir: string, filename: string, serial: string | null = null): boolean { const localPath = path.join(outputDir, filename); const args: string[] = []; if (serial) args.push('-s', serial); args.push('exec-out', 'screencap', '-p'); const res = spawnSync('adb', args, { timeout: 15 * 1000, maxBuffer: 128 * 1024 * 1024, windowsHide: true, }); if (res.error && (res.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { console.log(' [WARN] screenshot timeout'); return false; } const stdoutBuf = (res.stdout as Buffer) ?? Buffer.alloc(0); if (stdoutBuf.length > 0) { fs.writeFileSync(localPath, stdoutBuf); return true; } return false; } function adbDumpXml(outputDir: string, filename: string, 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 i = 0; i < 6; i++) { const check = adbShell(['ls', remotePath], serial); if (check.includes(remotePath)) break; sleep(0.2); } const xmlContent = adbShell(['cat', remotePath], serial, 15); if (xmlContent) { fs.writeFileSync(path.join(outputDir, filename), xmlContent, 'utf-8'); } return xmlContent; } interface CliArgs { device: 'adb'; outputDir: string; duration: number; serial: string | null; } function parseArgs(argv: string[]): CliArgs { const args: CliArgs = { device: 'adb', outputDir: '', duration: 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 !== 'adb') { console.error('--device must be adb (currently adb only)'); process.exit(2); } args.device = v; } else if (a === '--output-dir' || a === '-o') { args.outputDir = next(); } else if (a === '--duration' || a === '-d') { args.duration = 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( [ 'Burst capture: take view tree + screenshot every 1s for N seconds', '', 'Usage: node page_capture_burst.ts --device adb -o [--duration N] [--serial S]', '', ' --device Device type (currently adb only)', ' --output-dir,-o Output directory for captured files (required)', ' --duration,-d Total capture duration in seconds (default: 10)', ' --serial ADB device serial (optional)', ].join('\n'), ); } function main(): number { const args = parseArgs(process.argv.slice(2)); fs.mkdirSync(args.outputDir, { recursive: true }); console.log(`[Burst Capture] duration=${args.duration}s, output=${args.outputDir}`); console.log('[Burst Capture] Starting continuous capture...'); const startTime = Date.now(); let frame = 0; while (true) { const elapsed = (Date.now() - startTime) / 1000; if (elapsed >= args.duration) break; frame += 1; const frameStart = Date.now(); const frameStr = String(frame).padStart(3, '0'); process.stdout.write(` Frame ${frame} (t=${elapsed.toFixed(1)}s)... `); const xmlOk = !!adbDumpXml(args.outputDir, `view_${frameStr}.xml`, args.serial); const shotOk = adbScreenshot(args.outputDir, `screenshot_${frameStr}.png`, args.serial); console.log(xmlOk && shotOk ? 'OK' : 'partial'); const frameElapsed = (Date.now() - frameStart) / 1000; const sleepTime = Math.max(0, 1.0 - frameElapsed); if (sleepTime > 0 && (Date.now() - startTime) / 1000 + sleepTime < args.duration) { sleep(sleepTime); } } console.log(`\n[Burst Capture] Done. ${frame} frames captured to ${args.outputDir}`); return 0; } const isEntry = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isEntry) { process.exit(main()); } export { adbDumpXml, adbScreenshot, parseArgs };