manul-engine 0.0.5__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.
- manul_engine/__init__.py +27 -0
- manul_engine/__main__.py +6 -0
- manul_engine/_test_runner.py +143 -0
- manul_engine/actions.py +400 -0
- manul_engine/cache.py +228 -0
- manul_engine/cli.py +212 -0
- manul_engine/core.py +500 -0
- manul_engine/helpers.py +51 -0
- manul_engine/js_scripts.py +770 -0
- manul_engine/prompts.py +261 -0
- manul_engine/py.typed +0 -0
- manul_engine/scoring.py +276 -0
- manul_engine-0.0.5.dist-info/METADATA +301 -0
- manul_engine-0.0.5.dist-info/RECORD +18 -0
- manul_engine-0.0.5.dist-info/WHEEL +5 -0
- manul_engine-0.0.5.dist-info/entry_points.txt +2 -0
- manul_engine-0.0.5.dist-info/licenses/LICENSE +201 -0
- manul_engine-0.0.5.dist-info/top_level.txt +1 -0
manul_engine/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# manul_engine/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
ManulEngine — AI-powered browser automation engine.
|
|
4
|
+
|
|
5
|
+
Package structure:
|
|
6
|
+
manul_engine/
|
|
7
|
+
__init__.py — public API (re-exports ManulEngine)
|
|
8
|
+
prompts.py — configuration, thresholds, LLM prompts
|
|
9
|
+
helpers.py — pure utility functions and timing constants
|
|
10
|
+
js_scripts.py — JavaScript injected into the browser page
|
|
11
|
+
scoring.py — heuristic element-scoring algorithm
|
|
12
|
+
cache.py — persistent per-site controls cache mixin
|
|
13
|
+
core.py — ManulEngine class (LLM, resolution, mission runner)
|
|
14
|
+
actions.py — action execution mixin (click, type, select, hover, drag…)
|
|
15
|
+
test/
|
|
16
|
+
test_*.py — synthetic DOM unit tests
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
from manul_engine import ManulEngine
|
|
20
|
+
|
|
21
|
+
manul = ManulEngine()
|
|
22
|
+
await manul.run_mission("1. Navigate to ...")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .core import ManulEngine
|
|
26
|
+
|
|
27
|
+
__all__ = ["ManulEngine"]
|
manul_engine/__main__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# manul_engine/_test_runner.py
|
|
2
|
+
"""
|
|
3
|
+
Internal synthetic DOM test runner (developer tool — not part of the public CLI).
|
|
4
|
+
|
|
5
|
+
Invoked from the repository dev launcher::
|
|
6
|
+
|
|
7
|
+
python manul.py test
|
|
8
|
+
|
|
9
|
+
Runs all test_*.py suites inside manul_engine/test/ against locally rendered
|
|
10
|
+
HTML pages (no real websites, no internet required).
|
|
11
|
+
|
|
12
|
+
End users of the installed package do not have access to this command.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import io
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Directory that holds synthetic test_*.py suites (available when running from
|
|
23
|
+
# a source checkout; these tests are not packaged into the installed wheel).
|
|
24
|
+
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
25
|
+
_TEST_DIR = os.path.join(_PKG_DIR, "test")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Tee:
|
|
29
|
+
"""Duplicate stdout to a log file."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, path: str) -> None:
|
|
32
|
+
self._term = sys.stdout
|
|
33
|
+
self._file = open(path, "w", encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
def write(self, msg: str) -> None:
|
|
36
|
+
self._term.write(msg)
|
|
37
|
+
self._file.write(msg)
|
|
38
|
+
|
|
39
|
+
def flush(self) -> None:
|
|
40
|
+
self._term.flush()
|
|
41
|
+
self._file.flush()
|
|
42
|
+
|
|
43
|
+
def isatty(self) -> bool:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def term(self):
|
|
48
|
+
return self._term
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
self._file.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def run_tests(log_path: str) -> bool:
|
|
55
|
+
"""
|
|
56
|
+
Discover and run all test_*.py suites in manul_engine/test/.
|
|
57
|
+
|
|
58
|
+
Returns True if every suite passed, False otherwise.
|
|
59
|
+
Writes a full log to *log_path*.
|
|
60
|
+
"""
|
|
61
|
+
# Force heuristics-only, deterministic execution for the synthetic suite.
|
|
62
|
+
# MANUL_AI_THRESHOLD=0 prevents any LLM calls even if the developer has
|
|
63
|
+
# MANUL_MODEL set in their shell.
|
|
64
|
+
os.environ["MANUL_CONTROLS_CACHE_ENABLED"] = "False"
|
|
65
|
+
os.environ["MANUL_AI_THRESHOLD"] = "0"
|
|
66
|
+
try:
|
|
67
|
+
from manul_engine import prompts as _prompts
|
|
68
|
+
_prompts.CONTROLS_CACHE_ENABLED = False
|
|
69
|
+
_prompts.ENV_AI_THRESHOLD = 0
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
# Ensure UTF-8 output on Windows / misconfigured terminals.
|
|
74
|
+
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
|
|
75
|
+
sys.stdout = io.TextIOWrapper(
|
|
76
|
+
sys.stdout.detach(), encoding="utf-8", errors="replace", line_buffering=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
tee = _Tee(log_path)
|
|
80
|
+
real_stdout = sys.stdout
|
|
81
|
+
score_lines: list[str] = []
|
|
82
|
+
|
|
83
|
+
class _ScoreTee:
|
|
84
|
+
def write(self, msg: str) -> None:
|
|
85
|
+
real_stdout.write(msg)
|
|
86
|
+
tee._file.write(msg)
|
|
87
|
+
for line in msg.splitlines():
|
|
88
|
+
if "SCORE:" in line:
|
|
89
|
+
score_lines.append(line.strip())
|
|
90
|
+
|
|
91
|
+
def flush(self) -> None:
|
|
92
|
+
real_stdout.flush()
|
|
93
|
+
tee._file.flush()
|
|
94
|
+
|
|
95
|
+
def isatty(self) -> bool:
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
sys.stdout = _ScoreTee()
|
|
99
|
+
|
|
100
|
+
test_files = sorted(
|
|
101
|
+
f[:-3]
|
|
102
|
+
for f in os.listdir(_TEST_DIR)
|
|
103
|
+
if f.startswith("test_") and f.endswith(".py")
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
all_ok = True
|
|
107
|
+
suite_results: list[tuple[str, int, int]] = []
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
for mod_name in test_files:
|
|
111
|
+
mod = importlib.import_module(f"manul_engine.test.{mod_name}")
|
|
112
|
+
runner = getattr(mod, "run_laboratory", None) or getattr(mod, "run_suite", None)
|
|
113
|
+
if runner is None:
|
|
114
|
+
continue
|
|
115
|
+
before = len(score_lines)
|
|
116
|
+
ok = await runner()
|
|
117
|
+
if not ok:
|
|
118
|
+
all_ok = False
|
|
119
|
+
for sl in score_lines[before:]:
|
|
120
|
+
m = re.search(r"(\d+)/(\d+)", sl)
|
|
121
|
+
if m:
|
|
122
|
+
suite_results.append((mod_name, int(m.group(1)), int(m.group(2))))
|
|
123
|
+
|
|
124
|
+
total_passed = sum(p for _, p, _ in suite_results)
|
|
125
|
+
total_tests = sum(t for _, _, t in suite_results)
|
|
126
|
+
|
|
127
|
+
print(f"\n\n{'=' * 70}")
|
|
128
|
+
print("🐾 SYNTHETIC DOM LABORATORY SUMMARY")
|
|
129
|
+
print(f"{'=' * 70}")
|
|
130
|
+
for name, p, t in suite_results:
|
|
131
|
+
icon = "✅" if p == t else "❌"
|
|
132
|
+
label = name.replace("test_", "").replace("_", " ").upper()
|
|
133
|
+
print(f" {icon} {label:<30} {p:>4}/{t}")
|
|
134
|
+
print(f"{'─' * 70}")
|
|
135
|
+
print(f" {'TOTAL':<30} {total_passed:>4}/{total_tests}")
|
|
136
|
+
if total_passed == total_tests:
|
|
137
|
+
print("\n🏆 ALL TESTS PASSED — THE ENGINE IS UNBREAKABLE!")
|
|
138
|
+
print(f"{'=' * 70}")
|
|
139
|
+
finally:
|
|
140
|
+
sys.stdout = real_stdout
|
|
141
|
+
tee.close()
|
|
142
|
+
|
|
143
|
+
return all_ok
|
manul_engine/actions.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# manul_engine/actions.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import re
|
|
4
|
+
from .helpers import extract_quoted, compact_log_field, SCROLL_WAIT, ACTION_WAIT, NAV_WAIT
|
|
5
|
+
from .js_scripts import VISIBLE_TEXT_JS, EXTRACT_DATA_JS, DEEP_TEXT_JS, STATE_CHECK_JS
|
|
6
|
+
from . import prompts
|
|
7
|
+
|
|
8
|
+
class _ActionsMixin:
|
|
9
|
+
def _fmt_el_name(self, name: object) -> str:
|
|
10
|
+
return compact_log_field(name, "MANUL_LOG_NAME_MAXLEN")
|
|
11
|
+
|
|
12
|
+
def _remember_resolved_control(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
page,
|
|
16
|
+
cache_key: tuple,
|
|
17
|
+
mode: str,
|
|
18
|
+
search_texts: list[str],
|
|
19
|
+
target_field: str | None,
|
|
20
|
+
element: dict,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.learned_elements[cache_key] = {
|
|
23
|
+
"name": str(element.get("name", "")),
|
|
24
|
+
"tag": str(element.get("tag_name", "")),
|
|
25
|
+
}
|
|
26
|
+
persist = getattr(self, "_persist_control_cache_entry", None)
|
|
27
|
+
if callable(persist):
|
|
28
|
+
try:
|
|
29
|
+
persist(
|
|
30
|
+
page=page,
|
|
31
|
+
mode=mode,
|
|
32
|
+
search_texts=search_texts,
|
|
33
|
+
target_field=target_field,
|
|
34
|
+
element=element,
|
|
35
|
+
)
|
|
36
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
37
|
+
print(f" ⚠️ CONTROL CACHE: persist skipped ({type(exc).__name__})")
|
|
38
|
+
|
|
39
|
+
async def _handle_navigate(self, page, step: str) -> bool:
|
|
40
|
+
url = re.search(r'(https?://[^\s\'"<>]+)', step)
|
|
41
|
+
if not url: return False
|
|
42
|
+
await page.goto(url.group(1), wait_until="domcontentloaded", timeout=prompts.NAV_TIMEOUT)
|
|
43
|
+
self.last_xpath = None
|
|
44
|
+
await asyncio.sleep(NAV_WAIT)
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
async def _handle_scroll(self, page, step: str):
|
|
48
|
+
step_l = step.lower()
|
|
49
|
+
if "inside" in step_l or "list" in step_l:
|
|
50
|
+
await page.evaluate("const d=document.querySelector('#dropdown')||document.querySelector('[class*=\"dropdown\"]');if(d)d.scrollTop=d.scrollHeight;")
|
|
51
|
+
else:
|
|
52
|
+
await page.evaluate("window.scrollBy(0, window.innerHeight)")
|
|
53
|
+
await asyncio.sleep(SCROLL_WAIT)
|
|
54
|
+
|
|
55
|
+
async def _handle_extract(self, page, step: str) -> bool:
|
|
56
|
+
var_m = re.search(r'\{(.*?)\}', step)
|
|
57
|
+
target = (extract_quoted(step) or [""])[0].replace("'", "")
|
|
58
|
+
print(" ⚙️ DOM HEURISTICS: Extracting data via JS…")
|
|
59
|
+
|
|
60
|
+
step_lower = step.lower()
|
|
61
|
+
hint = ""
|
|
62
|
+
m_hint = re.search(r'extract\s+(.+?)\s+into\b', step_lower)
|
|
63
|
+
if m_hint:
|
|
64
|
+
raw = m_hint.group(1)
|
|
65
|
+
raw = re.sub(r"'[^']*'", "", raw).strip()
|
|
66
|
+
for w in ("the", "of", "from", "a", "an", "text", "value"):
|
|
67
|
+
raw = re.sub(rf'\b{w}\b', '', raw).strip()
|
|
68
|
+
hint = raw.strip()
|
|
69
|
+
|
|
70
|
+
currency_hint = ""
|
|
71
|
+
curr_m = re.search(r'([$€£₴¥₹])', step)
|
|
72
|
+
if curr_m:
|
|
73
|
+
currency_hint = curr_m.group(1)
|
|
74
|
+
for cw, cs in [("uah", "UAH"), ("pln", "PLN"), ("eur", "€"), ("gbp", "£"), ("usd", "$")]:
|
|
75
|
+
if cw in step_lower.split():
|
|
76
|
+
currency_hint = cs
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
val = await page.evaluate(EXTRACT_DATA_JS, [target.lower(), hint, currency_hint])
|
|
80
|
+
|
|
81
|
+
if val and var_m:
|
|
82
|
+
val = val.strip()
|
|
83
|
+
if hint and ':' in val:
|
|
84
|
+
m_lbl = re.match(r'^([A-Za-z][A-Za-z0-9 ]+?)\s*:\s+(.+)$', val)
|
|
85
|
+
if m_lbl:
|
|
86
|
+
label_part = m_lbl.group(1).lower()
|
|
87
|
+
value_part = m_lbl.group(2).strip()
|
|
88
|
+
hint_ws = set(re.findall(r'[a-z]{3,}', hint.lower()))
|
|
89
|
+
label_ws = set(re.findall(r'[a-z]{3,}', label_part))
|
|
90
|
+
if hint_ws & label_ws:
|
|
91
|
+
val = value_part
|
|
92
|
+
|
|
93
|
+
self.memory[var_m.group(1)] = val
|
|
94
|
+
print(f" 📦 COLLECTED: {val}")
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
async def _handle_verify(self, page, step: str) -> bool:
|
|
99
|
+
expected = extract_quoted(step)
|
|
100
|
+
step_no_quotes = re.sub(r"'[^']*'", "", step)
|
|
101
|
+
is_negative = bool(re.search(r'\b(NOT|HIDDEN|ABSENT)\b', step_no_quotes.upper()))
|
|
102
|
+
state_check = "disabled" if re.search(r'\bDISABLED\b', step.upper()) else "enabled" if re.search(r'\bENABLED\b', step.upper()) else None
|
|
103
|
+
is_checked_verify = bool(re.search(r'\bchecked\b', step.lower()))
|
|
104
|
+
|
|
105
|
+
msg = f" ⚙️ DOM HEURISTICS: Scanning for {expected}"
|
|
106
|
+
if is_negative: msg += " [MUST BE ABSENT]"
|
|
107
|
+
if state_check: msg += f" [{state_check.upper()}]"
|
|
108
|
+
if is_checked_verify: msg += " [CHECKED]"
|
|
109
|
+
print(msg)
|
|
110
|
+
|
|
111
|
+
for retry in range(15):
|
|
112
|
+
if is_checked_verify:
|
|
113
|
+
raw_els = await self._snapshot(page, "clickable", [t.lower() for t in expected])
|
|
114
|
+
scored = self._score_elements(raw_els, step, "clickable", expected, None, False)
|
|
115
|
+
if scored:
|
|
116
|
+
best = scored[0]
|
|
117
|
+
xpath = best["xpath"]
|
|
118
|
+
loc = page.locator(f"xpath={xpath}").first
|
|
119
|
+
try: checked = await loc.is_checked(timeout=2000)
|
|
120
|
+
except Exception: checked = False
|
|
121
|
+
if is_negative:
|
|
122
|
+
ok = not checked
|
|
123
|
+
if ok:
|
|
124
|
+
print(f" {'✅' if ok else '❌'} Checkbox not-checked={ok}")
|
|
125
|
+
return ok
|
|
126
|
+
else:
|
|
127
|
+
if checked:
|
|
128
|
+
print(f" {'✅' if checked else '❌'} Checkbox checked={checked}")
|
|
129
|
+
return checked
|
|
130
|
+
if retry < 14:
|
|
131
|
+
await asyncio.sleep(1)
|
|
132
|
+
continue
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
if state_check:
|
|
136
|
+
search_text = expected[0] if expected else ""
|
|
137
|
+
disabled_result = await page.evaluate(STATE_CHECK_JS, [search_text, state_check])
|
|
138
|
+
|
|
139
|
+
if disabled_result is not None:
|
|
140
|
+
icon = '✅' if disabled_result else '❌'
|
|
141
|
+
print(f" {icon} Element {state_check}={disabled_result}")
|
|
142
|
+
return disabled_result
|
|
143
|
+
if retry < 14:
|
|
144
|
+
await asyncio.sleep(1)
|
|
145
|
+
continue
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
text = await page.evaluate(VISIBLE_TEXT_JS)
|
|
149
|
+
found = all(t.lower() in text for t in expected) if expected else bool(text)
|
|
150
|
+
|
|
151
|
+
if not found and not is_negative:
|
|
152
|
+
text2 = await page.evaluate(DEEP_TEXT_JS)
|
|
153
|
+
found = all(t.lower() in text2 for t in expected) if expected else bool(text2)
|
|
154
|
+
|
|
155
|
+
if is_negative:
|
|
156
|
+
if not found:
|
|
157
|
+
print(f" ✅ Verified ABSENT — OK")
|
|
158
|
+
return True
|
|
159
|
+
if retry < 14:
|
|
160
|
+
await asyncio.sleep(1)
|
|
161
|
+
continue
|
|
162
|
+
print(f" ❌ Text still present after retries")
|
|
163
|
+
return False
|
|
164
|
+
else:
|
|
165
|
+
if found:
|
|
166
|
+
print(f" ✅ Verified — OK")
|
|
167
|
+
return True
|
|
168
|
+
if retry < 14:
|
|
169
|
+
await asyncio.sleep(1.5)
|
|
170
|
+
continue
|
|
171
|
+
print(f" ❌ Not found after retries: {expected}")
|
|
172
|
+
return False
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
async def _do_drag(self, page, step: str, expected: list[str], source_id: int) -> bool:
|
|
176
|
+
step_l = step.lower()
|
|
177
|
+
target_text = ""
|
|
178
|
+
m_to = re.search(r"to\s+['\"](.+?)['\"]", step_l)
|
|
179
|
+
if m_to: target_text = m_to.group(1)
|
|
180
|
+
elif len(expected) >= 2: target_text = expected[-1]
|
|
181
|
+
|
|
182
|
+
raw_els = await self._snapshot(page, "drag", [target_text])
|
|
183
|
+
dest = next((el for el in raw_els if el["id"] != source_id and target_text.lower() in el["name"].lower()), None)
|
|
184
|
+
if not dest: return False
|
|
185
|
+
|
|
186
|
+
src_el = next((el for el in raw_els if el["id"] == source_id), raw_els[0])
|
|
187
|
+
src_loc = page.locator(f"xpath={src_el['xpath']}").first
|
|
188
|
+
dest_loc = page.locator(f"xpath={dest['xpath']}").first
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
await src_loc.drag_to(dest_loc, timeout=5000)
|
|
192
|
+
except Exception:
|
|
193
|
+
sb = await src_loc.bounding_box()
|
|
194
|
+
db = await dest_loc.bounding_box()
|
|
195
|
+
if sb and db:
|
|
196
|
+
await page.mouse.move(sb["x"] + sb["width"]/2, sb["y"] + sb["height"]/2)
|
|
197
|
+
await page.mouse.down()
|
|
198
|
+
await asyncio.sleep(0.3)
|
|
199
|
+
await page.mouse.move(db["x"] + db["width"]/2, db["y"] + db["height"]/2, steps=20)
|
|
200
|
+
await page.mouse.up()
|
|
201
|
+
|
|
202
|
+
print(f" 🖱️ Dragged → '{self._fmt_el_name(dest.get('name', ''))}'")
|
|
203
|
+
await asyncio.sleep(ACTION_WAIT)
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
async def _execute_step(self, page, step: str, strategic_context: str = "") -> bool:
|
|
207
|
+
step_l = step.lower()
|
|
208
|
+
words = set(re.findall(r'\b[a-z]+\b', step_l))
|
|
209
|
+
|
|
210
|
+
if "drag" in words and "drop" in words: mode = "drag"
|
|
211
|
+
elif "select" in words or "choose" in words: mode = "select"
|
|
212
|
+
elif any(w in words for w in ("type","fill","enter")): mode = "input"
|
|
213
|
+
elif any(w in words for w in ("click","double","check","uncheck")): mode = "clickable"
|
|
214
|
+
elif "hover" in words: mode = "hover"
|
|
215
|
+
else: mode = "locate"
|
|
216
|
+
|
|
217
|
+
preserve = mode in ("input", "select")
|
|
218
|
+
expected = extract_quoted(step, preserve_case=preserve)
|
|
219
|
+
|
|
220
|
+
target_field = None
|
|
221
|
+
txt_to_type = ""
|
|
222
|
+
search_texts = []
|
|
223
|
+
|
|
224
|
+
if mode == "input" and expected:
|
|
225
|
+
txt_to_type = expected[-1]
|
|
226
|
+
search_texts = expected[:-1]
|
|
227
|
+
m = re.search(r'(?:into\s+the\s+|into\s+)([a-zA-Z0-9_]+)\s*field', step_l)
|
|
228
|
+
if m and m.group(1) not in ("that", "the", "a", "an"): target_field = m.group(1).lower()
|
|
229
|
+
else:
|
|
230
|
+
search_texts = expected
|
|
231
|
+
|
|
232
|
+
if search_texts or target_field:
|
|
233
|
+
self.last_xpath = None
|
|
234
|
+
|
|
235
|
+
is_optional = bool(re.search(r'\bif\s+exists\b|\boptional\b', re.sub(r'''["'][^"']*["']''', '', step_l)))
|
|
236
|
+
cache_key = (mode, tuple(t.lower() for t in search_texts), target_field)
|
|
237
|
+
failed_ids = set()
|
|
238
|
+
|
|
239
|
+
for attempt in range(3):
|
|
240
|
+
try:
|
|
241
|
+
el = await self._resolve_element(page, step, mode, search_texts, target_field, strategic_context, failed_ids=failed_ids)
|
|
242
|
+
except Exception:
|
|
243
|
+
if is_optional: return True
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
if el is None:
|
|
247
|
+
if is_optional: return True
|
|
248
|
+
if attempt < 2:
|
|
249
|
+
print(" 🔄 Target not found or rejected by AI. Scrolling and retrying...")
|
|
250
|
+
await page.evaluate("window.scrollBy(0, window.innerHeight / 2)")
|
|
251
|
+
await asyncio.sleep(1)
|
|
252
|
+
continue
|
|
253
|
+
else:
|
|
254
|
+
if mode != "locate":
|
|
255
|
+
print(" 💀 SELF-HEALING FAILED: No valid elements found after retries.")
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
if el["id"] in failed_ids: continue
|
|
259
|
+
|
|
260
|
+
self.last_xpath = el["xpath"]
|
|
261
|
+
name, xpath, is_sel, is_shad, el_id, tag, itype = el["name"], el["xpath"], el.get("is_select"), el.get("is_shadow"), el["id"], el.get("tag_name", ""), el.get("input_type", "")
|
|
262
|
+
|
|
263
|
+
if mode == "input" and itype in ("radio", "checkbox", "button", "submit", "image"):
|
|
264
|
+
failed_ids.add(el_id)
|
|
265
|
+
self.last_xpath = None
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if mode == "locate":
|
|
269
|
+
try:
|
|
270
|
+
loc = page.locator(f"xpath={xpath}").first
|
|
271
|
+
if not is_shad:
|
|
272
|
+
await loc.scroll_into_view_if_needed(timeout=2000)
|
|
273
|
+
await self._highlight(page, loc)
|
|
274
|
+
else:
|
|
275
|
+
await self._highlight(page, el_id, by_js_id=True)
|
|
276
|
+
except Exception: pass
|
|
277
|
+
print(f" 🔍 Located '{self._fmt_el_name(name)}'")
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
if mode == "drag": return await self._do_drag(page, step, expected, el_id)
|
|
281
|
+
|
|
282
|
+
loc = page.locator(f"xpath={xpath}").first
|
|
283
|
+
try:
|
|
284
|
+
if not is_shad:
|
|
285
|
+
await loc.scroll_into_view_if_needed(timeout=2000)
|
|
286
|
+
await self._highlight(page, loc)
|
|
287
|
+
else:
|
|
288
|
+
await self._highlight(page, el_id, by_js_id=True)
|
|
289
|
+
except Exception: pass
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
if mode == "input":
|
|
293
|
+
print(f" ⌨️ Typed '{txt_to_type}' → '{self._fmt_el_name(name)}'")
|
|
294
|
+
if is_shad: await page.evaluate(f"window.manulType({el_id}, '{txt_to_type}')")
|
|
295
|
+
else:
|
|
296
|
+
is_readonly = await loc.evaluate("el => el.readOnly || el.hasAttribute('readonly')")
|
|
297
|
+
if is_readonly:
|
|
298
|
+
escaped = txt_to_type.replace("'", "\\'")
|
|
299
|
+
await page.evaluate(f"el => {{ el.removeAttribute('readonly'); el.value = '{escaped}'; el.dispatchEvent(new Event('input', {{bubbles:true}})); el.dispatchEvent(new Event('change', {{bubbles:true}})); }}", await loc.element_handle())
|
|
300
|
+
else:
|
|
301
|
+
await loc.fill("", timeout=3000)
|
|
302
|
+
await loc.type(txt_to_type, delay=50, timeout=3000)
|
|
303
|
+
if "enter" in step_l:
|
|
304
|
+
await page.keyboard.press("Enter")
|
|
305
|
+
await asyncio.sleep(4)
|
|
306
|
+
self._remember_resolved_control(
|
|
307
|
+
page=page,
|
|
308
|
+
cache_key=cache_key,
|
|
309
|
+
mode=mode,
|
|
310
|
+
search_texts=search_texts,
|
|
311
|
+
target_field=target_field,
|
|
312
|
+
element=el,
|
|
313
|
+
)
|
|
314
|
+
self.last_xpath = None
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
elif mode == "select":
|
|
318
|
+
if is_sel:
|
|
319
|
+
opts = [expected[0]] if expected else [list(set(re.findall(r'\b[a-z0-9]{3,}\b', step_l)))[0]]
|
|
320
|
+
try: await loc.select_option(label=opts, timeout=3000)
|
|
321
|
+
except Exception: await loc.select_option(value=[o.lower() for o in opts], timeout=3000)
|
|
322
|
+
else:
|
|
323
|
+
print(f" 🖱️ Clicked (Custom Select) '{self._fmt_el_name(name)}'")
|
|
324
|
+
try:
|
|
325
|
+
await loc.click(force=True, timeout=3000)
|
|
326
|
+
except Exception:
|
|
327
|
+
await page.evaluate(f"window.manulClick({el_id})")
|
|
328
|
+
|
|
329
|
+
if expected:
|
|
330
|
+
await asyncio.sleep(0.5)
|
|
331
|
+
option_text = expected[0]
|
|
332
|
+
print(f" 🖱️ Selecting option '{option_text}'")
|
|
333
|
+
try:
|
|
334
|
+
opt_loc = page.locator(f"[role='option']:has-text('{option_text}'), [role='menuitem']:has-text('{option_text}')").first
|
|
335
|
+
await opt_loc.click(timeout=3000)
|
|
336
|
+
except Exception:
|
|
337
|
+
try:
|
|
338
|
+
opt_loc = page.locator(f"text='{option_text}'").last
|
|
339
|
+
await opt_loc.click(timeout=3000)
|
|
340
|
+
except Exception: pass
|
|
341
|
+
|
|
342
|
+
self._remember_resolved_control(
|
|
343
|
+
page=page,
|
|
344
|
+
cache_key=cache_key,
|
|
345
|
+
mode=mode,
|
|
346
|
+
search_texts=search_texts,
|
|
347
|
+
target_field=target_field,
|
|
348
|
+
element=el,
|
|
349
|
+
)
|
|
350
|
+
await asyncio.sleep(ACTION_WAIT)
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
elif mode == "hover":
|
|
354
|
+
print(f" 🚁 Hovered '{self._fmt_el_name(name)}'")
|
|
355
|
+
if is_shad: await page.evaluate(f"window.manulElements[{el_id}].dispatchEvent(new MouseEvent('mouseover',{{bubbles:true,cancelable:true,view:window}}))")
|
|
356
|
+
else: await loc.hover(force=True, timeout=3000)
|
|
357
|
+
self._remember_resolved_control(
|
|
358
|
+
page=page,
|
|
359
|
+
cache_key=cache_key,
|
|
360
|
+
mode=mode,
|
|
361
|
+
search_texts=search_texts,
|
|
362
|
+
target_field=target_field,
|
|
363
|
+
element=el,
|
|
364
|
+
)
|
|
365
|
+
await asyncio.sleep(ACTION_WAIT)
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
else:
|
|
369
|
+
print(f" 🖱️ Clicked '{self._fmt_el_name(name)}'")
|
|
370
|
+
if is_shad:
|
|
371
|
+
fn = "manulDoubleClick" if "double" in step_l else "manulClick"
|
|
372
|
+
await page.evaluate(f"window.{fn}({el_id})")
|
|
373
|
+
await asyncio.sleep(ACTION_WAIT)
|
|
374
|
+
else:
|
|
375
|
+
if "double" in step_l:
|
|
376
|
+
await loc.dblclick(force=True, timeout=3000)
|
|
377
|
+
elif itype in ("checkbox", "radio", "file"):
|
|
378
|
+
await loc.evaluate("el => el.click()")
|
|
379
|
+
else:
|
|
380
|
+
await loc.click(force=True, timeout=3000)
|
|
381
|
+
if itype == "submit" or (tag == "button" and itype in ("", "submit")):
|
|
382
|
+
try: await page.wait_for_load_state("networkidle", timeout=10_000)
|
|
383
|
+
except Exception: await asyncio.sleep(3.0)
|
|
384
|
+
await asyncio.sleep(ACTION_WAIT)
|
|
385
|
+
self._remember_resolved_control(
|
|
386
|
+
page=page,
|
|
387
|
+
cache_key=cache_key,
|
|
388
|
+
mode=mode,
|
|
389
|
+
search_texts=search_texts,
|
|
390
|
+
target_field=target_field,
|
|
391
|
+
element=el,
|
|
392
|
+
)
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
except Exception as ex:
|
|
396
|
+
failed_ids.add(el_id)
|
|
397
|
+
self.last_xpath = None
|
|
398
|
+
await asyncio.sleep(1)
|
|
399
|
+
|
|
400
|
+
return False
|