PyClickerRPA 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
autoclicker/runner.py ADDED
@@ -0,0 +1,666 @@
1
+ """Task execution engine — faithful port of taskrunner.cpp.
2
+
3
+ Qt-free: runs on a ``threading.Thread`` with a callbacks object and injectable
4
+ capture / clicker / matcher backends (so control flow is unit-testable without a
5
+ screen). Semantics mirror the C++ TaskRunner exactly: variables, loop/goto/
6
+ while/do-until/if-skip control flow, retry/repeat/until wrappers, subtasks.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import random
12
+ import threading
13
+ import time
14
+ from enum import Enum
15
+
16
+ from autoclicker.models import Step, StepType, Task, UntilCondition, RetryPolicy
17
+
18
+
19
+ class StepResult(Enum):
20
+ Success = 0
21
+ Fail = 1
22
+ Abort = 2
23
+
24
+
25
+ class RunnerCallbacks:
26
+ """Override to receive engine events (all no-ops by default)."""
27
+ def step_started(self, index: int, description: str) -> None: ...
28
+ def step_completed(self, index: int, success: bool) -> None: ...
29
+ def match_found(self, x: int, y: int, confidence: float) -> None: ...
30
+ def log(self, msg: str) -> None: ...
31
+ def var_changed(self, name: str, value: str) -> None: ...
32
+ def finished(self) -> None: ...
33
+
34
+
35
+ def _now_ms() -> float:
36
+ return time.monotonic() * 1000.0
37
+
38
+
39
+ class TaskRunner(threading.Thread):
40
+ def __init__(self, task: Task, target_hwnd=None, callbacks: RunnerCallbacks | None = None,
41
+ capture=None, clicker=None, matcher=None):
42
+ super().__init__(daemon=True)
43
+ self.task = task
44
+ self.target_hwnd = target_hwnd
45
+ self.cb = callbacks or RunnerCallbacks()
46
+
47
+ # Injectable backends (default to the real ones; tests pass fakes)
48
+ if capture is None:
49
+ from autoclicker import capture as capture
50
+ if clicker is None:
51
+ from autoclicker.clicker import Clicker
52
+ clicker = Clicker()
53
+ if matcher is None:
54
+ from autoclicker import matcher as matcher
55
+ self.capture = capture
56
+ self.clicker = clicker
57
+ self.matcher = matcher
58
+
59
+ self._running = False
60
+ self._paused = False
61
+ self._loop_counters: dict[int, int] = {}
62
+ self._vars: dict[str, str] = {}
63
+ self._last_condition = False
64
+
65
+ # Optional: save a screenshot whenever a step fails (debugging aid)
66
+ self.screenshot_on_fail = False
67
+ self.screenshot_dir = ".failures"
68
+
69
+ # ---- control ----
70
+ def stop(self) -> None:
71
+ self._running = False
72
+
73
+ def pause(self) -> None:
74
+ self._paused = True
75
+
76
+ def resume(self) -> None:
77
+ self._paused = False
78
+
79
+ def get_var(self, name: str) -> str:
80
+ return self._vars.get(name, "")
81
+
82
+ # ---- helpers ----
83
+ def _sleep_ms(self, ms: int) -> None:
84
+ """Sleep in 100ms chunks, bailing out early if stopped."""
85
+ elapsed = 0
86
+ while self._running and elapsed < ms:
87
+ time.sleep(0.1)
88
+ elapsed += 100
89
+
90
+ def _wait_if_paused(self) -> None:
91
+ while self._paused and self._running:
92
+ time.sleep(0.1)
93
+
94
+ def _template_list(self, step: Step) -> list[str]:
95
+ if step.imagePaths:
96
+ return list(step.imagePaths)
97
+ if step.imagePath:
98
+ return [step.imagePath]
99
+ return []
100
+
101
+ def _screenshot(self):
102
+ if self.target_hwnd:
103
+ try:
104
+ import win32gui
105
+ alive = win32gui.IsWindow(self.target_hwnd)
106
+ except Exception:
107
+ alive = True
108
+ if alive:
109
+ return self.capture.capture_window(self.target_hwnd)
110
+ return self.capture.capture_screen()
111
+
112
+ def _save_failure_shot(self, index: int) -> None:
113
+ try:
114
+ import cv2
115
+ screen = self._screenshot()
116
+ if screen is None or screen.size == 0:
117
+ return
118
+ os.makedirs(self.screenshot_dir, exist_ok=True)
119
+ path = os.path.join(self.screenshot_dir, f"fail_step{index + 1}_{int(time.time())}.png")
120
+ cv2.imwrite(path, screen)
121
+ self.cb.log(f" 已保存失败截图: {path}")
122
+ except Exception as e:
123
+ self.cb.log(f" 保存失败截图出错: {e}")
124
+
125
+ def _load_template(self, path: str, desc: str):
126
+ import cv2
127
+ if not path:
128
+ self.cb.log(f" 错误: 未指定图片路径 (步骤: {desc})")
129
+ return None
130
+ if not os.path.exists(path):
131
+ self.cb.log(f" 错误: 图片文件不存在: {path}\n 请检查路径是否正确,建议使用绝对路径")
132
+ return None
133
+ templ = cv2.imread(path)
134
+ if templ is None or templ.size == 0:
135
+ self.cb.log(f" 错误: 无法解码图片: {path}\n 文件存在但可能已损坏或格式不支持 (支持 PNG/JPG/BMP)")
136
+ return None
137
+ return templ
138
+
139
+ def _rand_offset(self, v: int, off: int) -> int:
140
+ return v + random.randint(-off, off) if off > 0 else v
141
+
142
+ # ---- run loop ----
143
+ def run(self) -> None:
144
+ self._running = True
145
+ self._loop_counters.clear()
146
+ self._vars.clear()
147
+ self._last_condition = False
148
+ self.cb.log(f"开始执行任务: {self.task.name} ({len(self.task.steps)} 步)")
149
+ self._run_steps()
150
+ self.cb.log("任务完成" if self._running else "任务已停止")
151
+ self._running = False
152
+ self.cb.finished()
153
+
154
+ def _run_steps(self, log_prefix: str = "") -> StepResult:
155
+ steps = self.task.steps
156
+ i = 0
157
+ while self._running and i < len(steps):
158
+ self._wait_if_paused()
159
+ if not self._running:
160
+ break
161
+ step = steps[i]
162
+
163
+ if step.type == StepType.GroupBegin and step.disabled:
164
+ depth = 1
165
+ skip = i + 1
166
+ while skip < len(steps) and depth > 0:
167
+ if steps[skip].type == StepType.GroupBegin:
168
+ depth += 1
169
+ elif steps[skip].type == StepType.GroupEnd:
170
+ depth -= 1
171
+ skip += 1
172
+ self.cb.log(f" 跳过禁用分组: {step.groupName}")
173
+ i = skip
174
+ continue
175
+
176
+ self.cb.step_started(i, log_prefix + step.display_name())
177
+ result = self._execute_with_repeat(i)
178
+ self.cb.step_completed(i, result == StepResult.Success)
179
+
180
+ if result != StepResult.Success and self.screenshot_on_fail:
181
+ self._save_failure_shot(i)
182
+
183
+ if result == StepResult.Abort:
184
+ self.cb.log(f"任务中止 (步骤 {i + 1} 失败)")
185
+ return StepResult.Abort
186
+ if not self._running:
187
+ break
188
+
189
+ if step.type == StepType.Loop:
190
+ key = i
191
+ self._loop_counters[key] = self._loop_counters.get(key, 0) + 1
192
+ if step.loopCount < 0 or self._loop_counters[key] < step.loopCount:
193
+ cnt = self._loop_counters[key]
194
+ i = step.loopTarget
195
+ self.cb.log(f" 循环回到步骤 {step.loopTarget + 1} (第 {cnt} 次)")
196
+ continue
197
+ else:
198
+ self._loop_counters.pop(key, None)
199
+
200
+ if step.type == StepType.DoUntil:
201
+ if not self._check_until(step.until):
202
+ self._loop_counters[i] = self._loop_counters.get(i, 0) + 1
203
+ cnt = self._loop_counters[i]
204
+ i = step.loopTarget
205
+ self.cb.log(f" 条件未满足,循环回到步骤 {step.loopTarget + 1} (第 {cnt} 次)")
206
+ continue
207
+ else:
208
+ self.cb.log(" 条件已满足,结束循环")
209
+ self._loop_counters.pop(i, None)
210
+
211
+ if step.type == StepType.WhileCondition:
212
+ met = False
213
+ if step.until.type != UntilCondition.NoneCond:
214
+ met = self._check_until(step.until)
215
+ elif step.varName:
216
+ met = self._compare(self._vars.get(step.varName, "0"), step.compareOp, step.compareValue)
217
+ if not met:
218
+ self.cb.log(f" While 条件为假,跳到步骤 {step.loopTarget + 1}")
219
+ i = step.loopTarget
220
+ continue
221
+ self.cb.log(" While 条件为真,继续执行")
222
+
223
+ if step.type == StepType.Goto:
224
+ self.cb.log(f" 跳转到步骤 {step.gotoTarget + 1}")
225
+ i = step.gotoTarget
226
+ continue
227
+
228
+ if step.type in (StepType.IfImageFound, StepType.IfTextFound, StepType.IfElse,
229
+ StepType.IfColorMatch, StepType.IfImageCount, StepType.IfVar):
230
+ if not self._last_condition:
231
+ self.cb.log(f" 条件为假,跳过 {step.elseSkip} 步")
232
+ i += step.elseSkip
233
+
234
+ i += 1
235
+ return StepResult.Success
236
+
237
+ # ---- wrappers ----
238
+ def _execute_with_repeat(self, index: int) -> StepResult:
239
+ step = self.task.steps[index]
240
+ has_repeat = step.repeat.times != 1
241
+ has_until = step.until.type != UntilCondition.NoneCond
242
+ if not has_repeat and not has_until:
243
+ return self._execute_with_retry(index)
244
+
245
+ start = _now_ms()
246
+ iteration = 0
247
+ last = StepResult.Success
248
+ while self._running:
249
+ last = self._execute_with_retry(index)
250
+ iteration += 1
251
+ if last == StepResult.Abort:
252
+ return StepResult.Abort
253
+ if has_until:
254
+ if self._check_until(step.until):
255
+ self.cb.log(f" 终止条件已满足 (第 {iteration} 次)")
256
+ return last
257
+ if _now_ms() - start >= step.until.timeoutMs:
258
+ self.cb.log(f" 终止条件超时 ({step.until.timeoutMs}ms)")
259
+ return last
260
+ if has_repeat and step.repeat.times > 0 and iteration >= step.repeat.times:
261
+ self.cb.log(f" 重复执行完成 ({iteration} 次)")
262
+ return last
263
+ self._wait_if_paused()
264
+ if not self._running:
265
+ break
266
+ self._sleep_ms(step.repeat.delayMs)
267
+ return last
268
+
269
+ def _execute_with_retry(self, index: int) -> StepResult:
270
+ step = self.task.steps[index]
271
+ policy = step.retry.policy
272
+ if policy in (RetryPolicy.NoneP, RetryPolicy.Skip):
273
+ r = self._execute_step(index)
274
+ if r == StepResult.Fail and policy == RetryPolicy.Skip:
275
+ self.cb.log(" 步骤失败,跳过")
276
+ return StepResult.Success
277
+ return r
278
+ if policy == RetryPolicy.Abort:
279
+ r = self._execute_step(index)
280
+ return StepResult.Abort if r == StepResult.Fail else r
281
+ # Retry
282
+ for attempt in range(step.retry.count + 1):
283
+ if not self._running:
284
+ return StepResult.Fail
285
+ if attempt > 0:
286
+ self.cb.log(f" 重试第 {attempt} 次...")
287
+ self._sleep_ms(step.retry.delayMs)
288
+ if self._execute_step(index) == StepResult.Success:
289
+ return StepResult.Success
290
+ self.cb.log(f" 重试 {step.retry.count} 次后仍失败")
291
+ return StepResult.Fail
292
+
293
+ def _compare(self, val: str, op: str, cmp: str) -> bool:
294
+ try:
295
+ a, b = float(val), float(cmp)
296
+ except ValueError:
297
+ a = b = None
298
+ if a is not None:
299
+ return {"==": a == b, "!=": a != b, ">": a > b,
300
+ "<": a < b, ">=": a >= b, "<=": a <= b}.get(op, False)
301
+ return {"==": val == cmp, "!=": val != cmp}.get(op, False)
302
+
303
+ def _check_until(self, cfg) -> bool:
304
+ import cv2
305
+ if cfg.type == UntilCondition.ImageFound:
306
+ templ = cv2.imread(cfg.image) if cfg.image else None
307
+ if templ is None:
308
+ return False
309
+ return bool(self.matcher.find_image(self._screenshot(), templ, cfg.threshold))
310
+ if cfg.type == UntilCondition.ImageGone:
311
+ templ = cv2.imread(cfg.image) if cfg.image else None
312
+ if templ is None:
313
+ return True
314
+ return not self.matcher.find_image(self._screenshot(), templ, cfg.threshold)
315
+ if cfg.type == UntilCondition.TextFound:
316
+ return bool(self.matcher.find_text(self._screenshot(), cfg.text))
317
+ if cfg.type == UntilCondition.ColorMatch:
318
+ screen = self._screenshot()
319
+ if screen.size == 0 or cfg.x >= screen.shape[1] or cfg.y >= screen.shape[0]:
320
+ return False
321
+ return self._color_at_matches(screen, cfg.x, cfg.y, cfg.color, cfg.colorTolerance)
322
+ return True
323
+
324
+ @staticmethod
325
+ def _parse_color(name: str):
326
+ """Parse #RRGGBB (or #RGB) into (r, g, b); None if invalid."""
327
+ if not name:
328
+ return None
329
+ s = name.strip().lstrip("#")
330
+ if len(s) == 3:
331
+ s = "".join(c * 2 for c in s)
332
+ if len(s) != 6:
333
+ return None
334
+ try:
335
+ return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
336
+ except ValueError:
337
+ return None
338
+
339
+ def _color_at_matches(self, screen, x: int, y: int, color: str, tol: int) -> bool:
340
+ rgb = self._parse_color(color)
341
+ if rgb is None:
342
+ return False
343
+ b, g, r = (int(v) for v in screen[y, x][:3]) # OpenCV is BGR
344
+ return abs(r - rgb[0]) <= tol and abs(g - rgb[1]) <= tol and abs(b - rgb[2]) <= tol
345
+
346
+ def _run_subtask(self, path: str) -> StepResult:
347
+ sub = Task.load_from_file(path) if os.path.exists(path) else Task()
348
+ if not sub.steps:
349
+ self.cb.log(f" 子任务加载失败: {path}")
350
+ return StepResult.Fail
351
+ self.cb.log(f" >>> 进入子任务: {sub.name}")
352
+ saved_task, saved_counters = self.task, self._loop_counters
353
+ self.task, self._loop_counters = sub, {}
354
+ result = self._run_steps("[子] ")
355
+ self.task, self._loop_counters = saved_task, saved_counters
356
+ self.cb.log(" <<< 子任务结束")
357
+ return result
358
+
359
+ # ---- per-step execution (port of executeStep) ----
360
+ def _execute_step(self, index: int) -> StepResult: # noqa: C901 - faithful 1:1 port
361
+ step = self.task.steps[index]
362
+ t = step.type
363
+
364
+ if t in (StepType.ClickImage, StepType.WaitImage, StepType.WaitImageGone, StepType.MergeImage):
365
+ return self._exec_image_poll(step, t)
366
+ if t == StepType.ClickText:
367
+ return self._exec_click_text(step)
368
+ if t == StepType.ClickPosition:
369
+ cx, cy = step.x, step.y
370
+ if step.varName == "use_var":
371
+ cx = int(self._vars.get("last_x", "0"))
372
+ cy = int(self._vars.get("last_y", "0"))
373
+ cx, cy = self._rand_offset(cx, step.randomOffset), self._rand_offset(cy, step.randomOffset)
374
+ self.cb.log(f" 点击 ({cx}, {cy})")
375
+ self.clicker.click(self.target_hwnd, cx, cy)
376
+ return StepResult.Success
377
+ if t == StepType.Wait:
378
+ self.cb.log(f" 等待 {step.waitMs}ms")
379
+ self._sleep_ms(step.waitMs)
380
+ return StepResult.Success
381
+ if t == StepType.RandomWait:
382
+ lo, hi = step.waitMs, step.waitMsMax
383
+ if hi <= lo:
384
+ hi = lo + 1
385
+ actual = random.randint(lo, hi - 1)
386
+ self.cb.log(f" 随机等待 {actual}ms (范围 {lo}~{hi})")
387
+ self._sleep_ms(actual)
388
+ return StepResult.Success
389
+ if t == StepType.ClickRelative:
390
+ bx = int(self._vars.get("last_x", "0")) + step.x
391
+ by = int(self._vars.get("last_y", "0")) + step.y
392
+ cx, cy = self._rand_offset(bx, step.randomOffset), self._rand_offset(by, step.randomOffset)
393
+ self.cb.log(f" 相对点击 last+({step.x},{step.y}) = ({cx}, {cy})")
394
+ self.clicker.click(self.target_hwnd, cx, cy)
395
+ return StepResult.Success
396
+ if t == StepType.ReadText:
397
+ return self._exec_read_text(step)
398
+ if t in (StepType.Loop, StepType.DoUntil, StepType.Goto,
399
+ StepType.GroupBegin, StepType.GroupEnd, StepType.WhileCondition):
400
+ return StepResult.Success
401
+ if t == StepType.IfImageFound:
402
+ return self._exec_if_image(step)
403
+ if t == StepType.IfTextFound:
404
+ self._last_condition = bool(self.matcher.find_text(self._screenshot(), step.text))
405
+ self.cb.log(f" 条件(文字): {'true' if self._last_condition else 'false'}")
406
+ return StepResult.Success
407
+ if t == StepType.IfElse:
408
+ self.cb.log(f" If/Else 分支 (上次条件: {'true' if self._last_condition else 'false'})")
409
+ return StepResult.Success
410
+ if t == StepType.CallSub:
411
+ if not step.subTaskPath:
412
+ self.cb.log(" 错误: 未指定子任务路径")
413
+ return StepResult.Fail
414
+ return self._run_subtask(step.subTaskPath)
415
+ if t == StepType.SetVar:
416
+ value = step.varValue
417
+ if value.startswith("++"):
418
+ value = str(int(self._vars.get(step.varName, "0")) + 1)
419
+ elif value.startswith("--"):
420
+ value = str(int(self._vars.get(step.varName, "0")) - 1)
421
+ self._vars[step.varName] = value
422
+ self.cb.var_changed(step.varName, value)
423
+ self.cb.log(f" 变量 {step.varName} = {value}")
424
+ return StepResult.Success
425
+ if t == StepType.KeyPress:
426
+ from autoclicker.clicker import parse_vk
427
+ vk = parse_vk(step.keyName)
428
+ if vk == 0:
429
+ self.cb.log(f' 错误: 无效按键 "{step.keyName}"')
430
+ return StepResult.Fail
431
+ self.cb.log(f" 按键: {step.keyName}")
432
+ self.clicker.key_press(vk)
433
+ return StepResult.Success
434
+ if t == StepType.TypeText:
435
+ self.cb.log(f' 输入文字: "{step.text}"')
436
+ self.clicker.type_text(step.text)
437
+ return StepResult.Success
438
+ if t == StepType.Hotkey:
439
+ from autoclicker.clicker import parse_vk
440
+ mod, vk = parse_vk(step.modifierKey), parse_vk(step.keyName)
441
+ if vk == 0:
442
+ self.cb.log(" 错误: 无效按键")
443
+ return StepResult.Fail
444
+ self.cb.log(f" 快捷键: {step.modifierKey}+{step.keyName}")
445
+ self.clicker.hotkey(mod, vk)
446
+ return StepResult.Success
447
+ if t == StepType.LongPress:
448
+ self.cb.log(f" 长按 ({step.x}, {step.y}) {step.holdMs}ms")
449
+ self.clicker.long_press(self.target_hwnd, step.x, step.y, step.holdMs)
450
+ return StepResult.Success
451
+ if t == StepType.Drag:
452
+ self.cb.log(f" 拖拽 ({step.x},{step.y}) → ({step.x2},{step.y2}) {step.durationMs}ms")
453
+ self.clicker.drag(self.target_hwnd, step.x, step.y, step.x2, step.y2, step.durationMs)
454
+ return StepResult.Success
455
+ if t == StepType.Pinch:
456
+ self.cb.log(f" 捏合 中心({step.x},{step.y}) {step.pinchStartDist}→{step.pinchEndDist}")
457
+ self.clicker.pinch(self.target_hwnd, step.x, step.y, step.pinchStartDist, step.pinchEndDist, step.durationMs)
458
+ return StepResult.Success
459
+ if t == StepType.Scroll:
460
+ self.cb.log(f" 滚轮 ({step.x},{step.y}) {step.scrollAmount}格")
461
+ self.clicker.scroll(self.target_hwnd, step.x, step.y, step.scrollAmount)
462
+ return StepResult.Success
463
+ if t == StepType.IfColorMatch:
464
+ screen = self._screenshot()
465
+ if screen.size == 0 or step.x >= screen.shape[1] or step.y >= screen.shape[0]:
466
+ self._last_condition = False
467
+ return StepResult.Success
468
+ self._last_condition = self._color_at_matches(screen, step.x, step.y, step.targetColor, step.colorTolerance)
469
+ self.cb.log(f" 条件(颜色): ({step.x},{step.y}) vs {step.targetColor} → "
470
+ f"{'true' if self._last_condition else 'false'}")
471
+ return StepResult.Success
472
+ if t == StepType.MultiDrag:
473
+ return self._exec_multidrag(step)
474
+ if t == StepType.IfImageCount:
475
+ return self._exec_if_image_count(step)
476
+ if t == StepType.IfVar:
477
+ val = self._vars.get(step.varName, "0")
478
+ self._last_condition = self._compare(val, step.compareOp, step.compareValue)
479
+ self.cb.log(f" 条件(变量): {step.varName}={val} {step.compareOp} {step.compareValue} → "
480
+ f"{'true' if self._last_condition else 'false'}")
481
+ return StepResult.Success
482
+ if t == StepType.ClickAll:
483
+ return self._exec_click_all(step)
484
+ return StepResult.Fail
485
+
486
+ # ---- step helpers ----
487
+ def _exec_image_poll(self, step: Step, t: StepType) -> StepResult:
488
+ templates = self._template_list(step)
489
+ if not templates:
490
+ self.cb.log(" 错误: 未指定图片路径")
491
+ return StepResult.Fail
492
+ templs = [x for x in (self._load_template(p, step.display_name()) for p in templates) if x is not None]
493
+ if not templs:
494
+ return StepResult.Fail
495
+
496
+ if t == StepType.MergeImage:
497
+ return self._exec_merge(step, templs)
498
+
499
+ start = _now_ms()
500
+ while self._running and _now_ms() - start < step.timeoutMs:
501
+ screen = self._screenshot()
502
+ if t == StepType.WaitImageGone:
503
+ any_found = any(self.matcher.find_image_roi(screen, tm, step.threshold, step.roiRect, step.multiScale) for tm in templs)
504
+ if not any_found:
505
+ self.cb.log(f" 图片已消失 ({int(_now_ms() - start)}ms)")
506
+ return StepResult.Success
507
+ else:
508
+ for tm in templs:
509
+ results = self.matcher.find_image_roi(screen, tm, step.threshold, step.roiRect, step.multiScale)
510
+ if results:
511
+ if t == StepType.WaitImage:
512
+ self.cb.log(f" 图片已出现 ({int(_now_ms() - start)}ms)")
513
+ return StepResult.Success
514
+ best = results[0]
515
+ cx = self._rand_offset(best.cx + step.x, step.randomOffset)
516
+ cy = self._rand_offset(best.cy + step.y, step.randomOffset)
517
+ self.cb.match_found(cx, cy, best.confidence)
518
+ self.cb.log(f" 匹配成功 ({cx}, {cy}) 置信度: {best.confidence:.3f}")
519
+ self.clicker.click(self.target_hwnd, cx, cy)
520
+ self._vars["last_x"] = str(cx)
521
+ self._vars["last_y"] = str(cy)
522
+ return StepResult.Success
523
+ self._sleep_ms(500)
524
+ self.cb.log(" 超时: 图片未出现" if t == StepType.WaitImage
525
+ else (" 超时: 图片仍存在" if t == StepType.WaitImageGone else " 超时: 未找到图片"))
526
+ return StepResult.Fail
527
+
528
+ def _exec_merge(self, step: Step, templs) -> StepResult:
529
+ merge_count = 0
530
+ start = _now_ms()
531
+ while self._running and _now_ms() - start < step.timeoutMs:
532
+ screen = self._screenshot()
533
+ all_m = []
534
+ for tm in templs:
535
+ all_m += self.matcher.find_image_roi(screen, tm, step.threshold, step.roiRect, step.multiScale)
536
+ if len(all_m) < 2:
537
+ self.cb.log(f" 合成完成: 剩余 {len(all_m)} 个目标, 共合成 {merge_count} 次")
538
+ return StepResult.Success
539
+ a, b = all_m[0], all_m[1]
540
+ self.cb.log(f" 合成拖拽: ({a.cx},{a.cy}) → ({b.cx},{b.cy}) [找到 {len(all_m)} 个]")
541
+ self.clicker.drag(self.target_hwnd, a.cx, a.cy, b.cx, b.cy, step.durationMs)
542
+ merge_count += 1
543
+ self._sleep_ms(step.mergeDelayMs)
544
+ self.cb.log(f" 合成超时, 已合成 {merge_count} 次")
545
+ return StepResult.Success if merge_count > 0 else StepResult.Fail
546
+
547
+ def _exec_click_text(self, step: Step) -> StepResult:
548
+ start = _now_ms()
549
+ first = True
550
+ while self._running and _now_ms() - start < step.timeoutMs:
551
+ screen = self._screenshot()
552
+ all_ocr = self.matcher.ocr_all(screen)
553
+ if first:
554
+ self.cb.log(f" OCR返回: {len(all_ocr)} 处文字")
555
+ first = False
556
+ results = self.matcher.match_text(all_ocr, step.text, True)
557
+ if results:
558
+ best = results[0]
559
+ cx = self._rand_offset(best.cx + step.x, step.randomOffset)
560
+ cy = self._rand_offset(best.cy + step.y, step.randomOffset)
561
+ self.cb.match_found(cx, cy, best.confidence)
562
+ self.cb.log(f' OCR匹配: "{best.text}" ({cx}, {cy})')
563
+ self.clicker.click(self.target_hwnd, cx, cy)
564
+ self._vars["last_x"] = str(best.cx)
565
+ self._vars["last_y"] = str(best.cy)
566
+ self._vars["last_text"] = best.text
567
+ return StepResult.Success
568
+ self._sleep_ms(500)
569
+ self.cb.log(f' 超时: 未找到文字 "{step.text}"')
570
+ return StepResult.Fail
571
+
572
+ def _exec_read_text(self, step: Step) -> StepResult:
573
+ import re
574
+ screen = self._screenshot()
575
+ region, _, _ = self.matcher.apply_roi(screen, step.roiRect)
576
+ texts = [r.text for r in self.matcher.ocr_all(region)]
577
+ if step.text:
578
+ texts = [t for t in texts if step.text in t]
579
+ joined = " ".join(texts)
580
+ value = joined
581
+ if step.varValue == "number":
582
+ m = re.search(r"-?\d+(?:\.\d+)?", joined)
583
+ value = m.group(0) if m else ""
584
+ if step.varName:
585
+ self._vars[step.varName] = value
586
+ self.cb.var_changed(step.varName, value)
587
+ self._vars["last_text"] = joined
588
+ self.cb.log(f' 读取文字→{step.varName} = "{value}"')
589
+ return StepResult.Success
590
+
591
+ def _exec_if_image(self, step: Step) -> StepResult:
592
+ templates = self._template_list(step)
593
+ if not templates:
594
+ self._last_condition = False
595
+ return StepResult.Success
596
+ screen = self._screenshot()
597
+ self._last_condition = False
598
+ for p in templates:
599
+ templ = self._load_template(p, step.display_name())
600
+ if templ is None:
601
+ continue
602
+ if self.matcher.find_image_roi(screen, templ, step.threshold, step.roiRect, step.multiScale):
603
+ self._last_condition = True
604
+ break
605
+ self.cb.log(f" 条件(图片): {'true' if self._last_condition else 'false'}")
606
+ return StepResult.Success
607
+
608
+ def _exec_if_image_count(self, step: Step) -> StepResult:
609
+ templates = self._template_list(step)
610
+ if not templates:
611
+ self._last_condition = False
612
+ return StepResult.Success
613
+ screen = self._screenshot()
614
+ total = 0
615
+ for p in templates:
616
+ templ = self._load_template(p, step.display_name())
617
+ if templ is None:
618
+ continue
619
+ total += len(self.matcher.find_image_roi(screen, templ, step.threshold, step.roiRect, step.multiScale))
620
+ self._last_condition = total >= step.minMatchCount
621
+ self.cb.log(f" 条件(图片数量): 找到 {total} 个, 需要≥{step.minMatchCount} → "
622
+ f"{'true' if self._last_condition else 'false'}")
623
+ return StepResult.Success
624
+
625
+ def _exec_click_all(self, step: Step) -> StepResult:
626
+ templates = self._template_list(step)
627
+ if not templates:
628
+ self.cb.log(" 错误: 未指定图片路径")
629
+ return StepResult.Fail
630
+ screen = self._screenshot()
631
+ all_m = []
632
+ for p in templates:
633
+ templ = self._load_template(p, step.display_name())
634
+ if templ is None:
635
+ continue
636
+ all_m += self.matcher.find_image_roi(screen, templ, step.threshold, step.roiRect, step.multiScale)
637
+ if not all_m:
638
+ self.cb.log(" 未找到任何匹配")
639
+ return StepResult.Fail
640
+ self.cb.log(f" 找到 {len(all_m)} 个匹配, 逐个点击")
641
+ for i, m in enumerate(all_m):
642
+ if not self._running:
643
+ break
644
+ cx = self._rand_offset(m.cx + step.x, step.randomOffset)
645
+ cy = self._rand_offset(m.cy + step.y, step.randomOffset)
646
+ self.cb.log(f" 点击 {i + 1}/{len(all_m)}: ({cx}, {cy})")
647
+ self.clicker.click(self.target_hwnd, cx, cy)
648
+ if i < len(all_m) - 1:
649
+ self._sleep_ms(step.clickDelayMs)
650
+ self._vars["last_click_count"] = str(len(all_m))
651
+ return StepResult.Success
652
+
653
+ def _exec_multidrag(self, step: Step) -> StepResult:
654
+ if len(step.dragPoints) < 2:
655
+ self.cb.log(" 错误: 多点拖拽至少需要2个点")
656
+ return StepResult.Fail
657
+ pts = step.dragPoints
658
+ for i in range(len(pts) - 1):
659
+ if not self._running:
660
+ break
661
+ (x1, y1), (x2, y2) = pts[i], pts[i + 1]
662
+ self.cb.log(f" 拖拽 {i + 1}/{len(pts) - 1}: ({x1},{y1}) → ({x2},{y2})")
663
+ self.clicker.drag(self.target_hwnd, x1, y1, x2, y2, step.durationMs)
664
+ if i < len(pts) - 2:
665
+ self._sleep_ms(step.dragStepDelay)
666
+ return StepResult.Success