seedcode-cli 6.1.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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,546 @@
1
+ """Browser Engine: complete web workflows as deterministic local procedures.
2
+
3
+ This module is the answer to a specific architectural failure. Browser work
4
+ used to be assembled by the *AI*: search, screenshot, reason about the pixels,
5
+ emit a click, discover a cookie banner, emit another click, fail when OCR was
6
+ missing. Every one of those steps is mechanical, and mechanical work belongs in
7
+ the engine. The AI's entire contribution should be the sentence "play Love Me
8
+ Thoda Aur on YouTube".
9
+
10
+ So the browser engine owns the whole workflow, and it is built on one central
11
+ insight: **most browser goals do not require touching the UI at all.** A URL
12
+ expresses the goal precisely. Searching YouTube is a URL. Playing a specific
13
+ video is a URL — once you know the video id, which is an HTTP lookup, not a
14
+ click on a thumbnail. Going back is a keystroke. Under this design the
15
+ "find and click the first search result" problem, which is where OCR and
16
+ vision were being dragged in, simply stops existing.
17
+
18
+ The execution ladder for any goal, cheapest and most reliable first:
19
+
20
+ 1. **URL construction** — the goal is expressible as an address. Zero UI.
21
+ 2. **HTTP resolution** — the goal needs a fact from the page (a video id);
22
+ fetch and parse it, then fall back to tier 1. Still zero UI.
23
+ 3. **DOM** (:mod:`.browser_cdp`) — genuine in-page interaction, done inside
24
+ the page so it cannot miss.
25
+ 4. **Accessibility / OCR / vision** — inherited from the element resolver,
26
+ for the rare browser-chrome interaction.
27
+
28
+ Around every action, :class:`~.browser_popups.PopupManager` clears consent
29
+ walls, translate bars, and sign-in interstitials, and after every action the
30
+ engine verifies the world actually changed. Failures come back as
31
+ :class:`BrowserWorkflowError` with a human-readable reason — never as a
32
+ half-finished sequence for the AI to untangle.
33
+
34
+ Deterministic and offline apart from the page fetch itself. No AI code.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+ import time
42
+ import urllib.error
43
+ import urllib.parse
44
+ import urllib.request
45
+ from dataclasses import dataclass
46
+ from typing import Any
47
+
48
+ from . import browser as browser_driver
49
+
50
+ # Time budgets. Generous enough for a cold page load on a slow link, short
51
+ # enough that a wedged step surfaces as a clear failure inside one agent turn.
52
+ _LOAD_TIMEOUT_S = 12.0
53
+ _HTTP_TIMEOUT_S = 8.0
54
+ _SETTLE_S = 1.2
55
+
56
+ # YouTube serves search results as server-rendered JSON embedded in the page.
57
+ # Reading the first videoId from it is how the engine plays a song without ever
58
+ # looking at a thumbnail.
59
+ _VIDEO_ID_RE = re.compile(r'"videoId"\s*:\s*"([A-Za-z0-9_-]{11})"')
60
+ _TITLE_RE = re.compile(r'"title"\s*:\s*\{\s*"runs"\s*:\s*\[\s*\{\s*"text"\s*:\s*"((?:[^"\\]|\\.)*)"')
61
+
62
+ # A desktop UA: YouTube serves a different (harder to parse) payload to
63
+ # unrecognised clients.
64
+ _UA = (
65
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
66
+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
67
+ )
68
+
69
+ SEARCH_ENGINES = browser_driver.SEARCH_ENGINES
70
+
71
+
72
+ class BrowserWorkflowError(Exception):
73
+ """A browser workflow could not be completed; the message reaches the AI."""
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class WorkflowResult:
78
+ """The verified outcome of a browser workflow."""
79
+
80
+ detail: str
81
+ url: str | None = None
82
+ title: str | None = None
83
+ # Expectation dict for the dispatcher's verifier (see verifier._KINDS).
84
+ expected: dict[str, Any] | None = None
85
+ # Popups cleared along the way — logged, never surfaced to the AI as a
86
+ # decision it needs to make.
87
+ popups: tuple[str, ...] = ()
88
+
89
+ def describe(self) -> str:
90
+ return self.detail
91
+
92
+
93
+ class BrowserEngine:
94
+ """Executes whole browser workflows from a single high-level goal."""
95
+
96
+ def __init__(
97
+ self,
98
+ controller: Any = None,
99
+ cdp: Any = None,
100
+ popups: Any = None,
101
+ driver: Any = None,
102
+ settle_s: float | None = None,
103
+ fetch: Any = None,
104
+ ) -> None:
105
+ if cdp is None:
106
+ from . import browser_cdp as cdp # type: ignore
107
+ if driver is None:
108
+ # Prefer the controller's own browser driver: it is the injectable
109
+ # seam the rest of the engine uses, so a test (or an alternative
110
+ # front end) that supplies a fake controller never reaches the real
111
+ # browser or the network.
112
+ driver = getattr(controller, "browser", None) or browser_driver
113
+ self._controller = controller
114
+ self._cdp = cdp
115
+ self._driver = driver
116
+ self._settle_s = _SETTLE_S if settle_s is None else max(0.0, float(settle_s))
117
+ # Page fetcher for the URL-resolution tier; injectable for tests.
118
+ self._fetch_impl = fetch
119
+ if popups is None:
120
+ from .browser_popups import PopupManager
121
+
122
+ popups = PopupManager(cdp=cdp, controller=controller)
123
+ self._popups = popups
124
+
125
+ # --- navigation ----------------------------------------------------------
126
+ def open_url(self, url: str, *, new_tab: bool = False) -> WorkflowResult:
127
+ """Open a URL and confirm the browser actually went there."""
128
+ target = _normalize(url)
129
+ if not target:
130
+ raise BrowserWorkflowError("A URL is required.")
131
+ self._navigate(target, new_tab=new_tab)
132
+ popups = self._settle()
133
+ return self._verified(
134
+ f"opened {target}", target, popups,
135
+ expected={"browser_url": _fragment(target)},
136
+ )
137
+
138
+ def new_tab(self, url: str = "about:blank") -> WorkflowResult:
139
+ """Open a new tab, optionally at a URL."""
140
+ target = _normalize(url) if url and url != "about:blank" else "about:blank"
141
+ if target == "about:blank":
142
+ # A blank tab has no address to verify; drive it through the
143
+ # browser's own shortcut so it lands in the focused window.
144
+ if self._cdp_live() and self._safe(lambda: self._cdp.new_tab(target)):
145
+ popups = self._settle()
146
+ return WorkflowResult("opened a new tab", None, None, None, popups)
147
+ self._hotkey(["ctrl", "t"], "open a new tab")
148
+ popups = self._settle()
149
+ return WorkflowResult("opened a new tab", None, None, None, popups)
150
+ return self.open_url(target, new_tab=True)
151
+
152
+ def close_tab(self) -> WorkflowResult:
153
+ """Close the active tab."""
154
+ if self._cdp_live():
155
+ tab = self._safe(lambda: self._cdp.active_tab())
156
+ if tab is not None and self._safe(lambda: self._cdp.close_tab(tab.target_id)):
157
+ return WorkflowResult(f"closed tab {tab.describe()}", None, tab.title)
158
+ self._hotkey(["ctrl", "w"], "close the active tab")
159
+ return WorkflowResult("closed the active browser tab")
160
+
161
+ def switch_tab(self, target: str = "") -> WorkflowResult:
162
+ """Focus another tab, chosen by title/URL fragment (or the next one)."""
163
+ wanted = (target or "").strip().lower()
164
+ if self._cdp_live():
165
+ tabs = self._safe(lambda: self._cdp.list_tabs()) or []
166
+ if tabs:
167
+ chosen = _pick_tab(tabs, wanted)
168
+ if chosen is None:
169
+ raise BrowserWorkflowError(
170
+ f'No open tab matches "{target}". Open tabs: '
171
+ + "; ".join(t.describe() for t in tabs[:8])
172
+ )
173
+ if self._safe(lambda: self._cdp.activate_tab(chosen.target_id)):
174
+ self._focus_browser_window()
175
+ return WorkflowResult(
176
+ f"switched to tab {chosen.describe()}", chosen.url, chosen.title,
177
+ expected={"browser_url": _fragment(chosen.url)} if chosen.url else None,
178
+ )
179
+ # No DevTools: Ctrl+Tab cycles, which honours "the next tab" only.
180
+ if wanted:
181
+ raise BrowserWorkflowError(
182
+ f'Cannot switch to a named tab ("{target}") without a DevTools '
183
+ "connection. Use open_url to reach the page directly instead."
184
+ )
185
+ self._hotkey(["ctrl", "tab"], "switch browser tab")
186
+ return WorkflowResult("switched to the next browser tab")
187
+
188
+ def back(self) -> WorkflowResult:
189
+ """Go back one entry in history."""
190
+ return self._history_step("back", "alt+left", -1)
191
+
192
+ def forward(self) -> WorkflowResult:
193
+ """Go forward one entry in history."""
194
+ return self._history_step("forward", "alt+right", 1)
195
+
196
+ def refresh(self) -> WorkflowResult:
197
+ """Reload the current page."""
198
+ before = self._current_url()
199
+ if not (self._cdp_live() and self._safe(lambda: self._cdp.evaluate("(location.reload(), true)"))):
200
+ self._hotkey(["f5"], "refresh the page")
201
+ popups = self._settle()
202
+ url = self._current_url() or before
203
+ return WorkflowResult(
204
+ "refreshed the current page", url, self._current_title(), popups=popups
205
+ )
206
+
207
+ def _history_step(self, name: str, hotkey: str, delta: int) -> WorkflowResult:
208
+ # Only DevTools can observe history movement. The fallback URL is
209
+ # "the last address we asked for", which does NOT change when the user
210
+ # goes back — so treating it as evidence would report every successful
211
+ # keyboard-driven back as a failure.
212
+ live = self._cdp_live()
213
+ before = self._current_url() if live else None
214
+ if not (live and self._safe(lambda: self._cdp.evaluate(f"(history.go({delta}), true)"))):
215
+ self._hotkey(hotkey.split("+"), f"go {name}")
216
+ popups = self._settle()
217
+ if not live:
218
+ return WorkflowResult(f"went {name}", None, None, popups=popups)
219
+ after = self._current_url()
220
+ if before and after and before == after:
221
+ raise BrowserWorkflowError(
222
+ f"Could not go {name}: the page did not change "
223
+ f"(still {after}). There may be no {name} history."
224
+ )
225
+ return WorkflowResult(
226
+ f"went {name}" + (f" to {after}" if after else ""),
227
+ after, self._current_title(), popups=popups,
228
+ )
229
+
230
+ # --- search --------------------------------------------------------------
231
+ def search(self, query: str, engine: str = "google") -> WorkflowResult:
232
+ """Run a search — built as a URL, so no search box is ever clicked."""
233
+ query = (query or "").strip()
234
+ if not query:
235
+ raise BrowserWorkflowError("A search query is required.")
236
+ key = (engine or "google").strip().lower()
237
+ template = SEARCH_ENGINES.get(key)
238
+ if template is None:
239
+ raise BrowserWorkflowError(
240
+ f'Unknown search engine "{engine}". '
241
+ f"Available: {', '.join(sorted(SEARCH_ENGINES))}."
242
+ )
243
+ url = template.format(q=urllib.parse.quote_plus(query))
244
+ self._navigate(url)
245
+ popups = self._settle()
246
+ return self._verified(
247
+ f'searched {key} for "{query}"', url, popups,
248
+ expected={"browser_url": _fragment(url)},
249
+ )
250
+
251
+ def google_search(self, query: str) -> WorkflowResult:
252
+ return self.search(query, "google")
253
+
254
+ def youtube_search(self, query: str) -> WorkflowResult:
255
+ return self.search(query, "youtube")
256
+
257
+ # --- YouTube playback ----------------------------------------------------
258
+ def youtube_play(self, query: str) -> WorkflowResult:
259
+ """Play the best match for ``query`` on YouTube.
260
+
261
+ The whole point of the refactor lives here. Rather than search, look at
262
+ the results, and click a thumbnail — the sequence that needed OCR and
263
+ produced a cascade of AI clicks — the engine resolves the video id over
264
+ HTTP and navigates straight to the watch URL with autoplay. One
265
+ navigation, nothing to see, nothing to click.
266
+
267
+ If the lookup fails (offline, markup change), it degrades to opening
268
+ the results page and clicking the first result *in the DOM*, and only
269
+ reports success when a watch page is actually open.
270
+ """
271
+ query = (query or "").strip()
272
+ if not query:
273
+ raise BrowserWorkflowError("A search query is required.")
274
+
275
+ video = self._resolve_youtube_video(query)
276
+ if video is not None:
277
+ video_id, title = video
278
+ url = f"https://www.youtube.com/watch?v={video_id}&autoplay=1"
279
+ self._navigate(url)
280
+ popups = self._settle()
281
+ self._ensure_playing()
282
+ label = title or query
283
+ return self._verified(
284
+ f'playing "{label}" on YouTube', url, popups,
285
+ expected={"browser_url": f"watch?v={video_id}"},
286
+ title=title,
287
+ )
288
+ return self._play_via_results(query)
289
+
290
+ def _resolve_youtube_video(self, query: str) -> tuple[str, str | None] | None:
291
+ """First video id + title for a query, straight from the results HTML."""
292
+ url = SEARCH_ENGINES["youtube"].format(q=urllib.parse.quote_plus(query))
293
+ html = self._fetch(url)
294
+ if not html:
295
+ return None
296
+ match = _VIDEO_ID_RE.search(html)
297
+ if match is None:
298
+ return None
299
+ video_id = match.group(1)
300
+ title = None
301
+ title_match = _TITLE_RE.search(html[match.start():match.start() + 4000])
302
+ if title_match:
303
+ try:
304
+ title = json.loads(f'"{title_match.group(1)}"')
305
+ except ValueError:
306
+ title = title_match.group(1)
307
+ return video_id, title
308
+
309
+ def _play_via_results(self, query: str) -> WorkflowResult:
310
+ """Fallback: open results and click the first video inside the page."""
311
+ self.youtube_search(query)
312
+ if not self._cdp_live():
313
+ raise BrowserWorkflowError(
314
+ f'Could not resolve a video for "{query}" (YouTube did not '
315
+ "return a parseable result and no DevTools connection is "
316
+ "available to click one). The search results are open in the "
317
+ "browser."
318
+ )
319
+ clicked = self._safe(
320
+ lambda: self._cdp.click_selector("a#video-title, ytd-video-renderer a#thumbnail")
321
+ )
322
+ if not clicked:
323
+ raise BrowserWorkflowError(
324
+ f'Opened YouTube results for "{query}" but could not start a '
325
+ "video: no playable result was found on the page."
326
+ )
327
+ popups = self._settle()
328
+ url = self._current_url() or ""
329
+ if "watch" not in url:
330
+ raise BrowserWorkflowError(
331
+ f'Clicked the first YouTube result for "{query}" but no watch '
332
+ f"page opened (currently at {url or 'an unknown page'})."
333
+ )
334
+ self._ensure_playing()
335
+ return self._verified(
336
+ f'playing the first YouTube result for "{query}"', url, popups,
337
+ expected={"browser_url": "watch"},
338
+ )
339
+
340
+ def _ensure_playing(self) -> None:
341
+ """Nudge the player if autoplay was blocked (best-effort, never fatal).
342
+
343
+ Chromium blocks autoplay with sound on pages the user has not
344
+ interacted with. A DevTools ``play()`` carries a user gesture, so it
345
+ starts reliably where a synthetic mouse click would not.
346
+ """
347
+ if not self._cdp_live():
348
+ return
349
+ self._safe(lambda: self._cdp.evaluate(
350
+ "(() => { const v = document.querySelector('video');"
351
+ " if (!v) return false;"
352
+ " if (v.paused) { v.play().catch(() => {}); }"
353
+ " return true; })()"
354
+ ))
355
+
356
+ # --- information ---------------------------------------------------------
357
+ def page_info(self) -> WorkflowResult:
358
+ """Current page title and address."""
359
+ url = self._current_url()
360
+ title = self._current_title()
361
+ if not url and not title:
362
+ return WorkflowResult("no browser page is open")
363
+ lines = [f"Title: {title}"] if title else []
364
+ if url:
365
+ lines.append(f"URL: {url}")
366
+ return WorkflowResult("\n".join(lines), url, title)
367
+
368
+ def which_browser(self) -> str:
369
+ return self._driver.default_browser().describe()
370
+
371
+ def dismiss_popups(self) -> WorkflowResult:
372
+ """Run a popup sweep on demand (the workflows do this automatically)."""
373
+ result = self._popups.sweep()
374
+ return WorkflowResult(result.describe(), popups=tuple(result.dismissed))
375
+
376
+ # --- internals -----------------------------------------------------------
377
+ def _navigate(self, url: str, *, new_tab: bool = False) -> None:
378
+ """Drive the browser to ``url`` — DevTools when live, else the OS."""
379
+ if not new_tab and self._cdp_live():
380
+ if self._safe(lambda: self._cdp.navigate(url)):
381
+ self._safe(lambda: self._cdp.wait_for_load(_LOAD_TIMEOUT_S))
382
+ return
383
+ try:
384
+ self._driver.navigate(url, new_window=False)
385
+ except Exception as exc:
386
+ raise BrowserWorkflowError(f"Could not open {url}: {exc}")
387
+ self._safe(lambda: self._cdp.wait_for_load(_LOAD_TIMEOUT_S))
388
+
389
+ def _settle(self) -> tuple[str, ...]:
390
+ """Let the page paint, then clear whatever popped up over it."""
391
+ if self._settle_s:
392
+ time.sleep(self._settle_s)
393
+ result = self._popups.sweep()
394
+ return tuple(getattr(result, "dismissed", ()) or ())
395
+
396
+ def _verified(
397
+ self,
398
+ detail: str,
399
+ url: str | None,
400
+ popups: tuple[str, ...],
401
+ *,
402
+ expected: dict[str, Any] | None = None,
403
+ title: str | None = None,
404
+ ) -> WorkflowResult:
405
+ """Confirm the browser really is where we asked it to go.
406
+
407
+ When DevTools is live the live address is authoritative and a mismatch
408
+ is a hard failure. Without it, the dispatcher's verifier still checks
409
+ the window title through the ``expected`` dict, so success is never
410
+ claimed on the strength of "we called navigate()".
411
+ """
412
+ live = self._current_url()
413
+ if live and url:
414
+ if not _same_page(live, url):
415
+ raise BrowserWorkflowError(
416
+ f"Navigation did not land: asked for {url}, browser is at {live}."
417
+ )
418
+ note = f" (cleared {', '.join(popups)})" if popups else ""
419
+ return WorkflowResult(
420
+ detail + note, url, title or self._current_title(), expected, popups
421
+ )
422
+
423
+ def _cdp_live(self) -> bool:
424
+ return bool(self._safe(lambda: self._cdp.is_available()))
425
+
426
+ def _current_url(self) -> str | None:
427
+ if self._cdp_live():
428
+ url = self._safe(lambda: self._cdp.current_url())
429
+ if url:
430
+ return str(url)
431
+ return self._safe(lambda: self._driver.current_url())
432
+
433
+ def _current_title(self) -> str | None:
434
+ if self._cdp_live():
435
+ title = self._safe(lambda: self._cdp.current_title())
436
+ if title:
437
+ return str(title)
438
+ return None
439
+
440
+ def _focus_browser_window(self) -> None:
441
+ """Bring the browser to the foreground (best-effort)."""
442
+ if self._controller is None:
443
+ return
444
+ name = self._safe(lambda: self._driver.default_browser().name)
445
+ if name:
446
+ self._safe(lambda: self._controller.focus_window(name))
447
+
448
+ def _hotkey(self, keys: list[str], what: str) -> None:
449
+ """Send a browser keyboard shortcut, focusing the browser first."""
450
+ if self._controller is None:
451
+ raise BrowserWorkflowError(
452
+ f"Cannot {what}: no desktop controller is available."
453
+ )
454
+ self._focus_browser_window()
455
+ try:
456
+ self._controller.hotkey(keys)
457
+ except Exception as exc:
458
+ raise BrowserWorkflowError(f"Could not {what}: {exc}")
459
+
460
+ def _fetch(self, url: str) -> str | None:
461
+ """GET a page as text (None on any failure — callers degrade).
462
+
463
+ Injectable via the constructor so tests, and any offline deployment,
464
+ can resolve results without reaching the network.
465
+ """
466
+ if self._fetch_impl is not None:
467
+ return self._safe(lambda: self._fetch_impl(url))
468
+ request = urllib.request.Request(
469
+ url,
470
+ headers={
471
+ "User-Agent": _UA,
472
+ "Accept-Language": "en-US,en;q=0.9",
473
+ # Skip the EU consent interstitial that otherwise replaces the
474
+ # results payload for unauthenticated fetches.
475
+ "Cookie": "CONSENT=YES+1",
476
+ },
477
+ )
478
+ try:
479
+ with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT_S) as resp:
480
+ return resp.read().decode("utf-8", "replace")
481
+ except (urllib.error.URLError, OSError, ValueError):
482
+ return None
483
+
484
+ @staticmethod
485
+ def _safe(call):
486
+ """Run a best-effort driver call; any failure becomes ``None``."""
487
+ try:
488
+ return call()
489
+ except Exception:
490
+ return None
491
+
492
+
493
+ # --- helpers -----------------------------------------------------------------
494
+
495
+ def _normalize(url: str) -> str:
496
+ """Coerce user/AI-supplied text into a real URL."""
497
+ url = (url or "").strip()
498
+ if not url:
499
+ return ""
500
+ if url.startswith(("http://", "https://", "file://", "about:", "ftp://")):
501
+ return url
502
+ return "https://" + url
503
+
504
+
505
+ def _fragment(url: str) -> str:
506
+ """A short, stable piece of a URL for the verifier to match on."""
507
+ stripped = (url or "").split("//")[-1]
508
+ host = stripped.split("/")[0]
509
+ return (host or stripped)[:30]
510
+
511
+
512
+ def _same_page(live: str, wanted: str) -> bool:
513
+ """Whether the live address satisfies the one we asked for.
514
+
515
+ Deliberately forgiving: sites append tracking parameters, normalise
516
+ ``www.``, and redirect ``/`` to a locale path. The check that matters is
517
+ that we are on the intended *site and resource*, not a byte-identical URL.
518
+ """
519
+ live_p = urllib.parse.urlparse(live.lower())
520
+ want_p = urllib.parse.urlparse(wanted.lower())
521
+ live_host = live_p.netloc.removeprefix("www.")
522
+ want_host = want_p.netloc.removeprefix("www.")
523
+ if want_host and live_host and want_host != live_host:
524
+ return False
525
+ # A watch URL must still be the same video, not merely the same host.
526
+ want_video = urllib.parse.parse_qs(want_p.query).get("v", [""])[0]
527
+ if want_video:
528
+ return urllib.parse.parse_qs(live_p.query).get("v", [""])[0] == want_video
529
+ want_path = want_p.path.rstrip("/")
530
+ if want_path and want_path not in live_p.path.rstrip("/"):
531
+ # A search results page keeps its path but carries the query; treat a
532
+ # matching path prefix as sufficient.
533
+ return False
534
+ return True
535
+
536
+
537
+ def _pick_tab(tabs: list, wanted: str):
538
+ """Choose the tab matching a title/URL fragment (or the next one)."""
539
+ if not tabs:
540
+ return None
541
+ if not wanted:
542
+ return tabs[1] if len(tabs) > 1 else tabs[0]
543
+ for tab in tabs:
544
+ if wanted in (tab.title or "").lower() or wanted in (tab.url or "").lower():
545
+ return tab
546
+ return None