execute-py.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950
  1. /**
  2. * 执行 Python 相关操作模块(通用功能)
  3. * 负责图像匹配、OCR识别等通用功能(通过调用 Python/JS 实现)
  4. * 注意:聊天记录提取等业务相关功能在 read-and-write.js 中
  5. */
  6. import { ipcMain } from 'electron';
  7. import { writeFile, mkdir, rm, readdir, stat } from 'fs/promises';
  8. import fs from 'fs';
  9. import { join, dirname, isAbsolute } from 'path';
  10. import { fileURLToPath } from 'url';
  11. import { exec } from 'child_process';
  12. import { promisify } from 'util';
  13. import { captureScreenshot } from './adb/screenshot.js';
  14. import { getDeviceResolution } from './adb/device-info.js';
  15. import { matchImage } from './func/image-center-location.js';
  16. import { findTextLocation } from './func/string-reg-location.js';
  17. import { ocrFullScreen as ocrFullScreenFromFunc, getLastMessage as getLastMessageFromFunc } from './func/ocr-chat.js';
  18. const execAsync = promisify(exec);
  19. const __filename = fileURLToPath(import.meta.url);
  20. const __dirname = dirname(__filename);
  21. /**
  22. * 递归计算目录大小
  23. * @param {string} dirPath - 目录路径
  24. * @returns {Promise<number>} 目录总大小(字节)
  25. */
  26. async function calculateDirSize(dirPath) {
  27. let totalSize = 0;
  28. try {
  29. const entries = await readdir(dirPath, { withFileTypes: true });
  30. for (const entry of entries) {
  31. const entryPath = join(dirPath, entry.name);
  32. try {
  33. if (entry.isFile()) {
  34. const stats = await stat(entryPath);
  35. totalSize += stats.size;
  36. } else if (entry.isDirectory()) {
  37. totalSize += await calculateDirSize(entryPath);
  38. }
  39. } catch (e) {
  40. // 忽略无法访问的文件/目录
  41. }
  42. }
  43. } catch (e) {
  44. // 忽略无法读取的目录
  45. }
  46. return totalSize;
  47. }
  48. /**
  49. * 执行图像匹配:截图、调用 Python 脚本、返回坐标
  50. * @param {string} ipPort - 设备 ID/IP:Port
  51. * @param {string} templateImagePath - 模板图片路径
  52. * @returns {Promise<{success: boolean, error?: string, coordinate?: Object, clickPosition?: Object}>}
  53. */
  54. export async function matchImageAndGetCoordinate(ipPort, templateImagePath) {
  55. try {
  56. if (!ipPort) {
  57. return { success: false, error: '缺少设备 ID' };
  58. }
  59. if (!templateImagePath) {
  60. return { success: false, error: '缺少模板图片路径' };
  61. }
  62. // 将相对路径转换为绝对路径
  63. let absoluteTemplatePath = templateImagePath;
  64. if (!isAbsolute(templateImagePath)) {
  65. absoluteTemplatePath = join(__dirname, '..', templateImagePath);
  66. }
  67. // 1. 获取设备分辨率
  68. const resolutionResult = await getDeviceResolution(ipPort);
  69. if (!resolutionResult.success) {
  70. return { success: false, error: '获取设备分辨率失败' };
  71. }
  72. const { width, height } = resolutionResult;
  73. // 2. 获取屏幕截图
  74. const screenshotResult = await captureScreenshot(ipPort, { format: 'png' });
  75. if (!screenshotResult.success || !screenshotResult.data) {
  76. return { success: false, error: '获取屏幕截图失败' };
  77. }
  78. // 3. 保存截图到临时文件
  79. const tempDir = join(__dirname, '..');
  80. const screenshotPath = join(tempDir, 'temp_screenshot.png');
  81. const screenshotBuffer = Buffer.from(screenshotResult.data, 'base64');
  82. await writeFile(screenshotPath, screenshotBuffer);
  83. // 4. 调用 JS 函数进行图像匹配
  84. const matchResult = await matchImage(screenshotPath, absoluteTemplatePath, width, height);
  85. if (!matchResult.success) {
  86. return { success: false, error: matchResult.error || '图像匹配失败' };
  87. }
  88. // 5. 返回匹配结果
  89. if (matchResult.success && matchResult.x !== undefined) {
  90. const { x, y, width: w, height: h } = matchResult;
  91. // 计算点击位置(中心点)
  92. const clickX = Math.round(x + w / 2);
  93. const clickY = Math.round(y + h / 2);
  94. return {
  95. success: true,
  96. coordinate: { x, y, width: w, height: h },
  97. clickPosition: { x: clickX, y: clickY }
  98. };
  99. } else {
  100. return {
  101. success: false,
  102. error: matchResult.error || '图像匹配失败'
  103. };
  104. }
  105. } catch (error) {
  106. console.error('图像匹配失败:', error);
  107. return { success: false, error: error.message };
  108. }
  109. }
  110. /**
  111. * 图像区域定位:在完整截图中查找区域截图的位置,返回四个顶点坐标
  112. * @param {string} screenshotPath - 完整截图路径
  113. * @param {string} regionPath - 区域截图路径
  114. * @param {string} device - 设备 ID(可选,用于获取分辨率)
  115. * @returns {Promise<{success: boolean, error?: string, corners?: Object}>}
  116. */
  117. export async function matchImageRegionLocation(screenshotPath, regionPath, device = null) {
  118. try {
  119. if (!regionPath) {
  120. return { success: false, error: '缺少区域截图路径' };
  121. }
  122. // 如果 screenshotPath 为 '__AUTO_SCREENSHOT__' 或 null,自动从设备获取截图
  123. let absoluteScreenshotPath = screenshotPath;
  124. if (!screenshotPath || screenshotPath === '__AUTO_SCREENSHOT__' || screenshotPath === null) {
  125. if (!device) {
  126. return { success: false, error: '缺少完整截图路径,且无法自动获取设备截图(缺少设备ID)' };
  127. }
  128. // 自动获取设备截图
  129. const resolutionResult = await getDeviceResolution(device);
  130. if (!resolutionResult.success) {
  131. return { success: false, error: '获取设备分辨率失败' };
  132. }
  133. const screenshotResult = await captureScreenshot(device, { format: 'png' });
  134. if (!screenshotResult.success || !screenshotResult.data) {
  135. return { success: false, error: '自动获取设备截图失败' };
  136. }
  137. // 保存截图到临时文件
  138. const tempDir = join(__dirname, '..');
  139. const tempScreenshotPath = join(tempDir, 'temp_screenshot.png');
  140. const screenshotBuffer = Buffer.from(screenshotResult.data, 'base64');
  141. await writeFile(tempScreenshotPath, screenshotBuffer);
  142. absoluteScreenshotPath = tempScreenshotPath;
  143. } else {
  144. // 将相对路径转换为绝对路径
  145. if (!isAbsolute(screenshotPath)) {
  146. absoluteScreenshotPath = join(__dirname, '..', screenshotPath);
  147. }
  148. }
  149. let absoluteRegionPath = regionPath;
  150. if (!isAbsolute(regionPath)) {
  151. absoluteRegionPath = join(__dirname, '..', regionPath);
  152. }
  153. // 可选:如果提供了设备ID,获取设备分辨率用于缩放
  154. let width = null;
  155. let height = null;
  156. if (device) {
  157. const resolutionResult = await getDeviceResolution(device);
  158. if (resolutionResult.success) {
  159. width = resolutionResult.width;
  160. height = resolutionResult.height;
  161. }
  162. }
  163. // 调用图像匹配函数
  164. const matchResult = await matchImage(absoluteScreenshotPath, absoluteRegionPath, width, height);
  165. if (!matchResult.success) {
  166. return { success: false, error: matchResult.error || '图像匹配失败' };
  167. }
  168. // 获取匹配结果
  169. const { x, y, width: w, height: h } = matchResult;
  170. // 计算四个顶点坐标
  171. const corners = {
  172. topLeft: { x, y },
  173. topRight: { x: x + w, y },
  174. bottomLeft: { x, y: y + h },
  175. bottomRight: { x: x + w, y: y + h }
  176. };
  177. // 如果提供了保存路径,裁剪并保存区域图片
  178. // 这个功能会在 executeImageRegionLocation 中调用时传入 savePath
  179. // 但为了保持API简洁,我们在这里不处理,而是在 executeImageRegionLocation 中处理
  180. return {
  181. success: true,
  182. x,
  183. y,
  184. width: w,
  185. height: h,
  186. corners: corners,
  187. similarity: matchResult.similarity,
  188. screenshotPath: absoluteScreenshotPath // 返回截图路径,用于后续裁剪
  189. };
  190. } catch (error) {
  191. console.error('图像区域定位失败:', error);
  192. return { success: false, error: error.message };
  193. }
  194. }
  195. /**
  196. * 裁剪并保存图片区域
  197. * @param {string} imagePath - 原图片路径
  198. * @param {number} x - 裁剪区域左上角x坐标
  199. * @param {number} y - 裁剪区域左上角y坐标
  200. * @param {number} width - 裁剪区域宽度
  201. * @param {number} height - 裁剪区域高度
  202. * @param {string} savePath - 保存路径
  203. * @returns {Promise<{success: boolean, error?: string}>}
  204. */
  205. export async function cropAndSaveImage(imagePath, x, y, width, height, savePath) {
  206. console.log('[cropAndSaveImage] 收到请求,参数:', { imagePath, x, y, width, height, savePath });
  207. try {
  208. // 处理相对路径,转换为绝对路径
  209. let absoluteImagePath = imagePath;
  210. if (!isAbsolute(imagePath)) {
  211. // 如果是相对路径,相对于项目根目录
  212. if (imagePath.startsWith('static/processing/')) {
  213. absoluteImagePath = join(__dirname, '..', imagePath);
  214. } else {
  215. absoluteImagePath = join(__dirname, '..', imagePath);
  216. }
  217. console.log('[cropAndSaveImage] 图片路径转换,原始:', imagePath, '-> 绝对:', absoluteImagePath);
  218. } else {
  219. console.log('[cropAndSaveImage] 图片路径已经是绝对路径:', absoluteImagePath);
  220. }
  221. let absoluteSavePath = savePath;
  222. if (!isAbsolute(savePath)) {
  223. // 如果是相对路径,需要判断是相对于工作流目录还是项目根目录
  224. // 优先检查 savePath 是否已经包含完整路径(以 static/processing/ 开头)
  225. if (savePath.startsWith('static/processing/')) {
  226. // savePath 已经是完整路径,如 "static/processing/微信聊天自动发送工作流/history/chat-area-cropped.png"
  227. // 直接拼接项目根目录即可,不需要再次提取工作流目录
  228. absoluteSavePath = join(__dirname, '..', savePath);
  229. console.log('[cropAndSaveImage] savePath 已包含完整路径,直接拼接项目根目录');
  230. } else if (imagePath.includes('static/processing/')) {
  231. // savePath 是相对于工作流目录的,如 "history/chat-area-cropped.png"
  232. // 从 imagePath 提取工作流目录路径
  233. const workflowMatch = imagePath.match(/static\/processing\/[^\/]+/);
  234. if (workflowMatch) {
  235. absoluteSavePath = join(__dirname, '..', workflowMatch[0], savePath);
  236. console.log('[cropAndSaveImage] savePath 相对于工作流目录,从 imagePath 提取工作流目录:', workflowMatch[0]);
  237. } else {
  238. // 如果无法匹配,尝试从 imagePath 提取工作流目录(去掉 /resources/ScreenShot.jpg)
  239. const workflowDir = imagePath.split('/resources/')[0];
  240. absoluteSavePath = join(__dirname, '..', workflowDir, savePath);
  241. console.log('[cropAndSaveImage] savePath 相对于工作流目录,从 imagePath 提取(备用方法):', workflowDir);
  242. }
  243. } else {
  244. // 默认相对于项目根目录
  245. absoluteSavePath = join(__dirname, '..', savePath);
  246. console.log('[cropAndSaveImage] savePath 相对于项目根目录');
  247. }
  248. console.log('[cropAndSaveImage] 保存路径转换,原始:', savePath, '-> 绝对:', absoluteSavePath);
  249. } else {
  250. console.log('[cropAndSaveImage] 保存路径已经是绝对路径:', absoluteSavePath);
  251. }
  252. console.log('[cropAndSaveImage] 最终路径:', { absoluteImagePath, absoluteSavePath });
  253. // 构建Python脚本代码
  254. // 使用 pathlib.Path 来处理路径,确保中文路径正确
  255. // 将 Windows 路径转换为 Python 可以处理的格式
  256. const normalizedImagePath = absoluteImagePath.replace(/\\/g, '/');
  257. const normalizedSavePath = absoluteSavePath.replace(/\\/g, '/');
  258. // 转义路径中的特殊字符,确保 Python 字符串正确
  259. const escapedImagePath = normalizedImagePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  260. const escapedSavePath = normalizedSavePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  261. const pythonCode = `
  262. # -*- coding: utf-8 -*-
  263. import sys
  264. import cv2
  265. import os
  266. from pathlib import Path
  267. # 立即输出,确认脚本开始执行
  268. print("Python脚本开始执行", file=sys.stderr)
  269. sys.stderr.flush()
  270. # 使用 Path 对象来处理路径,确保中文路径正确
  271. try:
  272. image_path_obj = Path(r"${escapedImagePath}")
  273. save_path_obj = Path(r"${escapedSavePath}")
  274. print(f"Path对象创建成功", file=sys.stderr)
  275. sys.stderr.flush()
  276. except Exception as e:
  277. print(f"创建Path对象失败: {e}", file=sys.stderr)
  278. sys.stderr.flush()
  279. sys.exit(1)
  280. # 转换为字符串(Path 会自动处理编码)
  281. image_path = str(image_path_obj)
  282. save_path = str(save_path_obj)
  283. x = ${x}
  284. y = ${y}
  285. w = ${width}
  286. h = ${height}
  287. print(f"图片路径: {image_path}", file=sys.stderr)
  288. sys.stderr.flush()
  289. print(f"保存路径: {save_path}", file=sys.stderr)
  290. sys.stderr.flush()
  291. print(f"裁剪参数: x={x}, y={y}, w={w}, h={h}", file=sys.stderr)
  292. sys.stderr.flush()
  293. try:
  294. # 检查图片文件是否存在
  295. print(f"检查图片文件: {image_path_obj}", file=sys.stderr)
  296. sys.stderr.flush()
  297. print(f"图片文件是否存在: {image_path_obj.exists()}", file=sys.stderr)
  298. sys.stderr.flush()
  299. if not image_path_obj.exists():
  300. print(f"错误: 图片文件不存在: {image_path}", file=sys.stderr)
  301. sys.stderr.flush()
  302. # 检查父目录是否存在
  303. parent = image_path_obj.parent
  304. print(f"父目录: {parent}", file=sys.stderr)
  305. sys.stderr.flush()
  306. print(f"父目录是否存在: {parent.exists()}", file=sys.stderr)
  307. sys.stderr.flush()
  308. if parent.exists():
  309. try:
  310. files = list(parent.iterdir())
  311. print(f"父目录中的文件: {[str(f.name) for f in files]}", file=sys.stderr)
  312. sys.stderr.flush()
  313. except Exception as e:
  314. print(f"列出父目录内容失败: {e}", file=sys.stderr)
  315. sys.stderr.flush()
  316. sys.exit(1)
  317. # 读取图片
  318. print(f"开始读取图片: {image_path}", file=sys.stderr)
  319. img = cv2.imread(image_path)
  320. if img is None:
  321. print(f"错误: 无法读取图片 {image_path}", file=sys.stderr)
  322. print(f"cv2.imread 返回 None,可能是文件格式不支持或文件损坏", file=sys.stderr)
  323. sys.exit(1)
  324. print(f"图片读取成功,尺寸: {img.shape}", file=sys.stderr)
  325. # 验证坐标是否在图片范围内
  326. img_height, img_width = img.shape[:2]
  327. if x < 0 or y < 0 or x + w > img_width or y + h > img_height:
  328. print(f"错误: 裁剪区域超出图片范围。图片尺寸: {img_width}x{img_height}, 裁剪区域: x={x}, y={y}, w={w}, h={h}", file=sys.stderr)
  329. sys.exit(1)
  330. # 裁剪区域
  331. cropped = img[y:y+h, x:x+w]
  332. print(f"裁剪成功,裁剪后尺寸: {cropped.shape}", file=sys.stderr)
  333. # 确保保存目录存在
  334. save_dir = save_path_obj.parent
  335. save_dir.mkdir(parents=True, exist_ok=True)
  336. print(f"保存目录已创建/存在: {save_dir}", file=sys.stderr)
  337. # 保存裁剪后的图片
  338. success = cv2.imwrite(str(save_path), cropped)
  339. print(f"cv2.imwrite 返回值: {success}", file=sys.stderr)
  340. if not success:
  341. print(f"错误: cv2.imwrite 返回 False,保存失败", file=sys.stderr)
  342. print(f"尝试保存到: {save_path}", file=sys.stderr)
  343. print(f"保存路径类型: {type(save_path)}", file=sys.stderr)
  344. sys.exit(1)
  345. # 验证文件是否真的保存了
  346. print(f"检查文件是否存在: {save_path_obj}", file=sys.stderr)
  347. print(f"文件是否存在: {save_path_obj.exists()}", file=sys.stderr)
  348. if save_path_obj.exists():
  349. file_size = save_path_obj.stat().st_size
  350. print(f"成功保存区域截图到: {save_path} (文件大小: {file_size} 字节)", file=sys.stderr)
  351. else:
  352. print(f"错误: 文件保存后不存在: {save_path}", file=sys.stderr)
  353. print(f"保存目录是否存在: {save_dir.exists()}", file=sys.stderr)
  354. print(f"保存目录路径: {save_dir}", file=sys.stderr)
  355. # 尝试列出目录内容
  356. try:
  357. if save_dir.exists():
  358. files = list(save_dir.iterdir())
  359. print(f"目录中的文件: {[str(f) for f in files]}", file=sys.stderr)
  360. except Exception as e:
  361. print(f"列出目录内容失败: {e}", file=sys.stderr)
  362. sys.exit(1)
  363. except Exception as e:
  364. print(f"裁剪图片失败: {e}", file=sys.stderr)
  365. import traceback
  366. traceback.print_exc(file=sys.stderr)
  367. sys.exit(1)
  368. `;
  369. // 获取Python可执行文件路径
  370. const pythonExePath = join(__dirname, '..', 'py', 'venv', 'Scripts', 'python.exe');
  371. console.log('[cropAndSaveImage] Python路径:', pythonExePath);
  372. console.log('[cropAndSaveImage] Python脚本代码长度:', pythonCode.length);
  373. // 执行Python脚本
  374. console.log('[cropAndSaveImage] 开始执行Python脚本...');
  375. console.log('[cropAndSaveImage] 命令:', `"${pythonExePath}" -c "..."`);
  376. try {
  377. const { stdout, stderr } = await execAsync(`"${pythonExePath}" -c "${pythonCode.replace(/"/g, '\\"')}"`, {
  378. maxBuffer: 10 * 1024 * 1024,
  379. encoding: 'utf8',
  380. cwd: join(__dirname, '..')
  381. });
  382. // 打印 stdout 和 stderr 以便调试(必须输出,无论是否为空)
  383. console.log('[cropAndSaveImage] Python 执行完成');
  384. console.log('[cropAndSaveImage] Python stdout 长度:', stdout ? stdout.length : 0);
  385. if (stdout) {
  386. console.log('[cropAndSaveImage] Python stdout:', stdout);
  387. } else {
  388. console.log('[cropAndSaveImage] Python stdout: (空)');
  389. }
  390. console.log('[cropAndSaveImage] Python stderr 长度:', stderr ? stderr.length : 0);
  391. if (stderr) {
  392. console.log('[cropAndSaveImage] Python stderr:', stderr);
  393. } else {
  394. console.log('[cropAndSaveImage] Python stderr: (空)');
  395. }
  396. // 检查是否有错误(Python脚本通过 sys.exit(1) 退出时,execAsync 会抛出错误)
  397. // 但如果脚本正常退出,stderr 可能包含我们的调试信息
  398. if (stderr && !stderr.includes('成功') && !stderr.includes('图片路径') && !stderr.includes('保存路径') && !stderr.includes('裁剪参数') && !stderr.includes('图片读取成功') && !stderr.includes('裁剪成功') && !stderr.includes('保存目录')) {
  399. // 如果 stderr 包含错误信息(不是我们的调试信息),可能是错误
  400. if (!stderr.includes('成功保存')) {
  401. console.error('[cropAndSaveImage] Python执行可能出错:', stderr);
  402. // 不抛出错误,让后续验证文件是否存在
  403. }
  404. }
  405. // 验证文件是否真的保存了
  406. console.log('[cropAndSaveImage] 检查文件是否存在:', absoluteSavePath);
  407. // 检查截图文件是否存在(用于调试)
  408. console.log('[cropAndSaveImage] 检查截图文件是否存在:', absoluteImagePath);
  409. const screenshotExists = fs.existsSync(absoluteImagePath);
  410. console.log('[cropAndSaveImage] 截图文件存在:', screenshotExists);
  411. if (!screenshotExists) {
  412. console.error('[cropAndSaveImage] ⚠️ 截图文件不存在,Python脚本可能无法执行');
  413. }
  414. if (fs.existsSync(absoluteSavePath)) {
  415. const stats = fs.statSync(absoluteSavePath);
  416. console.log('[cropAndSaveImage] ✅ 文件保存成功,路径:', absoluteSavePath, '大小:', stats.size, '字节');
  417. return { success: true };
  418. } else {
  419. console.error('[cropAndSaveImage] ❌ 文件保存失败,文件不存在:', absoluteSavePath);
  420. // 检查父目录是否存在
  421. const parentDir = dirname(absoluteSavePath);
  422. const parentExists = fs.existsSync(parentDir);
  423. console.error('[cropAndSaveImage] 父目录是否存在:', parentDir, '->', parentExists);
  424. if (!parentExists) {
  425. console.error('[cropAndSaveImage] 父目录不存在,可能是路径问题或Python脚本未创建目录');
  426. }
  427. // 构建详细的错误信息
  428. let errorMsg = `文件保存失败,文件不存在: ${absoluteSavePath}`;
  429. if (!screenshotExists) {
  430. errorMsg += `\n截图文件不存在: ${absoluteImagePath}`;
  431. }
  432. if (!parentExists) {
  433. errorMsg += `\n保存目录不存在: ${parentDir}`;
  434. }
  435. if (stderr) {
  436. errorMsg += `\nPython stderr: ${stderr}`;
  437. } else {
  438. errorMsg += `\nPython stderr: (空) - 可能Python脚本未执行或执行失败`;
  439. }
  440. if (stdout) {
  441. errorMsg += `\nPython stdout: ${stdout}`;
  442. }
  443. return { success: false, error: errorMsg };
  444. }
  445. } catch (execError) {
  446. // execAsync 执行失败(Python脚本返回非0退出码)
  447. console.error('[cropAndSaveImage] ❌ Python脚本执行失败:', execError);
  448. console.error('[cropAndSaveImage] 错误类型:', execError.constructor.name);
  449. console.error('[cropAndSaveImage] 错误消息:', execError.message);
  450. if (execError.code !== undefined) {
  451. console.error('[cropAndSaveImage] 退出码:', execError.code);
  452. }
  453. if (execError.stdout) {
  454. console.error('[cropAndSaveImage] Python stdout:', execError.stdout);
  455. } else {
  456. console.error('[cropAndSaveImage] Python stdout: (空)');
  457. }
  458. if (execError.stderr) {
  459. console.error('[cropAndSaveImage] Python stderr:', execError.stderr);
  460. } else {
  461. console.error('[cropAndSaveImage] Python stderr: (空)');
  462. }
  463. return { success: false, error: execError.stderr || execError.message || 'Python脚本执行失败' };
  464. }
  465. } catch (error) {
  466. console.error('[cropAndSaveImage] 裁剪图片失败:', error);
  467. console.error('[cropAndSaveImage] 错误堆栈:', error.stack);
  468. return { success: false, error: error.message };
  469. }
  470. }
  471. /**
  472. * 执行文字识别:截图、调用 Python 脚本、返回坐标
  473. * @param {string} ipPort - 设备 ID/IP:Port
  474. * @param {string} targetText - 目标文字
  475. * @returns {Promise<{success: boolean, error?: string, coordinate?: Object, clickPosition?: Object}>}
  476. */
  477. export async function findTextAndGetCoordinate(ipPort, targetText) {
  478. try {
  479. if (!ipPort) {
  480. return { success: false, error: '缺少设备 ID' };
  481. }
  482. if (!targetText) {
  483. return { success: false, error: '缺少目标文字' };
  484. }
  485. // 1. 获取设备分辨率
  486. const resolutionResult = await getDeviceResolution(ipPort);
  487. if (!resolutionResult.success) {
  488. return { success: false, error: '获取设备分辨率失败' };
  489. }
  490. const { width, height } = resolutionResult;
  491. // 2. 获取屏幕截图
  492. const screenshotResult = await captureScreenshot(ipPort, { format: 'png' });
  493. if (!screenshotResult.success || !screenshotResult.data) {
  494. return { success: false, error: '获取屏幕截图失败' };
  495. }
  496. // 3. 保存截图到临时文件
  497. const tempDir = join(__dirname, '..');
  498. const screenshotPath = join(tempDir, 'temp_screenshot.png');
  499. const screenshotBuffer = Buffer.from(screenshotResult.data, 'base64');
  500. await writeFile(screenshotPath, screenshotBuffer);
  501. // 4. 调用 JS 函数进行文字识别
  502. const textResult = await findTextLocation(screenshotPath, targetText, width, height);
  503. if (!textResult.success || !textResult.found) {
  504. return { success: false, error: textResult.error || `未找到文字: ${targetText}` };
  505. }
  506. // 5. 返回识别结果
  507. const { x, y, width: w, height: h } = textResult;
  508. // 计算点击位置(中心点)
  509. const clickX = Math.round(x + w / 2);
  510. const clickY = Math.round(y + h / 2);
  511. return {
  512. success: true,
  513. coordinate: { x, y, width: w, height: h },
  514. clickPosition: { x: clickX, y: clickY }
  515. };
  516. } catch (error) {
  517. console.error('文字识别失败:', error);
  518. // 如果是超时错误,提供更友好的提示
  519. if (error.message && error.message.includes('timeout')) {
  520. return { success: false, error: '文字识别超时,请检查网络连接或稍后重试' };
  521. }
  522. return { success: false, error: error.message };
  523. }
  524. }
  525. /**
  526. * 全屏OCR识别(通用功能)
  527. * @param {string} ipPort - 设备 ID/IP:Port
  528. * @param {string} folderPath - 工作流文件夹路径(可选,用于保存临时文件)
  529. * @returns {Promise<{success: boolean, error?: string, text?: string}>}
  530. */
  531. export async function ocrFullScreen(ipPort, folderPath = null) {
  532. try {
  533. if (!ipPort) {
  534. return { success: false, error: '缺少设备 ID' };
  535. }
  536. // 1. 获取设备分辨率
  537. const resolutionResult = await getDeviceResolution(ipPort);
  538. if (!resolutionResult.success) {
  539. return { success: false, error: '获取设备分辨率失败' };
  540. }
  541. const { width, height } = resolutionResult;
  542. // 2. 获取屏幕截图
  543. const screenshotResult = await captureScreenshot(ipPort, { format: 'png' });
  544. if (!screenshotResult.success || !screenshotResult.data) {
  545. return { success: false, error: '获取屏幕截图失败' };
  546. }
  547. // 3. 保存截图到临时文件(如果提供了工作流文件夹,保存到 tmp/时间戳 目录)
  548. let screenshotPath;
  549. let tmpDir = null; // 用于跟踪需要删除的临时目录
  550. if (folderPath) {
  551. // 确保 folderPath 是绝对路径
  552. let absoluteFolderPath = folderPath;
  553. if (!isAbsolute(folderPath)) {
  554. // 如果已经是 static/processing/xxx 格式,去掉开头的 static/processing 再拼接
  555. if (folderPath.startsWith('static/processing/')) {
  556. const folderName = folderPath.replace('static/processing/', '');
  557. absoluteFolderPath = join(__dirname, '..', 'static', 'processing', folderName);
  558. } else if (folderPath.startsWith('static\\processing\\')) {
  559. const folderName = folderPath.replace('static\\processing\\', '');
  560. absoluteFolderPath = join(__dirname, '..', 'static', 'processing', folderName);
  561. } else {
  562. // 如果只是文件夹名,需要加上 static/processing
  563. absoluteFolderPath = join(__dirname, '..', 'static', 'processing', folderPath);
  564. }
  565. }
  566. const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19).replace('T', '_');
  567. tmpDir = join(absoluteFolderPath, 'tmp', timestamp);
  568. await mkdir(tmpDir, { recursive: true });
  569. screenshotPath = join(tmpDir, 'screenshot_ocr.png');
  570. } else {
  571. const tempDir = join(__dirname, '..');
  572. screenshotPath = join(tempDir, 'temp_screenshot_ocr.png');
  573. }
  574. const screenshotBuffer = Buffer.from(screenshotResult.data, 'base64');
  575. await writeFile(screenshotPath, screenshotBuffer);
  576. try {
  577. // 4. 调用 JS 实现进行全屏OCR识别
  578. const normalizedScreenshotPath = screenshotPath.replace(/\\/g, '/');
  579. const result = await ocrFullScreenFromFunc(normalizedScreenshotPath, width, height);
  580. if (result.success) {
  581. return {
  582. success: true,
  583. text: result.text || ''
  584. };
  585. } else {
  586. return { success: false, error: result.error || 'OCR识别失败' };
  587. }
  588. } finally {
  589. // 5. 使用完后,先判断 tmp 目录总大小是否超过 20MB,再决定是否删除临时目录
  590. if (tmpDir) {
  591. try {
  592. // 获取 tmp 目录的父目录(工作流目录下的 tmp 文件夹)
  593. const tmpParentDir = dirname(tmpDir);
  594. // 检查 tmp 目录下所有文件和子目录的总大小
  595. let totalSize = 0;
  596. const dirsToDelete = [];
  597. try {
  598. const entries = await readdir(tmpParentDir, { withFileTypes: true });
  599. for (const entry of entries) {
  600. const entryPath = join(tmpParentDir, entry.name);
  601. try {
  602. if (entry.isFile()) {
  603. const stats = await stat(entryPath);
  604. totalSize += stats.size;
  605. } else if (entry.isDirectory()) {
  606. // 递归计算子目录大小
  607. const dirSize = await calculateDirSize(entryPath);
  608. totalSize += dirSize;
  609. const dirStats = await stat(entryPath);
  610. dirsToDelete.push({ path: entryPath, size: dirSize, mtime: dirStats.mtime });
  611. }
  612. } catch (e) {
  613. // 忽略无法访问的文件
  614. }
  615. }
  616. } catch (e) {
  617. // 如果无法读取目录,直接删除临时目录
  618. await rm(tmpDir, { recursive: true, force: true });
  619. return;
  620. }
  621. const maxSize = 20 * 1024 * 1024; // 20MB
  622. // 如果总大小超过 20MB,删除时间最早的目录
  623. if (totalSize > maxSize) {
  624. // 按修改时间排序(最早的在前)
  625. dirsToDelete.sort((a, b) => a.mtime - b.mtime);
  626. // 删除最早的目录直到总大小小于 20MB
  627. for (const dirInfo of dirsToDelete) {
  628. if (totalSize <= maxSize) {
  629. break;
  630. }
  631. try {
  632. await rm(dirInfo.path, { recursive: true, force: true });
  633. totalSize -= dirInfo.size;
  634. } catch (e) {
  635. // 忽略删除失败
  636. }
  637. }
  638. }
  639. // 如果临时目录仍然存在且总大小未超过限制,也删除它(保持原有行为)
  640. try {
  641. const tmpDirExists = await stat(tmpDir).then(() => true).catch(() => false);
  642. if (tmpDirExists) {
  643. await rm(tmpDir, { recursive: true, force: true });
  644. }
  645. } catch (rmError) {
  646. // 忽略删除失败
  647. }
  648. } catch (error) {
  649. // 清理失败不影响主流程
  650. }
  651. }
  652. }
  653. } catch (error) {
  654. console.error('OCR识别失败:', error);
  655. if (error.message && error.message.includes('timeout')) {
  656. return { success: false, error: 'OCR识别超时,请检查网络连接或稍后重试' };
  657. }
  658. return { success: false, error: error.message };
  659. }
  660. }
  661. /**
  662. * OCR识别最后一条消息(兼容旧API)
  663. * @param {string} ipPort - 设备 ID/IP:Port
  664. * @param {string} method - 识别方法 ('full-screen' | 'by-avatar')
  665. * @param {string} avatarPath - 头像路径(by-avatar 时使用)
  666. * @param {string} area - 区域(未使用,保留兼容性)
  667. * @param {string} folderPath - 工作流文件夹路径(可选)
  668. * @returns {Promise<{success: boolean, error?: string, text?: string, position?: Object}>}
  669. */
  670. export async function ocrLastMessage(ipPort, method, avatarPath, area, folderPath = null) {
  671. try {
  672. if (!ipPort) {
  673. return { success: false, error: '缺少设备 ID' };
  674. }
  675. // 1. 获取设备分辨率
  676. const resolutionResult = await getDeviceResolution(ipPort);
  677. if (!resolutionResult.success) {
  678. return { success: false, error: '获取设备分辨率失败' };
  679. }
  680. const { width, height } = resolutionResult;
  681. // 2. 获取屏幕截图
  682. const screenshotResult = await captureScreenshot(ipPort, { format: 'png' });
  683. if (!screenshotResult.success || !screenshotResult.data) {
  684. return { success: false, error: '获取屏幕截图失败' };
  685. }
  686. // 3. 保存截图到临时文件(如果提供了工作流文件夹,保存到 tmp/时间戳 目录)
  687. let screenshotPath;
  688. let tmpDir = null; // 用于跟踪需要删除的临时目录
  689. if (folderPath) {
  690. // 确保 folderPath 是绝对路径
  691. let absoluteFolderPath = folderPath;
  692. if (!isAbsolute(folderPath)) {
  693. // 如果已经是 static/processing/xxx 格式,去掉开头的 static/processing 再拼接
  694. if (folderPath.startsWith('static/processing/')) {
  695. const folderName = folderPath.replace('static/processing/', '');
  696. absoluteFolderPath = join(__dirname, '..', 'static', 'processing', folderName);
  697. } else if (folderPath.startsWith('static\\processing\\')) {
  698. const folderName = folderPath.replace('static\\processing\\', '');
  699. absoluteFolderPath = join(__dirname, '..', 'static', 'processing', folderName);
  700. } else {
  701. // 如果只是文件夹名,需要加上 static/processing
  702. absoluteFolderPath = join(__dirname, '..', 'static', 'processing', folderPath);
  703. }
  704. }
  705. const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19).replace('T', '_');
  706. tmpDir = join(absoluteFolderPath, 'tmp', timestamp);
  707. await mkdir(tmpDir, { recursive: true });
  708. screenshotPath = join(tmpDir, 'screenshot_ocr.png');
  709. } else {
  710. const tempDir = join(__dirname, '..');
  711. screenshotPath = join(tempDir, 'temp_screenshot_ocr.png');
  712. }
  713. const screenshotBuffer = Buffer.from(screenshotResult.data, 'base64');
  714. await writeFile(screenshotPath, screenshotBuffer);
  715. try {
  716. // 4. 调用 JS 实现进行OCR识别
  717. const normalizedScreenshotPath = screenshotPath.replace(/\\/g, '/');
  718. let result;
  719. if (method === 'full-screen') {
  720. // 全屏OCR识别
  721. result = await ocrFullScreenFromFunc(normalizedScreenshotPath, width, height);
  722. } else if (method === 'by-avatar' && avatarPath) {
  723. // 通过头像定位最后一条消息
  724. let friendAvatarArg = null;
  725. let myAvatarArg = null;
  726. if (isAbsolute(avatarPath)) {
  727. friendAvatarArg = avatarPath;
  728. myAvatarArg = avatarPath;
  729. } else {
  730. const folderName = avatarPath.split(/[/\\]/)[0];
  731. const avatarName = avatarPath.split(/[/\\]/).slice(1).join('/');
  732. friendAvatarArg = join(__dirname, '..', 'static', 'processing', folderName, avatarName);
  733. myAvatarArg = friendAvatarArg;
  734. }
  735. const normalizedFriendAvatar = friendAvatarArg.replace(/\\/g, '/');
  736. const normalizedMyAvatar = myAvatarArg.replace(/\\/g, '/');
  737. result = await getLastMessageFromFunc(normalizedScreenshotPath, normalizedFriendAvatar, normalizedMyAvatar, width, height);
  738. } else {
  739. // 默认使用全屏OCR
  740. result = await ocrFullScreenFromFunc(normalizedScreenshotPath, width, height);
  741. }
  742. if (result.success) {
  743. // 返回兼容旧API的格式
  744. return {
  745. success: true,
  746. text: result.text || '',
  747. position: result.position || null
  748. };
  749. } else {
  750. return { success: false, error: result.error || 'OCR识别失败' };
  751. }
  752. } finally {
  753. // 5. 使用完后,先判断 tmp 目录总大小是否超过 20MB,再决定是否删除临时目录
  754. if (tmpDir) {
  755. try {
  756. // 获取 tmp 目录的父目录(工作流目录下的 tmp 文件夹)
  757. const tmpParentDir = dirname(tmpDir);
  758. // 检查 tmp 目录下所有文件和子目录的总大小
  759. let totalSize = 0;
  760. const dirsToDelete = [];
  761. try {
  762. const entries = await readdir(tmpParentDir, { withFileTypes: true });
  763. for (const entry of entries) {
  764. const entryPath = join(tmpParentDir, entry.name);
  765. try {
  766. if (entry.isFile()) {
  767. const stats = await stat(entryPath);
  768. totalSize += stats.size;
  769. } else if (entry.isDirectory()) {
  770. // 递归计算子目录大小
  771. const dirSize = await calculateDirSize(entryPath);
  772. totalSize += dirSize;
  773. const dirStats = await stat(entryPath);
  774. dirsToDelete.push({ path: entryPath, size: dirSize, mtime: dirStats.mtime });
  775. }
  776. } catch (e) {
  777. // 忽略无法访问的文件
  778. }
  779. }
  780. } catch (e) {
  781. // 如果无法读取目录,直接删除临时目录
  782. await rm(tmpDir, { recursive: true, force: true });
  783. return;
  784. }
  785. const maxSize = 20 * 1024 * 1024; // 20MB
  786. // 如果总大小超过 20MB,删除时间最早的目录
  787. if (totalSize > maxSize) {
  788. // 按修改时间排序(最早的在前)
  789. dirsToDelete.sort((a, b) => a.mtime - b.mtime);
  790. // 删除最早的目录直到总大小小于 20MB
  791. for (const dirInfo of dirsToDelete) {
  792. if (totalSize <= maxSize) {
  793. break;
  794. }
  795. try {
  796. await rm(dirInfo.path, { recursive: true, force: true });
  797. totalSize -= dirInfo.size;
  798. } catch (e) {
  799. // 忽略删除失败
  800. }
  801. }
  802. }
  803. // 如果临时目录仍然存在且总大小未超过限制,也删除它(保持原有行为)
  804. try {
  805. const tmpDirExists = await stat(tmpDir).then(() => true).catch(() => false);
  806. if (tmpDirExists) {
  807. await rm(tmpDir, { recursive: true, force: true });
  808. }
  809. } catch (rmError) {
  810. // 忽略删除失败
  811. }
  812. } catch (error) {
  813. // 清理失败不影响主流程
  814. }
  815. }
  816. }
  817. } catch (error) {
  818. console.error('OCR识别失败:', error);
  819. if (error.message && error.message.includes('timeout')) {
  820. return { success: false, error: 'OCR识别超时,请检查网络连接或稍后重试' };
  821. }
  822. return { success: false, error: error.message };
  823. }
  824. }
  825. /**
  826. * 注册 IPC 处理器(Python 执行相关)
  827. */
  828. export function registerIpcHandlers() {
  829. // 图像匹配
  830. ipcMain.handle('match-image-and-get-coordinate', async (event, ipPort, templateImagePath) => {
  831. return await matchImageAndGetCoordinate(ipPort, templateImagePath);
  832. });
  833. // 图像区域定位
  834. ipcMain.handle('match-image-region-location', async (event, screenshotPath, regionPath, device) => {
  835. return await matchImageRegionLocation(screenshotPath, regionPath, device);
  836. });
  837. ipcMain.handle('crop-and-save-image', async (event, imagePath, x, y, width, height, savePath) => {
  838. console.log('[IPC] crop-and-save-image 收到请求:', { imagePath, x, y, width, height, savePath });
  839. const result = await cropAndSaveImage(imagePath, x, y, width, height, savePath);
  840. console.log('[IPC] crop-and-save-image 返回结果:', result);
  841. return result;
  842. });
  843. // 文字识别
  844. ipcMain.handle('find-text-and-get-coordinate', async (event, ipPort, targetText) => {
  845. return await findTextAndGetCoordinate(ipPort, targetText);
  846. });
  847. // OCR识别最后一条消息(兼容旧API)
  848. ipcMain.handle('ocr-last-message', async (event, ipPort, method, avatarPath, area, folderPath) => {
  849. return await ocrLastMessage(ipPort, method, avatarPath, area, folderPath);
  850. });
  851. }