|
@@ -0,0 +1,314 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 使用 ADB XML 解析方式提取聊天记录
|
|
|
|
|
+ * 通过 uiautomator dump 获取 UI 层次结构 XML,然后解析提取文本
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+import { exec } from 'child_process';
|
|
|
|
|
+import { promisify } from 'util';
|
|
|
|
|
+import { join, dirname, isAbsolute } from 'path';
|
|
|
|
|
+import { fileURLToPath } from 'url';
|
|
|
|
|
+import { getCachedAdbPath } from '../config.js';
|
|
|
|
|
+
|
|
|
|
|
+const execAsync = promisify(exec);
|
|
|
|
|
+const __filename = fileURLToPath(import.meta.url);
|
|
|
|
|
+const __dirname = dirname(__filename);
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 使用 ADB uiautomator dump 获取屏幕文本
|
|
|
|
|
+ * @param {string} ipPort - 设备 ID/IP:Port
|
|
|
|
|
+ * @param {string} method - 提取方法('full-screen' 或 'last-message')
|
|
|
|
|
+ * @param {string} folderPath - 工作流文件夹路径(可选,如果提供则保存 XML 到 tmp 目录)
|
|
|
|
|
+ * @returns {Promise<{success: boolean, text?: string, error?: string}>}
|
|
|
|
|
+ */
|
|
|
|
|
+export async function extractChatHistoryByAdbXml(ipPort, method = 'full-screen', folderPath = null) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!ipPort) {
|
|
|
|
|
+ return { success: false, error: '缺少设备 ID' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const adbPath = getCachedAdbPath();
|
|
|
|
|
+
|
|
|
|
|
+ // 使用 uiautomator dump 获取 UI 层次结构 XML
|
|
|
|
|
+ // 先 dump 到设备文件,然后读取内容
|
|
|
|
|
+ let xmlContent = '';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 执行 uiautomator dump 到设备文件
|
|
|
|
|
+ // 尝试多种方式获取 UI dump
|
|
|
|
|
+ console.log('[ADB XML] 尝试方法1: uiautomator dump 到文件');
|
|
|
|
|
+ const dumpCommand = `${adbPath} -s ${ipPort} shell uiautomator dump /sdcard/ui_dump.xml`;
|
|
|
|
|
+ const { stdout: dumpStdout, stderr: dumpStderr } = await execAsync(dumpCommand, {
|
|
|
|
|
+ timeout: 10000,
|
|
|
|
|
+ maxBuffer: 1024 * 1024,
|
|
|
|
|
+ encoding: 'utf8'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 检查 dump 是否成功(通常输出 "UI hierchary dumped to: /sdcard/ui_dump.xml")
|
|
|
|
|
+ console.log('[ADB XML] dump 命令输出:', dumpStdout);
|
|
|
|
|
+ if (dumpStderr) {
|
|
|
|
|
+ console.log('[ADB XML] dump 命令错误输出:', dumpStderr);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 等待一小段时间,确保文件写入完成
|
|
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
|
+
|
|
|
|
|
+ // 读取 XML 文件内容
|
|
|
|
|
+ const catCommand = `${adbPath} -s ${ipPort} shell cat /sdcard/ui_dump.xml`;
|
|
|
|
|
+ const { stdout } = await execAsync(catCommand, {
|
|
|
|
|
+ timeout: 10000,
|
|
|
|
|
+ maxBuffer: 10 * 1024 * 1024,
|
|
|
|
|
+ encoding: 'utf8'
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ console.log('[ADB XML] 读取到的原始内容长度:', stdout.length);
|
|
|
|
|
+ console.log('[ADB XML] 读取到的原始内容(前500字符):', stdout.substring(0, 500));
|
|
|
|
|
+
|
|
|
|
|
+ // 清理可能的提示信息(uiautomator dump 可能会在 XML 前输出提示)
|
|
|
|
|
+ // 查找 XML 开始标记
|
|
|
|
|
+ const xmlStartIndex = stdout.indexOf('<?xml');
|
|
|
|
|
+ if (xmlStartIndex > 0) {
|
|
|
|
|
+ xmlContent = stdout.substring(xmlStartIndex);
|
|
|
|
|
+ } else if (stdout.includes('<hierarchy')) {
|
|
|
|
|
+ // 如果没有 XML 声明,查找 hierarchy 标签
|
|
|
|
|
+ const hierarchyStartIndex = stdout.indexOf('<hierarchy');
|
|
|
|
|
+ xmlContent = stdout.substring(hierarchyStartIndex);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ xmlContent = stdout;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 清理设备上的临时文件
|
|
|
|
|
+ try {
|
|
|
|
|
+ await execAsync(`${adbPath} -s ${ipPort} shell rm /sdcard/ui_dump.xml`, {
|
|
|
|
|
+ timeout: 3000
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (rmError) {
|
|
|
|
|
+ // 忽略删除失败
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('[ADB XML] 获取 UI dump 失败:', error);
|
|
|
|
|
+ return { success: false, error: `获取 UI dump 失败: ${error.message}` };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!xmlContent || !xmlContent.trim()) {
|
|
|
|
|
+ console.error('[ADB XML] UI dump 内容为空');
|
|
|
|
|
+ return { success: false, error: 'UI dump 内容为空' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 调试:输出 XML 内容的前1000字符和总长度
|
|
|
|
|
+ console.log('[ADB XML] UI dump 内容长度:', xmlContent.length);
|
|
|
|
|
+ console.log('[ADB XML] UI dump 内容(前1000字符):', xmlContent.substring(0, 1000));
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否包含 XML 结构
|
|
|
|
|
+ if (!xmlContent.includes('<hierarchy') && !xmlContent.includes('<?xml')) {
|
|
|
|
|
+ console.error('[ADB XML] UI dump 内容不包含有效的 XML 结构');
|
|
|
|
|
+ console.error('[ADB XML] 实际内容:', xmlContent.substring(0, 500));
|
|
|
|
|
+ return { success: false, error: 'UI dump 内容格式不正确,未找到 XML 结构' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查 XML 是否为空(只有空的 hierarchy 节点)
|
|
|
|
|
+ const nodeCount = (xmlContent.match(/<node/g) || []).length;
|
|
|
|
|
+ console.log('[ADB XML] XML 中的 node 节点数量:', nodeCount);
|
|
|
|
|
+
|
|
|
|
|
+ if (nodeCount <= 1) {
|
|
|
|
|
+ console.warn('[ADB XML] 警告:XML 中几乎没有节点,可能是微信使用了自定义视图,uiautomator dump 无法获取聊天内容');
|
|
|
|
|
+ console.warn('[ADB XML] 建议:微信聊天内容可能使用 Canvas 或自定义 View 绘制,uiautomator dump 无法捕获这些内容');
|
|
|
|
|
+ console.warn('[ADB XML] 解决方案:1. 检查是否需要开启辅助功能权限 2. 考虑使用 OCR 方式 3. 尝试其他 ADB 命令');
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试使用 dumpsys 获取窗口信息(作为备选方案)
|
|
|
|
|
+ try {
|
|
|
|
|
+ console.log('[ADB XML] 尝试方法2: dumpsys window 获取窗口信息');
|
|
|
|
|
+ const dumpsysCommand = `${adbPath} -s ${ipPort} shell dumpsys window windows | grep -E 'mCurrentFocus|mFocusedApp'`;
|
|
|
|
|
+ const { stdout: dumpsysStdout } = await execAsync(dumpsysCommand, {
|
|
|
|
|
+ timeout: 5000,
|
|
|
|
|
+ maxBuffer: 1024 * 1024,
|
|
|
|
|
+ encoding: 'utf8'
|
|
|
|
|
+ });
|
|
|
|
|
+ console.log('[ADB XML] dumpsys window 输出:', dumpsysStdout);
|
|
|
|
|
+ } catch (dumpsysError) {
|
|
|
|
|
+ console.warn('[ADB XML] dumpsys 命令执行失败:', dumpsysError.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果提供了 folderPath,保存 XML 到 tmp 目录
|
|
|
|
|
+ if (folderPath) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { mkdir, writeFile } = await import('fs/promises');
|
|
|
|
|
+
|
|
|
|
|
+ // 确保 folderPath 是绝对路径
|
|
|
|
|
+ let absoluteFolderPath = folderPath;
|
|
|
|
|
+ if (!isAbsolute(folderPath)) {
|
|
|
|
|
+ if (folderPath.startsWith('static/processing/')) {
|
|
|
|
|
+ const folderName = folderPath.replace('static/processing/', '');
|
|
|
|
|
+ absoluteFolderPath = join(__dirname, '..', '..', 'static', 'processing', folderName);
|
|
|
|
|
+ } else if (folderPath.startsWith('static\\processing\\')) {
|
|
|
|
|
+ const folderName = folderPath.replace('static\\processing\\', '');
|
|
|
|
|
+ absoluteFolderPath = join(__dirname, '..', '..', 'static', 'processing', folderName);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ absoluteFolderPath = join(__dirname, '..', '..', 'static', 'processing', folderPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 创建 tmp 目录
|
|
|
|
|
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19).replace('T', '_');
|
|
|
|
|
+ const tmpDir = join(absoluteFolderPath, 'tmp', timestamp);
|
|
|
|
|
+ await mkdir(tmpDir, { recursive: true });
|
|
|
|
|
+
|
|
|
|
|
+ // 保存 XML 文件
|
|
|
|
|
+ const xmlPath = join(tmpDir, 'ui_dump.xml');
|
|
|
|
|
+ await writeFile(xmlPath, xmlContent, 'utf-8');
|
|
|
|
|
+ console.log(`[ADB XML] XML 已保存到: ${xmlPath}`);
|
|
|
|
|
+ } catch (saveError) {
|
|
|
|
|
+ console.warn('[ADB XML] 保存 XML 到 tmp 目录失败:', saveError);
|
|
|
|
|
+ // 保存失败不影响主流程,继续执行
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 解析 XML,提取所有 text 属性
|
|
|
|
|
+ const texts = extractTextsFromXml(xmlContent);
|
|
|
|
|
+
|
|
|
|
|
+ console.log('[ADB XML] 提取到的文本数量:', texts.length);
|
|
|
|
|
+ if (texts.length > 0) {
|
|
|
|
|
+ console.log('[ADB XML] 提取到的文本(前5条):', texts.slice(0, 5));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (texts.length === 0) {
|
|
|
|
|
+ // 调试:尝试直接匹配所有 text 属性(不过滤)
|
|
|
|
|
+ const allTexts = extractTextsFromXml(xmlContent, true);
|
|
|
|
|
+ console.log('[ADB XML] 未过滤的文本数量:', allTexts.length);
|
|
|
|
|
+ if (allTexts.length > 0) {
|
|
|
|
|
+ console.log('[ADB XML] 未过滤的文本(前5条):', allTexts.slice(0, 5));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 如果 XML 中几乎没有节点,说明 uiautomator dump 无法获取微信的聊天内容
|
|
|
|
|
+ const nodeCount = (xmlContent.match(/<node/g) || []).length;
|
|
|
|
|
+ if (nodeCount <= 1) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ success: false,
|
|
|
|
|
+ error: '微信聊天内容使用自定义视图(Canvas/自定义View),uiautomator dump 无法获取。建议:1. 检查是否需要开启辅助功能权限 2. 考虑使用 OCR 方式提取文本'
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { success: false, error: '未能从 UI dump 中提取到文本' };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 根据 method 返回结果
|
|
|
|
|
+ if (method === 'last-message') {
|
|
|
|
|
+ // 返回最后一条消息(通常是最后出现的文本)
|
|
|
|
|
+ return {
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ text: texts[texts.length - 1] || ''
|
|
|
|
|
+ };
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 返回所有文本(用换行符连接)
|
|
|
|
|
+ return {
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ text: texts.join('\n')
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('ADB XML 解析失败:', error);
|
|
|
|
|
+ return { success: false, error: error.message || 'ADB XML 解析失败' };
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 从 XML 内容中提取所有文本
|
|
|
|
|
+ * 使用正则表达式提取 text 属性(简单但有效)
|
|
|
|
|
+ * @param {string} xmlContent - XML 内容
|
|
|
|
|
+ * @param {boolean} skipFilter - 是否跳过过滤(用于调试)
|
|
|
|
|
+ * @returns {string[]} 提取的文本数组
|
|
|
|
|
+ */
|
|
|
|
|
+function extractTextsFromXml(xmlContent, skipFilter = false) {
|
|
|
|
|
+ const texts = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 方法1: 使用正则表达式匹配 text="..." 属性(支持转义字符)
|
|
|
|
|
+ const textRegex1 = /text\s*=\s*"((?:[^"\\]|\\.)*)"|text\s*=\s*'((?:[^'\\]|\\.)*)'/g;
|
|
|
|
|
+ let match;
|
|
|
|
|
+
|
|
|
|
|
+ while ((match = textRegex1.exec(xmlContent)) !== null) {
|
|
|
|
|
+ let text = match[1] || match[2] || '';
|
|
|
|
|
+
|
|
|
|
|
+ // 处理转义字符
|
|
|
|
|
+ if (text) {
|
|
|
|
|
+ text = text
|
|
|
|
|
+ .replace(/\\"/g, '"')
|
|
|
|
|
+ .replace(/\\'/g, "'")
|
|
|
|
|
+ .replace(/\\\\/g, '\\');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (text && text.trim()) {
|
|
|
|
|
+ const trimmedText = text.trim();
|
|
|
|
|
+ if (skipFilter || !isUIControlText(trimmedText)) {
|
|
|
|
|
+ texts.push(trimmedText);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 方法2: 如果方法1没有匹配到任何内容,尝试更简单的正则表达式
|
|
|
|
|
+ if (texts.length === 0) {
|
|
|
|
|
+ console.log('[ADB XML] 方法1未匹配到文本,尝试方法2(简单正则)');
|
|
|
|
|
+ const textRegex2 = /text="([^"]*)"|text='([^']*)'/g;
|
|
|
|
|
+ let match2;
|
|
|
|
|
+
|
|
|
|
|
+ while ((match2 = textRegex2.exec(xmlContent)) !== null) {
|
|
|
|
|
+ const text = match2[1] || match2[2] || '';
|
|
|
|
|
+ if (text && text.trim()) {
|
|
|
|
|
+ const trimmedText = text.trim();
|
|
|
|
|
+ if (skipFilter || !isUIControlText(trimmedText)) {
|
|
|
|
|
+ texts.push(trimmedText);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 方法3: 如果仍然没有匹配到,尝试匹配 content-desc 属性(某些应用使用 content-desc 而不是 text)
|
|
|
|
|
+ if (texts.length === 0) {
|
|
|
|
|
+ console.log('[ADB XML] 方法2未匹配到文本,尝试方法3(content-desc)');
|
|
|
|
|
+ const contentDescRegex = /content-desc="([^"]*)"|content-desc='([^']*)'/g;
|
|
|
|
|
+ let match3;
|
|
|
|
|
+
|
|
|
|
|
+ while ((match3 = contentDescRegex.exec(xmlContent)) !== null) {
|
|
|
|
|
+ const text = match3[1] || match3[2] || '';
|
|
|
|
|
+ if (text && text.trim()) {
|
|
|
|
|
+ const trimmedText = text.trim();
|
|
|
|
|
+ if (skipFilter || !isUIControlText(trimmedText)) {
|
|
|
|
|
+ texts.push(trimmedText);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 调试:输出匹配结果
|
|
|
|
|
+ if (texts.length > 0) {
|
|
|
|
|
+ console.log(`[ADB XML] 成功提取 ${texts.length} 条文本`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 输出 XML 片段,帮助调试
|
|
|
|
|
+ const sampleXml = xmlContent.substring(0, 2000);
|
|
|
|
|
+ console.log('[ADB XML] 未能匹配到任何文本,XML 片段:', sampleXml);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return texts;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 判断是否为 UI 控件文本(按钮、标签等)
|
|
|
|
|
+ * 可以根据实际需求调整过滤规则
|
|
|
|
|
+ * @param {string} text - 文本内容
|
|
|
|
|
+ * @returns {boolean} 是否为 UI 控件文本
|
|
|
|
|
+ */
|
|
|
|
|
+function isUIControlText(text) {
|
|
|
|
|
+ // 暂时不过滤任何文本,保留所有文本
|
|
|
|
|
+ // 如果后续需要过滤,可以在这里添加逻辑
|
|
|
|
|
+ return false;
|
|
|
|
|
+
|
|
|
|
|
+ // 以下是之前的过滤逻辑(已禁用)
|
|
|
|
|
+ // const uiControlKeywords = [
|
|
|
|
|
+ // '发送', '返回', '确定', '取消', '搜索', '设置',
|
|
|
|
|
+ // '更多', '分享', '复制', '删除', '编辑',
|
|
|
|
|
+ // 'SEND', 'BACK', 'OK', 'CANCEL', 'SEARCH', 'SETTINGS'
|
|
|
|
|
+ // ];
|
|
|
|
|
+ // if (text.length <= 3 && uiControlKeywords.includes(text)) {
|
|
|
|
|
+ // return true;
|
|
|
|
|
+ // }
|
|
|
|
|
+ // return false;
|
|
|
|
|
+}
|