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,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)