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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- 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
|