omnius 1.0.855 → 1.0.856

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.
@@ -21,6 +21,9 @@ import json
21
21
  import subprocess
22
22
  import sys
23
23
  import importlib
24
+ import re
25
+ from datetime import timedelta
26
+ from urllib.parse import urljoin, urlparse
24
27
  from typing import Any
25
28
 
26
29
  # ---------------------------------------------------------------------------
@@ -86,182 +89,160 @@ def ensure_playwright():
86
89
 
87
90
 
88
91
  # ---------------------------------------------------------------------------
89
- # BeautifulSoup crawler
92
+ # Complete page observations, with explicit resource failures (never prefixes)
90
93
  # ---------------------------------------------------------------------------
91
-
92
- async def crawl_beautifulsoup(
93
- start_url: str,
94
- max_pages: int = 5,
95
- max_depth: int = 1,
96
- extract: str = "all",
97
- ) -> list[dict[str, Any]]:
98
- """Crawl using BeautifulSoup (HTTP-based, fast, no JS rendering)."""
99
- from crawlee.beautifulsoup_crawler import BeautifulSoupCrawler, BeautifulSoupCrawlingContext
100
- from crawlee import ConcurrencySettings
101
-
102
- results: list[dict[str, Any]] = []
103
- pages_crawled = 0
104
-
105
- crawler = BeautifulSoupCrawler(
106
- max_request_retries=2,
107
- request_handler_timeout=asyncio.timedelta(seconds=30),
108
- max_requests_per_crawl=max_pages,
109
- concurrency_settings=ConcurrencySettings(
110
- max_concurrency=3,
111
- ),
112
- )
113
-
114
- @crawler.router.default_handler
115
- async def handler(context: BeautifulSoupCrawlingContext) -> None:
116
- nonlocal pages_crawled
117
- if pages_crawled >= max_pages:
94
+ MAX_PAGE_BYTES = 5 * 1024 * 1024
95
+ MAX_RESULT_BYTES = 8 * 1024 * 1024
96
+ DESTINATION_ATTRIBUTES = ("href", "src", "srcset", "action", "formaction", "poster", "data-src", "data-srcset", "data-original")
97
+
98
+
99
+ def extract_page(soup: Any, url: str) -> dict[str, Any]:
100
+ """Retain the entire parser/DOM snapshot and clean complete body view.
101
+
102
+ Snapshot provenance is deliberately not an HTTP-status or full-site claim.
103
+ Script/style/template payloads stay in the retained snapshot; JSON-LD is
104
+ additionally exposed without discarding malformed or duplicate entries.
105
+ """
106
+ from bs4 import Comment, NavigableString, Tag
107
+ snapshot = str(soup)
108
+ if len(snapshot.encode("utf-8")) > MAX_PAGE_BYTES:
109
+ raise ValueError("Page snapshot exceeded the complete-page resource limit; no prefix was retained")
110
+ body = soup.body or soup
111
+ links = [{"url": urljoin(url, str(tag.get("href"))), "text": tag.get_text(" ", strip=True)}
112
+ for tag in soup.find_all("a", href=True)]
113
+ metadata = [{"name": str(tag.get("name") or tag.get("property") or tag.get("http-equiv") or ""),
114
+ "content": str(tag.get("content") or "")} for tag in soup.find_all("meta")]
115
+ structured = []
116
+ for tag in soup.find_all("script"):
117
+ if "ld+json" in str(tag.get("type", "")).lower():
118
+ payload = tag.string if tag.string is not None else tag.get_text()
119
+ try:
120
+ structured.append({"type": str(tag.get("type")), "value": json.loads(payload), "raw": payload})
121
+ except (ValueError, TypeError):
122
+ structured.append({"type": str(tag.get("type")), "raw": payload, "parse_error": True})
123
+
124
+ parts: list[str] = []
125
+ def visit(node: Any, preformatted: bool = False) -> None:
126
+ if isinstance(node, Comment):
118
127
  return
119
- pages_crawled += 1
120
-
121
- soup = context.soup
122
- page_data: dict[str, Any] = {
123
- "url": context.request.url,
124
- "status": 200,
125
- }
126
-
127
- # Extract title
128
- title_tag = soup.find("title")
129
- page_data["title"] = title_tag.get_text(strip=True) if title_tag else ""
130
-
131
- # Extract text content
132
- if extract in ("text", "all"):
133
- # Remove script and style elements
134
- for tag in soup(["script", "style", "nav", "footer", "header"]):
135
- tag.decompose()
136
- text = soup.get_text(separator="\n", strip=True)
137
- # Collapse whitespace
138
- lines = [line.strip() for line in text.splitlines() if line.strip()]
139
- page_data["text"] = "\n".join(lines)
140
-
141
- # Extract links
142
- if extract in ("links", "all"):
143
- links = []
144
- for a_tag in soup.find_all("a", href=True):
145
- href = str(a_tag["href"])
146
- link_text = a_tag.get_text(strip=True)
147
- if href.startswith(("http://", "https://")):
148
- links.append({"url": href, "text": link_text})
149
- page_data["links"] = links[:50] # Cap at 50 links per page
150
-
151
- # Extract metadata
152
- meta_tags = {}
153
- for meta in soup.find_all("meta"):
154
- name = meta.get("name", meta.get("property", ""))
155
- content = meta.get("content", "")
156
- if name and content:
157
- meta_tags[str(name)] = str(content)
158
- if meta_tags:
159
- page_data["meta"] = meta_tags
160
-
161
- results.append(page_data)
162
-
163
- # Enqueue links for deeper crawling if depth allows
164
- if max_depth > 0:
165
- await context.enqueue_links(strategy="same-domain")
166
-
167
- await crawler.run([start_url])
128
+ if isinstance(node, NavigableString):
129
+ # Preserve code/pre whitespace exactly; normal HTML whitespace is
130
+ # collapsed without removing navigation/footer or document tails.
131
+ parts.append(str(node) if preformatted else re.sub(r"\s+", " ", str(node)))
132
+ return
133
+ if not isinstance(node, Tag):
134
+ return
135
+ for attribute in DESTINATION_ATTRIBUTES:
136
+ value = node.get(attribute)
137
+ if value is not None:
138
+ parts.append("\n[" + attribute + "=" + str(value) + "]\n")
139
+ if node.name == "img" and node.get("alt") is not None:
140
+ parts.append(str(node.get("alt")))
141
+ if node.name in ("script", "style", "template"):
142
+ return
143
+ block = node.name in ("address", "article", "aside", "blockquote", "br", "caption", "div", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "p", "pre", "section", "table", "tr")
144
+ if block:
145
+ parts.append("\n")
146
+ for child in node.children:
147
+ visit(child, preformatted or node.name in ("pre", "code"))
148
+ if node.name in ("td", "th"):
149
+ parts.append("\t")
150
+ if block:
151
+ parts.append("\n")
152
+ visit(body)
153
+ title = soup.find("title")
154
+ return {"url": url, "title": title.get_text(" ", strip=True) if title else "", "text": "".join(parts),
155
+ "html_snapshot": snapshot, "links": links, "metadata": metadata, "structured_data": structured,
156
+ "text_coverage": {"scope": "all_static_body_text", "rendered": False,
157
+ "excluded_payload_elements": ["script", "style", "template"],
158
+ "included_attributes": list(DESTINATION_ATTRIBUTES)}}
159
+
160
+
161
+ def checked_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
162
+ if len(json.dumps(results, ensure_ascii=False).encode("utf-8")) > MAX_RESULT_BYTES:
163
+ raise ValueError("Crawl exceeded the complete-result resource limit; no partial success was retained")
168
164
  return results
169
165
 
170
166
 
171
- # ---------------------------------------------------------------------------
172
- # Playwright crawler
173
- # ---------------------------------------------------------------------------
174
-
175
- async def crawl_playwright(
176
- start_url: str,
177
- max_pages: int = 5,
178
- max_depth: int = 1,
179
- extract: str = "all",
180
- ) -> list[dict[str, Any]]:
181
- """Crawl using Playwright (headless browser, JS rendering)."""
182
- from crawlee.playwright_crawler import PlaywrightCrawler, PlaywrightCrawlingContext
167
+ async def crawl_pages(start_url: str, max_pages: int, max_depth: int, rendered: bool) -> list[dict[str, Any]]:
183
168
  from crawlee import ConcurrencySettings
184
-
169
+ if rendered:
170
+ from crawlee.playwright_crawler import PlaywrightCrawler as Crawler
171
+ else:
172
+ from crawlee.beautifulsoup_crawler import BeautifulSoupCrawler as Crawler
185
173
  results: list[dict[str, Any]] = []
186
- pages_crawled = 0
187
-
188
- crawler = PlaywrightCrawler(
189
- max_request_retries=2,
190
- request_handler_timeout=asyncio.timedelta(seconds=60),
191
- max_requests_per_crawl=max_pages,
192
- headless=True,
193
- browser_type="chromium",
194
- concurrency_settings=ConcurrencySettings(
195
- max_concurrency=2,
196
- ),
197
- )
174
+ visited: set[str] = set()
175
+ frontier = [start_url]
176
+ domain = urlparse(start_url).netloc
177
+ # Breadth-first explicit levels avoid relying on crawler-specific private
178
+ # request depth fields. max_pages and max_depth define requested scope.
179
+ for depth in range(max_depth + 1):
180
+ urls = [url for url in frontier if url not in visited][:max_pages - len(results)]
181
+ if not urls:
182
+ break
183
+ failures: list[str] = []
184
+ crawler_options: dict[str, Any] = {
185
+ "max_request_retries": 2,
186
+ "request_handler_timeout": timedelta(seconds=60 if rendered else 30),
187
+ "max_requests_per_crawl": len(urls),
188
+ "concurrency_settings": ConcurrencySettings(max_concurrency=1),
189
+ }
190
+ if rendered:
191
+ crawler_options.update({"headless": True, "browser_type": "chromium"})
192
+ crawler = Crawler(**crawler_options)
193
+
194
+ @crawler.router.default_handler
195
+ async def handler(context: Any) -> None:
196
+ url = context.request.url
197
+ if rendered:
198
+ from bs4 import BeautifulSoup
199
+ await context.page.wait_for_load_state("domcontentloaded", timeout=15000)
200
+ soup = BeautifulSoup(await context.page.content(), "html.parser")
201
+ else:
202
+ soup = context.soup
203
+ page = extract_page(soup, url)
204
+ page["depth"] = depth
205
+ page["text_coverage"]["rendered"] = rendered
206
+ # Record an observed status only when the actual response exposes it.
207
+ response = getattr(context, "response", None) if rendered else getattr(context, "http_response", None)
208
+ status = getattr(response, "status", None) if rendered else getattr(response, "status_code", None)
209
+ if isinstance(status, int):
210
+ if status >= 400:
211
+ raise RuntimeError(f"Page request failed with observed HTTP {status}")
212
+ page["observed_status"] = status
213
+ results.append(page)
214
+ checked_results(results)
215
+
216
+ @crawler.failed_request_handler
217
+ async def failed(context: Any, error: Exception) -> None:
218
+ failures.append(str(context.request.url))
219
+
220
+ await crawler.run(urls)
221
+ if failures or len(results) < len(visited) + len(urls):
222
+ raise RuntimeError("Crawl did not retrieve all dispatched pages; no partial success was retained")
223
+ visited.update(urls)
224
+ if len(results) >= max_pages:
225
+ break
226
+ frontier = []
227
+ seen = set(visited)
228
+ for page in results:
229
+ if page["depth"] != depth:
230
+ continue
231
+ for link in page["links"]:
232
+ candidate = link["url"]
233
+ parsed = urlparse(candidate)
234
+ if parsed.scheme in ("http", "https") and parsed.netloc == domain and candidate not in seen:
235
+ frontier.append(candidate)
236
+ seen.add(candidate)
237
+ return checked_results(results)
198
238
 
199
- @crawler.router.default_handler
200
- async def handler(context: PlaywrightCrawlingContext) -> None:
201
- nonlocal pages_crawled
202
- if pages_crawled >= max_pages:
203
- return
204
- pages_crawled += 1
205
239
 
206
- page = context.page
207
- page_data: dict[str, Any] = {
208
- "url": context.request.url,
209
- "status": 200,
210
- }
240
+ async def crawl_beautifulsoup(start_url: str, max_pages: int = 5, max_depth: int = 1, extract: str = "all") -> list[dict[str, Any]]:
241
+ return await crawl_pages(start_url, max_pages, max_depth, False)
211
242
 
212
- # Wait for content to load
213
- try:
214
- await page.wait_for_load_state("networkidle", timeout=15000)
215
- except Exception:
216
- await page.wait_for_load_state("domcontentloaded", timeout=10000)
217
-
218
- # Extract title
219
- page_data["title"] = await page.title()
220
-
221
- # Extract text content
222
- if extract in ("text", "all"):
223
- # Remove non-content elements via JS
224
- text = await page.evaluate("""() => {
225
- const remove = document.querySelectorAll('script, style, nav, footer, header, [role="navigation"]');
226
- remove.forEach(el => el.remove());
227
- return document.body ? document.body.innerText : '';
228
- }""")
229
- lines = [line.strip() for line in str(text).splitlines() if line.strip()]
230
- page_data["text"] = "\n".join(lines)
231
-
232
- # Extract links
233
- if extract in ("links", "all"):
234
- links = await page.evaluate("""() => {
235
- const anchors = document.querySelectorAll('a[href]');
236
- return Array.from(anchors).slice(0, 50).map(a => ({
237
- url: a.href,
238
- text: a.innerText.trim()
239
- })).filter(l => l.url.startsWith('http'));
240
- }""")
241
- page_data["links"] = links
242
-
243
- # Extract meta tags
244
- meta_tags = await page.evaluate("""() => {
245
- const metas = document.querySelectorAll('meta[name], meta[property]');
246
- const result = {};
247
- metas.forEach(m => {
248
- const key = m.getAttribute('name') || m.getAttribute('property');
249
- const val = m.getAttribute('content');
250
- if (key && val) result[key] = val;
251
- });
252
- return result;
253
- }""")
254
- if meta_tags:
255
- page_data["meta"] = meta_tags
256
-
257
- results.append(page_data)
258
-
259
- # Enqueue links for deeper crawling
260
- if max_depth > 0:
261
- await context.enqueue_links(strategy="same-domain")
262
-
263
- await crawler.run([start_url])
264
- return results
243
+
244
+ async def crawl_playwright(start_url: str, max_pages: int = 5, max_depth: int = 1, extract: str = "all") -> list[dict[str, Any]]:
245
+ return await crawl_pages(start_url, max_pages, max_depth, True)
265
246
 
266
247
 
267
248
  # ---------------------------------------------------------------------------
@@ -297,6 +278,8 @@ async def main():
297
278
  )
298
279
 
299
280
  args = parser.parse_args()
281
+ if not 1 <= args.max_pages <= 50 or not 0 <= args.max_depth <= 5:
282
+ parser.error("max-pages must be 1..50 and max-depth must be 0..5")
300
283
 
301
284
  # Ensure crawlee is installed
302
285
  if not ensure_crawlee():
@@ -332,26 +315,23 @@ async def main():
332
315
  print(json.dumps(result))
333
316
  sys.exit(1)
334
317
 
335
- # Output results
318
+ # Both display formats retain the complete structured source, including
319
+ # metadata and JSON-LD. Refuse an oversized result instead of a prefix.
320
+ output = {
321
+ "success": True,
322
+ "strategy": args.strategy,
323
+ "content_complete": True,
324
+ "requested_scope": {"max_pages": args.max_pages, "max_depth": args.max_depth},
325
+ "pages_crawled": len(results),
326
+ "results": results,
327
+ }
328
+ serialized = json.dumps(output, ensure_ascii=False, indent=2)
329
+ if len(serialized.encode("utf-8")) > MAX_RESULT_BYTES:
330
+ print(json.dumps({"error": "Crawl exceeded the complete-result resource limit; no partial success was retained"}))
331
+ sys.exit(1)
336
332
  if args.output == "text":
337
- for page in results:
338
- print(f"=== {page.get('title', 'Untitled')} ===")
339
- print(f"URL: {page.get('url', '')}")
340
- if "text" in page:
341
- print(page["text"][:5000])
342
- if "links" in page:
343
- print(f"\nLinks ({len(page['links'])}):")
344
- for link in page["links"][:20]:
345
- print(f" - {link.get('text', '')}: {link.get('url', '')}")
346
- print()
347
- else:
348
- output = {
349
- "success": True,
350
- "strategy": args.strategy,
351
- "pages_crawled": len(results),
352
- "results": results,
353
- }
354
- print(json.dumps(output, ensure_ascii=False, indent=2))
333
+ print("Complete structured crawl source:")
334
+ print(serialized)
355
335
 
356
336
 
357
337
  if __name__ == "__main__":
@@ -184,8 +184,6 @@ def _serialize_script_result(value, depth: int = 0, seen: Optional[set[int]] = N
184
184
  seen = set()
185
185
  if value is None or isinstance(value, (str, int, float, bool)):
186
186
  return value
187
- if depth > 5:
188
- return str(value)
189
187
  if isinstance(value, WebElement):
190
188
  try:
191
189
  rect = value.rect or {}
@@ -212,7 +210,8 @@ def _serialize_script_result(value, depth: int = 0, seen: Optional[set[int]] = N
212
210
  "type": attr("type"),
213
211
  "role": attr("role"),
214
212
  "ariaLabel": attr("aria-label"),
215
- "text": text[:240],
213
+ "text": text,
214
+ "__omnius_observation_scope": "element_descriptor",
216
215
  "rect": {
217
216
  "x": rect.get("x", 0),
218
217
  "y": rect.get("y", 0),
@@ -220,21 +219,25 @@ def _serialize_script_result(value, depth: int = 0, seen: Optional[set[int]] = N
220
219
  "height": rect.get("height", 0),
221
220
  },
222
221
  }
223
- if isinstance(value, (list, tuple, set)):
224
- return [_serialize_script_result(item, depth + 1, seen) for item in list(value)[:200]]
222
+ if isinstance(value, (list, tuple)):
223
+ ident = id(value)
224
+ if ident in seen:
225
+ raise ValueError("Circular evaluation result is not JSON serializable")
226
+ seen.add(ident)
227
+ try:
228
+ return [_serialize_script_result(item, depth + 1, seen) for item in value]
229
+ finally:
230
+ seen.remove(ident)
225
231
  if isinstance(value, dict):
226
232
  ident = id(value)
227
233
  if ident in seen:
228
- return "[Circular]"
234
+ raise ValueError("Circular evaluation result is not JSON serializable")
229
235
  seen.add(ident)
230
- out = {}
231
- for idx, (key, item) in enumerate(value.items()):
232
- if idx >= 200:
233
- out["__omnius_truncated"] = True
234
- break
235
- out[str(key)] = _serialize_script_result(item, depth + 1, seen)
236
- return out
237
- return str(value)
236
+ try:
237
+ return {str(key): _serialize_script_result(item, depth + 1, seen) for key, item in value.items()}
238
+ finally:
239
+ seen.remove(ident)
240
+ raise ValueError(f"Unsupported evaluation result type: {type(value).__name__}")
238
241
 
239
242
 
240
243
  class Tools:
@@ -486,13 +489,11 @@ class Tools:
486
489
  return f"Error typing into {selector}: {e}"
487
490
 
488
491
  @staticmethod
489
- def get_dom_snapshot(max_chars: int = 200_000) -> str:
492
+ def get_dom_snapshot() -> str:
490
493
  if not Tools._driver:
491
494
  return ""
492
495
  try:
493
496
  dom = Tools._driver.execute_script("return document.documentElement.outerHTML;")
494
- if dom and len(dom) > max_chars:
495
- dom = dom[:max_chars]
496
497
  return dom or ""
497
498
  except Exception as exc:
498
499
  log_message(f"[dom] snapshot failed: {exc}", "WARNING")
@@ -508,6 +509,7 @@ class Tools:
508
509
  "ok": True,
509
510
  "result": _serialize_script_result(result),
510
511
  "result_type": "undefined" if result is None else type(result).__name__,
512
+ "url": Tools._driver.current_url,
511
513
  }
512
514
  except Exception as exc:
513
515
  log_message(f"[evaluate] script failed: {exc}", "ERROR")
@@ -1155,7 +1157,7 @@ def evaluate_script():
1155
1157
  return _error(result.get("error") if isinstance(result, dict) else "evaluate failed", 500)
1156
1158
  sid = data.get("sid") or next(iter(_SESSIONS), "")
1157
1159
  _queue_event(sid, {"type": "status", "msg": "evaluate", "ts": int(time.time() * 1000)})
1158
- return _ok(result=result.get("result"), result_type=result.get("result_type"))
1160
+ return _ok(result=result.get("result"), result_type=result.get("result_type"), url=result.get("url"), content_coverage="complete_returned_view")
1159
1161
 
1160
1162
 
1161
1163
  @app.post("/scroll")
@@ -1430,12 +1432,12 @@ def drag():
1430
1432
  def dom_snapshot():
1431
1433
  if not _auth_ok(request):
1432
1434
  return _error("unauthorized", 401)
1433
- html = Tools.get_dom_snapshot(max_chars=200_000)
1435
+ html = Tools.get_dom_snapshot()
1434
1436
  if not html:
1435
1437
  return _error("no dom (browser closed?)", 409)
1436
1438
  sid = request.args.get("sid") or next(iter(_SESSIONS), "")
1437
1439
  _queue_event(sid, {"type": "dom", "chars": len(html), "ts": int(time.time() * 1000)})
1438
- return _ok(dom=html, length=len(html))
1440
+ return _ok(dom=html, length=len(html), url=Tools._driver.current_url if Tools._driver else None, content_coverage="complete_returned_view")
1439
1441
 
1440
1442
 
1441
1443
  def _record_frame_meta(sid: str, fname: str, width: int, height: int) -> None:
@@ -250778,7 +250778,7 @@ var MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
250778
250778
  var MAX_SEARCH_RESPONSE_BYTES = 2 * 1024 * 1024;
250779
250779
 
250780
250780
  // packages/execution/dist/tools/web-crawl.js
250781
- var MAX_RESPONSE_BYTES2 = 5 * 1024 * 1024;
250781
+ var MAX_CRAWL_RESPONSE_BYTES = 10 * 1024 * 1024;
250782
250782
 
250783
250783
  // packages/execution/dist/tools/osint-catalog-data.js
250784
250784
  var OSINT_CATALOG_RESOURCES = [
@@ -295733,10 +295733,10 @@ for (const resource of OSINT_CATALOG_RESOURCES) {
295733
295733
  }
295734
295734
 
295735
295735
  // packages/execution/dist/tools/carbonyl-browser.js
295736
- init_venv_paths();
295737
- init_process_lifecycle();
295738
295736
  import { homedir as homedir4 } from "node:os";
295739
295737
  import { join as join3 } from "node:path";
295738
+ init_venv_paths();
295739
+ init_process_lifecycle();
295740
295740
 
295741
295741
  // packages/execution/dist/tools/playwright-browser.js
295742
295742
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
@@ -298686,10 +298686,10 @@ var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
298686
298686
  init_process_async();
298687
298687
 
298688
298688
  // packages/execution/dist/tools/browser-action.js
298689
- init_process_lifecycle();
298690
298689
  import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "node:fs";
298691
298690
  import { basename as basename2, dirname as dirname4, join as join5, resolve as resolve4 } from "node:path";
298692
298691
  import { fileURLToPath } from "node:url";
298692
+ init_process_lifecycle();
298693
298693
  init_model_store();
298694
298694
  var __dirname2 = dirname4(fileURLToPath(import.meta.url));
298695
298695
  var DEFAULT_PORT = 8130;