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,301 @@
|
|
|
1
|
+
"""Web extraction: structured page data over the DevTools DOM tier.
|
|
2
|
+
|
|
3
|
+
Turns "what does this page say?" into a deterministic JSON answer using the
|
|
4
|
+
page's own DOM (:mod:`.browser_cdp`), never screenshots. Every extraction
|
|
5
|
+
carries source traceability (url, title, timestamp, method) so answers can
|
|
6
|
+
cite where they came from — and so the memory layer can store provenance.
|
|
7
|
+
|
|
8
|
+
Synchronization is state-based: ``wait_for_element`` /
|
|
9
|
+
``wait_for_page_state`` poll the live DOM with bounded timeouts; there are
|
|
10
|
+
no blind sleeps. All JS evaluation is injectable, making the module fully
|
|
11
|
+
unit-testable without a browser.
|
|
12
|
+
|
|
13
|
+
The one intentional limitation: without a DevTools connection (browser not
|
|
14
|
+
started with the debugging port), DOM extraction is unavailable and
|
|
15
|
+
:class:`WebPageNotConnectedError` says so — falling back to reading pixels
|
|
16
|
+
would be the exact anti-pattern this module exists to remove.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..core.errors import ExtractionError, WebPageNotConnectedError
|
|
26
|
+
from ..core.limits import MAX_WAIT_ELEMENT_S, MAX_WAIT_NAVIGATION_S, MAX_WAIT_POLL_S, clamp_wait
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class PageSource:
|
|
31
|
+
"""Provenance record attached to every extraction."""
|
|
32
|
+
|
|
33
|
+
url: str
|
|
34
|
+
title: str
|
|
35
|
+
retrieved_at: str
|
|
36
|
+
method: str = "dom"
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"source_url": self.url, "page_title": self.title,
|
|
41
|
+
"retrieved_at": self.retrieved_at, "extraction_method": self.method,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(slots=True)
|
|
46
|
+
class Extraction:
|
|
47
|
+
"""One structured extraction with its source."""
|
|
48
|
+
|
|
49
|
+
kind: str # text | headings | links | tables | metadata | find
|
|
50
|
+
data: Any
|
|
51
|
+
source: PageSource
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
out = {"kind": self.kind, "content": self.data}
|
|
55
|
+
out.update(self.source.to_dict())
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- JS payloads (run inside the page; return JSON-safe values) ---------------------
|
|
60
|
+
|
|
61
|
+
_JS_TEXT = """(() => {
|
|
62
|
+
const clone = document.body.cloneNode(true);
|
|
63
|
+
clone.querySelectorAll('script,style,noscript,svg').forEach(n => n.remove());
|
|
64
|
+
return (clone.innerText || '').replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, 20000);
|
|
65
|
+
})()"""
|
|
66
|
+
|
|
67
|
+
_JS_HEADINGS = """(() => {
|
|
68
|
+
const out = [];
|
|
69
|
+
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(h => {
|
|
70
|
+
const r = h.getBoundingClientRect();
|
|
71
|
+
if (r.width <= 0 && r.height <= 0) return;
|
|
72
|
+
out.push({level: parseInt(h.tagName.slice(1)), text: (h.innerText || '').trim()});
|
|
73
|
+
});
|
|
74
|
+
return out.slice(0, 100);
|
|
75
|
+
})()"""
|
|
76
|
+
|
|
77
|
+
_JS_LINKS = """(() => {
|
|
78
|
+
const out = [];
|
|
79
|
+
document.querySelectorAll('a[href]').forEach(a => {
|
|
80
|
+
const r = a.getBoundingClientRect();
|
|
81
|
+
if (r.width <= 0 && r.height <= 0) return;
|
|
82
|
+
out.push({text: (a.innerText || '').trim().slice(0, 200), href: a.href});
|
|
83
|
+
});
|
|
84
|
+
return out.slice(0, 300);
|
|
85
|
+
})()"""
|
|
86
|
+
|
|
87
|
+
_JS_TABLES = """(() => {
|
|
88
|
+
const tables = [];
|
|
89
|
+
document.querySelectorAll('table').forEach(t => {
|
|
90
|
+
const rows = [];
|
|
91
|
+
t.querySelectorAll('tr').forEach(tr => {
|
|
92
|
+
const cells = [];
|
|
93
|
+
tr.querySelectorAll('th,td').forEach(td =>
|
|
94
|
+
cells.push((td.innerText || '').trim().slice(0, 200)));
|
|
95
|
+
if (cells.length) rows.push(cells);
|
|
96
|
+
});
|
|
97
|
+
if (rows.length) tables.push(rows.slice(0, 200));
|
|
98
|
+
});
|
|
99
|
+
return tables.slice(0, 30);
|
|
100
|
+
})()"""
|
|
101
|
+
|
|
102
|
+
_JS_LISTS = """(() => {
|
|
103
|
+
const lists = [];
|
|
104
|
+
document.querySelectorAll('ul,ol').forEach(l => {
|
|
105
|
+
const items = [];
|
|
106
|
+
l.querySelectorAll(':scope > li').forEach(li =>
|
|
107
|
+
items.push((li.innerText || '').trim().slice(0, 300)));
|
|
108
|
+
if (items.length) lists.push(items.slice(0, 100));
|
|
109
|
+
});
|
|
110
|
+
return lists.slice(0, 50);
|
|
111
|
+
})()"""
|
|
112
|
+
|
|
113
|
+
_JS_META = """(() => {
|
|
114
|
+
const pick = (sel, attr) => {
|
|
115
|
+
const el = document.querySelector(sel);
|
|
116
|
+
return el ? (el.getAttribute(attr) || el.content || '') : '';
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
description: pick('meta[name="description"]', 'content')
|
|
120
|
+
|| pick('meta[property="og:description"]', 'content'),
|
|
121
|
+
og_title: pick('meta[property="og:title"]', 'content'),
|
|
122
|
+
og_image: pick('meta[property="og:image"]', 'content'),
|
|
123
|
+
canonical: pick('link[rel="canonical"]', 'href'),
|
|
124
|
+
lang: document.documentElement.lang || '',
|
|
125
|
+
charset: document.characterSet || '',
|
|
126
|
+
};
|
|
127
|
+
})()"""
|
|
128
|
+
|
|
129
|
+
_JS_FIND = """((needle) => {
|
|
130
|
+
const want = needle.toLowerCase();
|
|
131
|
+
const out = [];
|
|
132
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
133
|
+
let node;
|
|
134
|
+
while ((node = walker.nextNode()) && out.length < 40) {
|
|
135
|
+
const text = (node.textContent || '').trim();
|
|
136
|
+
if (!text) continue;
|
|
137
|
+
const idx = text.toLowerCase().indexOf(want);
|
|
138
|
+
if (idx === -1) continue;
|
|
139
|
+
let el = node.parentElement;
|
|
140
|
+
while (el && el !== document.body) {
|
|
141
|
+
const r = el.getBoundingClientRect();
|
|
142
|
+
if (r.width > 0 || r.height > 0) break;
|
|
143
|
+
el = el.parentElement;
|
|
144
|
+
}
|
|
145
|
+
const a = el && el.closest ? el.closest('a') : null;
|
|
146
|
+
out.push({
|
|
147
|
+
text: text.slice(0, 300),
|
|
148
|
+
tag: el ? el.tagName.toLowerCase() : '',
|
|
149
|
+
href: a ? a.href : null,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
})(%(needle)s)"""
|
|
154
|
+
|
|
155
|
+
_JS_WAIT_ELEMENT = """((selector) => {
|
|
156
|
+
const el = document.querySelector(selector);
|
|
157
|
+
if (!el) return false;
|
|
158
|
+
const r = el.getBoundingClientRect();
|
|
159
|
+
return r.width > 0 || r.height > 0;
|
|
160
|
+
})(%(selector)s)"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class WebExtractor:
|
|
164
|
+
"""DOM-level page extraction (injectable evaluate for tests)."""
|
|
165
|
+
|
|
166
|
+
def __init__(self, cdp: Any = None) -> None:
|
|
167
|
+
if cdp is None:
|
|
168
|
+
from .computer import browser_cdp as cdp # type: ignore
|
|
169
|
+
self._cdp = cdp
|
|
170
|
+
|
|
171
|
+
# --- plumbing ---------------------------------------------------------------
|
|
172
|
+
def _require_live(self) -> None:
|
|
173
|
+
try:
|
|
174
|
+
live = bool(self._cdp.is_available())
|
|
175
|
+
except Exception:
|
|
176
|
+
live = False
|
|
177
|
+
if not live:
|
|
178
|
+
raise WebPageNotConnectedError()
|
|
179
|
+
|
|
180
|
+
def _eval(self, js: str) -> Any:
|
|
181
|
+
try:
|
|
182
|
+
return self._cdp.evaluate(js)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
raise ExtractionError(f"page evaluation failed: {exc}")
|
|
185
|
+
|
|
186
|
+
def _source(self) -> PageSource:
|
|
187
|
+
url = self._eval("location.href") or ""
|
|
188
|
+
title = self._eval("document.title") or ""
|
|
189
|
+
return PageSource(
|
|
190
|
+
url=str(url), title=str(title),
|
|
191
|
+
retrieved_at=time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# --- extractions ---------------------------------------------------------------
|
|
195
|
+
def page_text(self) -> Extraction:
|
|
196
|
+
self._require_live()
|
|
197
|
+
return Extraction("text", self._eval(_JS_TEXT) or "", self._source())
|
|
198
|
+
|
|
199
|
+
def headings(self) -> Extraction:
|
|
200
|
+
self._require_live()
|
|
201
|
+
return Extraction("headings", self._eval(_JS_HEADINGS) or [], self._source())
|
|
202
|
+
|
|
203
|
+
def links(self, *, filter_text: str = "") -> Extraction:
|
|
204
|
+
self._require_live()
|
|
205
|
+
data = self._eval(_JS_LINKS) or []
|
|
206
|
+
if filter_text:
|
|
207
|
+
low = filter_text.lower()
|
|
208
|
+
data = [l for l in data if low in (l.get("text") or "").lower()
|
|
209
|
+
or low in (l.get("href") or "").lower()]
|
|
210
|
+
return Extraction("links", data, self._source())
|
|
211
|
+
|
|
212
|
+
def tables(self) -> Extraction:
|
|
213
|
+
self._require_live()
|
|
214
|
+
return Extraction("tables", self._eval(_JS_TABLES) or [], self._source())
|
|
215
|
+
|
|
216
|
+
def lists(self) -> Extraction:
|
|
217
|
+
self._require_live()
|
|
218
|
+
return Extraction("lists", self._eval(_JS_LISTS) or [], self._source())
|
|
219
|
+
|
|
220
|
+
def metadata(self) -> Extraction:
|
|
221
|
+
self._require_live()
|
|
222
|
+
return Extraction("metadata", self._eval(_JS_META) or {}, self._source())
|
|
223
|
+
|
|
224
|
+
def structured_data(self) -> Extraction:
|
|
225
|
+
"""JSON-LD / microdata blocks sites use for products, recipes, etc."""
|
|
226
|
+
self._require_live()
|
|
227
|
+
js = """(() => {
|
|
228
|
+
const out = [];
|
|
229
|
+
document.querySelectorAll('script[type="application/ld+json"]').forEach(s => {
|
|
230
|
+
try { out.push(JSON.parse(s.textContent)); } catch (e) {}
|
|
231
|
+
});
|
|
232
|
+
return out.slice(0, 20);
|
|
233
|
+
})()"""
|
|
234
|
+
return Extraction("structured_data", self._eval(js) or [], self._source())
|
|
235
|
+
|
|
236
|
+
def find_on_page(self, query: str) -> Extraction:
|
|
237
|
+
"""Locate text/elements matching a phrase, with their context."""
|
|
238
|
+
self._require_live()
|
|
239
|
+
needle = (query or "").strip()
|
|
240
|
+
if not needle:
|
|
241
|
+
raise ExtractionError("A search phrase is required.")
|
|
242
|
+
import json as _json
|
|
243
|
+
|
|
244
|
+
js = _JS_FIND % {"needle": _json.dumps(needle)}
|
|
245
|
+
return Extraction("find", self._eval(js) or [], self._source())
|
|
246
|
+
|
|
247
|
+
def page_info(self) -> dict[str, Any]:
|
|
248
|
+
self._require_live()
|
|
249
|
+
src = self._source()
|
|
250
|
+
return {"url": src.url, "title": src.title, "method": src.method}
|
|
251
|
+
|
|
252
|
+
# --- waits (state-based, bounded) -------------------------------------------------
|
|
253
|
+
def wait_for_element(self, selector: str,
|
|
254
|
+
timeout_s: float = MAX_WAIT_ELEMENT_S) -> bool:
|
|
255
|
+
"""Poll until a selector exists and is visible. False on timeout."""
|
|
256
|
+
self._require_live()
|
|
257
|
+
import json as _json
|
|
258
|
+
|
|
259
|
+
js = _JS_WAIT_ELEMENT % {"selector": _json.dumps(selector)}
|
|
260
|
+
deadline = time.monotonic() + clamp_wait(timeout_s, 30.0)
|
|
261
|
+
while time.monotonic() < deadline:
|
|
262
|
+
if self._eval(js) is True:
|
|
263
|
+
return True
|
|
264
|
+
time.sleep(MAX_WAIT_POLL_S)
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
def wait_for_navigation(self, *, timeout_s: float = MAX_WAIT_NAVIGATION_S) -> bool:
|
|
268
|
+
"""Wait until the document reaches 'complete' (bounded)."""
|
|
269
|
+
self._require_live()
|
|
270
|
+
deadline = time.monotonic() + clamp_wait(timeout_s, 30.0)
|
|
271
|
+
while time.monotonic() < deadline:
|
|
272
|
+
try:
|
|
273
|
+
if self._eval("document.readyState === 'complete'") is True:
|
|
274
|
+
return True
|
|
275
|
+
except ExtractionError:
|
|
276
|
+
pass # mid-navigation evaluations can transiently fail
|
|
277
|
+
time.sleep(MAX_WAIT_POLL_S)
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --- shared instance ---------------------------------------------------------------
|
|
282
|
+
_EXTRACTOR: WebExtractor | None = None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_web_extractor(cdp: Any = None) -> WebExtractor:
|
|
286
|
+
global _EXTRACTOR
|
|
287
|
+
if _EXTRACTOR is None or cdp is not None:
|
|
288
|
+
if _EXTRACTOR is None:
|
|
289
|
+
_EXTRACTOR = WebExtractor(cdp=cdp)
|
|
290
|
+
return _EXTRACTOR
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def reset_web_extractor() -> None:
|
|
294
|
+
global _EXTRACTOR
|
|
295
|
+
_EXTRACTOR = None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
__all__ = [
|
|
299
|
+
"PageSource", "Extraction", "WebExtractor",
|
|
300
|
+
"get_web_extractor", "reset_web_extractor",
|
|
301
|
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Popup Manager: make browser interruptions invisible to the planner.
|
|
2
|
+
|
|
3
|
+
Every real browsing session is punctuated by modals the user never asked for —
|
|
4
|
+
a cookie/consent wall, "Translate this page?", "Show notifications?", a sign-in
|
|
5
|
+
interstitial, a camera/location permission prompt. Historically the AI saw
|
|
6
|
+
these, reasoned about them, and burned turns emitting clicks to clear them.
|
|
7
|
+
That is the wrong boundary: dismissing chrome is mechanical work with a known
|
|
8
|
+
answer, so it belongs to the engine.
|
|
9
|
+
|
|
10
|
+
:class:`PopupManager` sweeps for them automatically before and after every
|
|
11
|
+
browser action. The AI is never told a popup existed; skills simply behave as
|
|
12
|
+
though the page were clean.
|
|
13
|
+
|
|
14
|
+
Two detection surfaces, in ladder order:
|
|
15
|
+
|
|
16
|
+
1. **DOM** (:mod:`.browser_cdp`) — in-page banners: consent walls, sign-in
|
|
17
|
+
interstitials, newsletter modals. Matched by CSS selector for the big known
|
|
18
|
+
offenders, then by button text ("Accept all", "No thanks") for the long
|
|
19
|
+
tail. Clicks happen *in the page*, so they cannot miss.
|
|
20
|
+
2. **Accessibility tree** — browser-*chrome* bubbles that live outside the
|
|
21
|
+
document and therefore have no DOM at all: Chrome's translate bar, the
|
|
22
|
+
notification/location permission bubble, the password-save prompt.
|
|
23
|
+
|
|
24
|
+
Both degrade to no-ops when unavailable, and the sweep never raises: a popup
|
|
25
|
+
sweep that fails must not fail the workflow that called it.
|
|
26
|
+
|
|
27
|
+
Deterministic, offline, no AI.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
# --- what counts as a popup --------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class PopupRule:
|
|
41
|
+
"""One recognisable popup and how to make it go away."""
|
|
42
|
+
|
|
43
|
+
kind: str # translate | cookies | notifications | signin | permissions
|
|
44
|
+
label: str # human description, for the execution log
|
|
45
|
+
# CSS selectors whose presence means this popup is on screen. The first
|
|
46
|
+
# match also acts as the click target when ``accept_text`` finds nothing.
|
|
47
|
+
selectors: tuple[str, ...] = ()
|
|
48
|
+
# Button captions to click, in preference order. Matched case-insensitively
|
|
49
|
+
# against visible text / aria-label / title.
|
|
50
|
+
accept_text: tuple[str, ...] = ()
|
|
51
|
+
# Accessibility-tree element names for browser-chrome popups with no DOM.
|
|
52
|
+
chrome_names: tuple[str, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Ordered most-blocking first: a consent wall blocks the whole page, while a
|
|
56
|
+
# translate bar merely shifts it. Selector lists cover the dominant consent
|
|
57
|
+
# platforms (OneTrust, Quantcast, Cookiebot, Didomi, Osano, TrustArc, Usercentrics)
|
|
58
|
+
# plus Google/YouTube's own consent frame, which together account for the
|
|
59
|
+
# overwhelming majority of real-world banners.
|
|
60
|
+
RULES: tuple[PopupRule, ...] = (
|
|
61
|
+
PopupRule(
|
|
62
|
+
kind="cookies",
|
|
63
|
+
label="cookie / consent banner",
|
|
64
|
+
selectors=(
|
|
65
|
+
"#onetrust-banner-sdk", "#onetrust-consent-sdk",
|
|
66
|
+
".qc-cmp2-container", "#qc-cmp2-ui",
|
|
67
|
+
"#CybotCookiebotDialog",
|
|
68
|
+
"#didomi-popup", ".didomi-popup-container",
|
|
69
|
+
".osano-cm-dialog", "#truste-consent-track",
|
|
70
|
+
"#usercentrics-root", "#cmpbox",
|
|
71
|
+
"[aria-label='Cookie banner']", "[id*='cookie-banner']",
|
|
72
|
+
"[class*='cookie-consent']", "[class*='cookie-banner']",
|
|
73
|
+
"c-wiz[jsrenderer] form[action*='consent']",
|
|
74
|
+
),
|
|
75
|
+
accept_text=(
|
|
76
|
+
# Consent captions only. "Allow all" and friends are deliberately
|
|
77
|
+
# absent: they read as granting a permission, so _forbidden blocks
|
|
78
|
+
# them anyway — a consent wall is cleared by accepting *cookies*,
|
|
79
|
+
# never by clicking a bare "Allow".
|
|
80
|
+
"accept all cookies", "accept all", "accept cookies",
|
|
81
|
+
"i agree", "agree to all", "got it", "understood",
|
|
82
|
+
"reject all", "essential only", "only necessary",
|
|
83
|
+
"accept", "agree", "ok",
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
PopupRule(
|
|
87
|
+
kind="signin",
|
|
88
|
+
label="sign-in / account interstitial",
|
|
89
|
+
selectors=(
|
|
90
|
+
"#credential_picker_container", "iframe[src*='accounts.google.com/gsi']",
|
|
91
|
+
"[aria-label='Sign in to YouTube']",
|
|
92
|
+
"tp-yt-paper-dialog:has(ytd-consent-bump-v2-lightbox)",
|
|
93
|
+
"[data-testid='sheetDialog']",
|
|
94
|
+
"[class*='login-modal']", "[class*='signup-modal']", "[id*='login-overlay']",
|
|
95
|
+
),
|
|
96
|
+
# Never click "Sign in" — dismissing must not start an auth flow.
|
|
97
|
+
accept_text=(
|
|
98
|
+
"not now", "no thanks", "stay signed out", "maybe later",
|
|
99
|
+
"continue without signing in", "skip for now", "dismiss", "close",
|
|
100
|
+
),
|
|
101
|
+
chrome_names=("Save password", "Never", "Not now"),
|
|
102
|
+
),
|
|
103
|
+
PopupRule(
|
|
104
|
+
kind="translate",
|
|
105
|
+
label="translate bar",
|
|
106
|
+
# Chrome's translate UI is browser chrome, not page DOM, so it is
|
|
107
|
+
# detected through the accessibility tree only.
|
|
108
|
+
chrome_names=(
|
|
109
|
+
"Translate this page?", "Translate page?", "Nope", "No thanks",
|
|
110
|
+
"Never translate", "Close", "Translate",
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
PopupRule(
|
|
114
|
+
kind="notifications",
|
|
115
|
+
label="notification permission prompt",
|
|
116
|
+
selectors=("[class*='push-notification']", "[id*='notification-prompt']"),
|
|
117
|
+
accept_text=("block", "no thanks", "not now", "don't allow", "later"),
|
|
118
|
+
chrome_names=(
|
|
119
|
+
"Show notifications", "Block", "Don't allow", "Never allow",
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
PopupRule(
|
|
123
|
+
kind="permissions",
|
|
124
|
+
label="site permission prompt (camera / mic / location)",
|
|
125
|
+
chrome_names=(
|
|
126
|
+
"Use your microphone", "Use your camera", "Know your location",
|
|
127
|
+
"Block", "Don't allow", "Never allow",
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Captions that must never be clicked while dismissing: clearing a popup must
|
|
133
|
+
# never grant a permission, start a sign-in, or accept a purchase on the user's
|
|
134
|
+
# behalf. Matched against the caption as a whole or as a *word sequence*, never
|
|
135
|
+
# as a bare substring — "allow" appearing inside "Don't allow" (the correct way
|
|
136
|
+
# to decline a notification prompt) must not block it.
|
|
137
|
+
_NEVER_CLICK = (
|
|
138
|
+
"sign in", "log in", "login", "sign up", "subscribe", "buy", "purchase",
|
|
139
|
+
"allow", "allow all", "enable notifications", "turn on", "yes, i'm in",
|
|
140
|
+
"create account",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Words that flip a caption from affirmative to declining. A caption carrying
|
|
144
|
+
# one of these is a dismissal even when it contains a forbidden word.
|
|
145
|
+
_NEGATORS = (
|
|
146
|
+
"no", "not", "never", "don't", "dont", "do not", "block", "deny",
|
|
147
|
+
"reject", "decline", "without", "later", "skip", "close", "dismiss",
|
|
148
|
+
"nope", "stay", "essential only", "necessary only",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(slots=True)
|
|
153
|
+
class SweepResult:
|
|
154
|
+
"""What one sweep dismissed."""
|
|
155
|
+
|
|
156
|
+
dismissed: list[str] = field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
def __bool__(self) -> bool:
|
|
159
|
+
return bool(self.dismissed)
|
|
160
|
+
|
|
161
|
+
def describe(self) -> str:
|
|
162
|
+
if not self.dismissed:
|
|
163
|
+
return "no popups present"
|
|
164
|
+
return "dismissed " + ", ".join(self.dismissed)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PopupManager:
|
|
168
|
+
"""Detects and clears browser popups without involving the AI.
|
|
169
|
+
|
|
170
|
+
``cdp`` and ``controller`` are injectable so the logic is unit-testable
|
|
171
|
+
with no live browser.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(self, cdp: Any = None, controller: Any = None, rules: Any = None) -> None:
|
|
175
|
+
if cdp is None:
|
|
176
|
+
from . import browser_cdp as cdp # type: ignore
|
|
177
|
+
self._cdp = cdp
|
|
178
|
+
self._controller = controller
|
|
179
|
+
self._rules = tuple(rules) if rules is not None else RULES
|
|
180
|
+
|
|
181
|
+
# --- public API ----------------------------------------------------------
|
|
182
|
+
def sweep(self, max_passes: int = 2) -> SweepResult:
|
|
183
|
+
"""Dismiss every popup currently on screen.
|
|
184
|
+
|
|
185
|
+
Runs more than one pass because popups stack: dismissing a consent wall
|
|
186
|
+
frequently reveals the sign-in prompt that was behind it. Stops early
|
|
187
|
+
once a pass finds nothing, so the common clean-page case costs one
|
|
188
|
+
cheap DOM query.
|
|
189
|
+
"""
|
|
190
|
+
result = SweepResult()
|
|
191
|
+
for _ in range(max(1, max_passes)):
|
|
192
|
+
found_this_pass = False
|
|
193
|
+
for rule in self._rules:
|
|
194
|
+
if self._dismiss(rule):
|
|
195
|
+
result.dismissed.append(rule.kind)
|
|
196
|
+
found_this_pass = True
|
|
197
|
+
if not found_this_pass:
|
|
198
|
+
break
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def present(self) -> list[str]:
|
|
202
|
+
"""Which popup kinds are detectable right now (diagnostic)."""
|
|
203
|
+
return [rule.kind for rule in self._rules if self._detect(rule)]
|
|
204
|
+
|
|
205
|
+
# --- detection -----------------------------------------------------------
|
|
206
|
+
def _detect(self, rule: PopupRule) -> bool:
|
|
207
|
+
"""Whether this popup appears to be on screen (DOM, then chrome)."""
|
|
208
|
+
for selector in rule.selectors:
|
|
209
|
+
if self._safe(lambda s=selector: self._cdp.exists(s)) is True:
|
|
210
|
+
return True
|
|
211
|
+
return self._chrome_element(rule) is not None
|
|
212
|
+
|
|
213
|
+
def _chrome_element(self, rule: PopupRule):
|
|
214
|
+
"""Find a browser-chrome popup in the accessibility tree."""
|
|
215
|
+
if not rule.chrome_names or self._controller is None:
|
|
216
|
+
return None
|
|
217
|
+
vision = getattr(self._controller, "vision", None)
|
|
218
|
+
if vision is None:
|
|
219
|
+
return None
|
|
220
|
+
elements = self._safe(lambda: vision.snapshot(None))
|
|
221
|
+
if not elements:
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
_title, items = elements
|
|
225
|
+
except (TypeError, ValueError):
|
|
226
|
+
return None
|
|
227
|
+
for wanted in rule.chrome_names:
|
|
228
|
+
needle = wanted.lower()
|
|
229
|
+
for el in items or []:
|
|
230
|
+
name = (getattr(el, "name", "") or "").strip().lower()
|
|
231
|
+
if name and needle in name:
|
|
232
|
+
return el
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# --- dismissal -----------------------------------------------------------
|
|
236
|
+
def _dismiss(self, rule: PopupRule) -> bool:
|
|
237
|
+
"""Try to clear one popup. Returns whether anything was dismissed."""
|
|
238
|
+
if not self._detect(rule):
|
|
239
|
+
return False
|
|
240
|
+
# 1) In-page button by caption — the precise, reliable path.
|
|
241
|
+
for caption in rule.accept_text:
|
|
242
|
+
if _forbidden(caption):
|
|
243
|
+
continue
|
|
244
|
+
if self._safe(lambda c=caption: self._cdp.click_text(c)) is True:
|
|
245
|
+
return True
|
|
246
|
+
# 2) The banner's own close control, when captions did not match.
|
|
247
|
+
for selector in rule.selectors:
|
|
248
|
+
close = f"{selector} [aria-label*='close' i], {selector} button.close"
|
|
249
|
+
if self._safe(lambda s=close: self._cdp.click_selector(s)) is True:
|
|
250
|
+
return True
|
|
251
|
+
# 3) Browser-chrome bubble: click it through the accessibility tree.
|
|
252
|
+
if self._click_chrome(rule):
|
|
253
|
+
return True
|
|
254
|
+
# 4) Last resort: Esc closes most transient bubbles and never submits.
|
|
255
|
+
return self._press_escape(rule)
|
|
256
|
+
|
|
257
|
+
def _click_chrome(self, rule: PopupRule) -> bool:
|
|
258
|
+
"""Click a chrome popup's dismiss control via the accessibility tree."""
|
|
259
|
+
if self._controller is None:
|
|
260
|
+
return False
|
|
261
|
+
# Only ever click a *dismissing* caption, never the affirmative one.
|
|
262
|
+
for wanted in rule.chrome_names:
|
|
263
|
+
if not _is_dismissive(wanted) or _forbidden(wanted):
|
|
264
|
+
continue
|
|
265
|
+
element = self._chrome_element(
|
|
266
|
+
PopupRule(kind=rule.kind, label=rule.label, chrome_names=(wanted,))
|
|
267
|
+
)
|
|
268
|
+
if element is None:
|
|
269
|
+
continue
|
|
270
|
+
clicked = self._safe(
|
|
271
|
+
lambda el=element: self._controller.mouse_click(el.x, el.y)
|
|
272
|
+
)
|
|
273
|
+
if clicked is not None:
|
|
274
|
+
return True
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
def _press_escape(self, rule: PopupRule) -> bool:
|
|
278
|
+
"""Send Esc, then confirm the popup actually went away.
|
|
279
|
+
|
|
280
|
+
Esc is only reported as a dismissal when detection stops firing —
|
|
281
|
+
otherwise a stubborn banner would be logged as cleared while still
|
|
282
|
+
blocking the page.
|
|
283
|
+
"""
|
|
284
|
+
if self._controller is None:
|
|
285
|
+
return False
|
|
286
|
+
if self._safe(lambda: self._controller.hotkey(["esc"])) is None:
|
|
287
|
+
return False
|
|
288
|
+
return not self._detect(rule)
|
|
289
|
+
|
|
290
|
+
# --- plumbing ------------------------------------------------------------
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _safe(call):
|
|
293
|
+
"""Run a driver call, converting any failure into ``None``.
|
|
294
|
+
|
|
295
|
+
A popup sweep is opportunistic housekeeping; if the browser is gone or
|
|
296
|
+
DevTools is unreachable, the workflow continues unaffected.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
return call()
|
|
300
|
+
except Exception:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _forbidden(caption: str) -> bool:
|
|
305
|
+
"""Whether clicking this caption could act on the user's behalf.
|
|
306
|
+
|
|
307
|
+
A negated caption ("Don't allow", "Reject all") is always safe: it is the
|
|
308
|
+
*decline* control, and it frequently contains the very word that makes the
|
|
309
|
+
affirmative version dangerous. Checking negation first is what keeps
|
|
310
|
+
"Don't allow" clickable while "Allow" stays blocked.
|
|
311
|
+
"""
|
|
312
|
+
low = " ".join((caption or "").strip().lower().split())
|
|
313
|
+
if not low:
|
|
314
|
+
return True
|
|
315
|
+
if _is_dismissive(low):
|
|
316
|
+
return False
|
|
317
|
+
# Whole-caption match, or the forbidden phrase appearing as whole words.
|
|
318
|
+
return any(bad == low or _has_phrase(low, bad) for bad in _NEVER_CLICK)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _has_phrase(caption: str, phrase: str) -> bool:
|
|
322
|
+
"""Whether ``phrase`` occurs in ``caption`` on word boundaries."""
|
|
323
|
+
return re.search(rf"(?<!\w){re.escape(phrase)}(?!\w)", caption) is not None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _is_dismissive(caption: str) -> bool:
|
|
327
|
+
"""Whether a caption declines rather than accepts."""
|
|
328
|
+
low = " ".join((caption or "").strip().lower().split())
|
|
329
|
+
return any(_has_phrase(low, word) for word in _NEGATORS)
|