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.
@@ -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"]
@@ -0,0 +1,6 @@
1
+ # manul_engine/__main__.py
2
+ """Entry point for `python -m manul_engine`."""
3
+
4
+ from manul_engine.cli import sync_main
5
+
6
+ sync_main()
@@ -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
@@ -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