qubicl-cli 0.1.0-dev.0 → 0.1.1

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 (91) hide show
  1. package/README.md +129 -19
  2. package/dist/SBOM.spdx.json +222 -0
  3. package/dist/THIRD_PARTY_NOTICES.txt +244 -0
  4. package/dist/assets/chromium-seccomp.json +181 -0
  5. package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
  6. package/dist/assets/computer/Dockerfile +191 -49
  7. package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
  8. package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
  9. package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
  10. package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
  11. package/dist/assets/computer/browser-skills-requirements.txt +12 -0
  12. package/dist/assets/computer/chromium-wrapper.sh +12 -0
  13. package/dist/assets/computer/control.mjs +112 -52
  14. package/dist/assets/computer/entrypoint.sh +272 -35
  15. package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
  16. package/dist/assets/computer/manifests/browser.json +72 -0
  17. package/dist/assets/computer/manifests/computer.json +76 -0
  18. package/dist/assets/computer/manifests/file-system.json +42 -0
  19. package/dist/assets/computer/manifests/workstation.json +78 -0
  20. package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
  21. package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
  22. package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  23. package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
  24. package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
  25. package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
  26. package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
  27. package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
  28. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
  29. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
  30. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  31. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  32. package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
  33. package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
  34. package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
  35. package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
  36. package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
  37. package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
  38. package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
  39. package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
  40. package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
  41. package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
  42. package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
  43. package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
  44. package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
  45. package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
  46. package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
  47. package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
  48. package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
  49. package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
  50. package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
  51. package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
  52. package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
  53. package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
  54. package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
  55. package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
  56. package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
  57. package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
  58. package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
  59. package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
  60. package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
  61. package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
  62. package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
  63. package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
  64. package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
  65. package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
  66. package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
  67. package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
  68. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
  69. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
  70. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
  71. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
  72. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
  73. package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
  74. package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
  75. package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
  76. package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
  77. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
  78. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
  79. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
  80. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
  81. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
  82. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
  83. package/dist/assets/computer/skills/core-catalog.json +246 -0
  84. package/dist/assets/computer/skills-requirements.txt +18 -0
  85. package/dist/assets/computer/web-provider.py +592 -0
  86. package/dist/assets/computer/web-requirements.txt +37 -0
  87. package/dist/assets/gateway/Dockerfile +3 -1
  88. package/dist/assets/gateway/gateway.mjs +69 -40
  89. package/dist/assets/image-catalog.json +183 -0
  90. package/dist/qubicl.mjs +297 -167
  91. package/package.json +8 -8
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env python3
2
+ """Keyless DDGS search and bounded local extraction for Qubicl's isolated web service."""
3
+
4
+ import ipaddress
5
+ import io
6
+ import json
7
+ import os
8
+ import re
9
+ import socket
10
+ import sys
11
+ import time
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ import zlib
16
+ from html import unescape
17
+
18
+ from ddgs import DDGS
19
+ from lxml import etree, html
20
+ from pypdf import PdfReader
21
+ from readability import Document as ReadabilityDocument
22
+ from trafilatura import extract as trafilatura_extract
23
+ from trafilatura.settings import use_config as trafilatura_config
24
+
25
+ MAX_DOWNLOAD = 8_000_000
26
+ MAX_DECOMPRESSED = 16_000_000
27
+ MAX_REDIRECTS = 5
28
+ TIMEOUT = 12
29
+ USER_AGENT = "Qubicl-Web/1.0 (+https://github.com/EldanRing/qubicl)"
30
+ MAX_HTML_TREE_SIZE = 500_000
31
+ MAX_RENDERED_HTML = 1_500_000
32
+ BOILERPLATE_TOKENS = {
33
+ "advert", "advertisement", "comments", "consent", "cookie", "footer",
34
+ "navbar", "navigation", "newsletter", "promo", "related", "share",
35
+ "sharing", "sidebar", "social",
36
+ }
37
+ TRAFILATURA_CONFIG = trafilatura_config()
38
+ TRAFILATURA_CONFIG.set("DEFAULT", "MAX_TREE_SIZE", str(MAX_HTML_TREE_SIZE))
39
+ TRAFILATURA_CONFIG.set("DEFAULT", "EXTRACTION_TIMEOUT", "15")
40
+ STRUCTURED_KEYS = {
41
+ "availability": "Availability",
42
+ "datecreated": "Date created",
43
+ "datemodified": "Date modified",
44
+ "datepublished": "Date published",
45
+ "highprice": "High price",
46
+ "lowprice": "Low price",
47
+ "offerprice": "Offer price",
48
+ "price": "Price",
49
+ "priceamount": "Price",
50
+ "pricecurrency": "Price currency",
51
+ "productid": "Product ID",
52
+ "ratingvalue": "Rating",
53
+ "reviewcount": "Review count",
54
+ "sku": "SKU",
55
+ "validthrough": "Valid through",
56
+ }
57
+
58
+
59
+ class WebError(Exception):
60
+ def __init__(self, code, message):
61
+ super().__init__(message)
62
+ self.code = code
63
+
64
+
65
+ def policy_profile():
66
+ try:
67
+ return json.loads(os.environ.get("QUBICL_NETWORK_POLICY", '{}')).get("profile", "developer")
68
+ except json.JSONDecodeError:
69
+ return "developer"
70
+
71
+
72
+ def require_online():
73
+ if policy_profile() == "offline":
74
+ raise WebError("network_policy_denied", "This computer's offline network policy denies web access.")
75
+
76
+
77
+ def public_url(value):
78
+ try:
79
+ parsed = urllib.parse.urlsplit(value)
80
+ except ValueError as error:
81
+ raise WebError("web_invalid_url", f"Invalid URL: {error}") from error
82
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
83
+ raise WebError("web_invalid_url", "Only complete HTTP and HTTPS URLs are allowed.")
84
+ if parsed.username is not None or parsed.password is not None:
85
+ raise WebError("web_invalid_url", "Embedded URL credentials are not allowed.")
86
+ # Restricted profiles place this worker on internal-only networks. In that
87
+ # topology the authenticated egress proxy is the only resolver and applies
88
+ # the same public-address check to every HTTP request and CONNECT target.
89
+ # Resolving here would both fail and create a second, inconsistent policy
90
+ # decision. Developer mode has no proxy, so direct requests still validate
91
+ # every DNS answer locally before opening a connection.
92
+ if os.environ.get("QUBICL_PROXY_URL"):
93
+ return urllib.parse.urlunsplit(parsed)
94
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
95
+ try:
96
+ addresses = {entry[4][0] for entry in socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)}
97
+ except socket.gaierror as error:
98
+ raise WebError("web_dns_failure", f"Could not resolve destination: {error}") from error
99
+ if not addresses:
100
+ raise WebError("web_dns_failure", "The destination resolved to no addresses.")
101
+ for address in addresses:
102
+ ip = ipaddress.ip_address(address.split("%", 1)[0])
103
+ if not ip.is_global:
104
+ raise WebError("web_private_destination", "Loopback, private, link-local, metadata, reserved, and other non-public destinations are blocked.")
105
+ return urllib.parse.urlunsplit(parsed)
106
+
107
+
108
+ class ValidatingRedirect(urllib.request.HTTPRedirectHandler):
109
+ def __init__(self):
110
+ self.redirects = 0
111
+
112
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
113
+ self.redirects += 1
114
+ if self.redirects > MAX_REDIRECTS:
115
+ raise WebError("web_redirect_limit", f"Web extraction follows at most {MAX_REDIRECTS} redirects.")
116
+ return super().redirect_request(req, fp, code, msg, headers, public_url(newurl))
117
+
118
+
119
+ def read_bounded(response):
120
+ chunks, total = [], 0
121
+ while True:
122
+ chunk = response.read(min(65_536, MAX_DOWNLOAD + 1 - total))
123
+ if not chunk:
124
+ break
125
+ total += len(chunk)
126
+ if total > MAX_DOWNLOAD:
127
+ raise WebError("web_response_too_large", f"Response exceeds the {MAX_DOWNLOAD}-byte download limit.")
128
+ chunks.append(chunk)
129
+ raw = b"".join(chunks)
130
+ encoding = (response.headers.get("content-encoding") or "").lower()
131
+ if encoding in ("gzip", "x-gzip"):
132
+ raw = bounded_inflate(raw, 16 + zlib.MAX_WBITS, "gzip")
133
+ elif encoding == "deflate":
134
+ raw = bounded_inflate(raw, zlib.MAX_WBITS, "deflate")
135
+ elif encoding and encoding != "identity":
136
+ raise WebError("web_invalid_content", f"Unsupported content encoding: {encoding}")
137
+ if len(raw) > MAX_DECOMPRESSED:
138
+ raise WebError("web_decompression_limit", f"Decompressed response exceeds {MAX_DECOMPRESSED} bytes.")
139
+ return raw
140
+
141
+
142
+ def bounded_inflate(data, window_bits, label):
143
+ try:
144
+ inflater = zlib.decompressobj(window_bits)
145
+ result = inflater.decompress(data, MAX_DECOMPRESSED + 1)
146
+ if inflater.unconsumed_tail or len(result) > MAX_DECOMPRESSED:
147
+ raise WebError("web_decompression_limit", f"Decompressed response exceeds {MAX_DECOMPRESSED} bytes.")
148
+ result += inflater.flush(MAX_DECOMPRESSED + 1 - len(result))
149
+ if len(result) > MAX_DECOMPRESSED:
150
+ raise WebError("web_decompression_limit", f"Decompressed response exceeds {MAX_DECOMPRESSED} bytes.")
151
+ return result
152
+ except WebError:
153
+ raise
154
+ except zlib.error as error:
155
+ raise WebError("web_invalid_content", f"Invalid {label}-encoded response.") from error
156
+
157
+
158
+ def fetch(url):
159
+ require_online()
160
+ target = public_url(url)
161
+ redirect = ValidatingRedirect()
162
+ proxy = os.environ.get("QUBICL_PROXY_URL")
163
+ opener = urllib.request.build_opener(redirect, urllib.request.ProxyHandler({"http": proxy, "https": proxy}) if proxy else urllib.request.ProxyHandler({}))
164
+ request = urllib.request.Request(target, headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/pdf,text/plain,application/json,application/xml,text/xml;q=0.9,*/*;q=0.1", "Accept-Encoding": "identity"})
165
+ try:
166
+ with opener.open(request, timeout=TIMEOUT) as response:
167
+ final_url = public_url(response.geturl())
168
+ return final_url, response.headers.get_content_type().lower(), response.headers.get_content_charset() or "utf-8", read_bounded(response)
169
+ except WebError:
170
+ raise
171
+ except urllib.error.HTTPError as error:
172
+ if error.code == 429:
173
+ raise WebError("web_rate_limited", "The upstream site rate-limited this request.") from error
174
+ raise WebError("web_upstream_error", f"Upstream returned HTTP {error.code}.") from error
175
+ except (urllib.error.URLError, TimeoutError, socket.timeout) as error:
176
+ reason = getattr(error, "reason", error)
177
+ if isinstance(reason, (TimeoutError, socket.timeout)):
178
+ raise WebError("web_timeout", "The upstream request timed out.") from error
179
+ raise WebError("web_upstream_error", f"The upstream request failed: {reason}") from error
180
+
181
+
182
+ def clean_text(value):
183
+ return re.sub(r"\n{3,}", "\n\n", re.sub(r"[ \t]+", " ", value)).strip()
184
+
185
+
186
+ def decoded_html(data, charset):
187
+ try:
188
+ return data.decode(charset, errors="replace")
189
+ except LookupError as error:
190
+ raise WebError("web_invalid_content", f"Unsupported HTML encoding: {charset}") from error
191
+
192
+
193
+ def parsed_html(source):
194
+ try:
195
+ return html.fromstring(source)
196
+ except (etree.ParserError, TypeError, ValueError) as error:
197
+ raise WebError("web_invalid_content", f"Could not parse HTML: {error}") from error
198
+
199
+
200
+ def fallback_title(source):
201
+ document = parsed_html(source)
202
+ candidates = [
203
+ "//meta[@property='og:title'][1]/@content",
204
+ "//meta[@name='twitter:title'][1]/@content",
205
+ "//title[1]//text()",
206
+ "//h1[1]//text()",
207
+ ]
208
+ for xpath in candidates:
209
+ title = clean_text(" ".join(str(value) for value in document.xpath(xpath)))
210
+ if title:
211
+ return title
212
+ return None
213
+
214
+
215
+ def meaningful_html(content):
216
+ words = re.findall(r"\b[\w'-]+\b", content, flags=re.UNICODE)
217
+ visible = re.sub(r"[^\w]+", "", content, flags=re.UNICODE)
218
+ return len(words) >= 12 and len(visible) >= 80
219
+
220
+
221
+ def markdownish_to_text(content):
222
+ lines = []
223
+ table_separator = re.compile(r"^\|?(?:\s*:?-{3,}:?\s*\|)+\s*$")
224
+ for original in content.splitlines():
225
+ line = original.strip()
226
+ if not line or line.startswith("```") or table_separator.match(line):
227
+ if lines and lines[-1] != "":
228
+ lines.append("")
229
+ continue
230
+ line = re.sub(r"^#{1,6}\s+", "", line)
231
+ line = re.sub(r"^>\s?", "", line)
232
+ line = re.sub(r"^(?:[-+*]|\d+[.)])\s+", "• ", line)
233
+ line = re.sub(r"\[([^]]+)]\((?:https?://|/)[^)]+\)", r"\1", line)
234
+ line = re.sub(r"(?<!\w)(?:\*\*|__)(.+?)(?:\*\*|__)(?!\w)", r"\1", line)
235
+ line = re.sub(r"(?<!\w)(?:\*|_)(.+?)(?:\*|_)(?!\w)", r"\1", line)
236
+ if line.startswith("|") and line.endswith("|"):
237
+ line = "\t".join(cell.strip() for cell in line[1:-1].split("|"))
238
+ lines.append(line)
239
+ return clean_text("\n".join(lines))
240
+
241
+
242
+ def trafilatura_html(source, final_url, output_format):
243
+ try:
244
+ content = trafilatura_extract(
245
+ source,
246
+ url=final_url,
247
+ output_format="txt" if output_format == "text" else "markdown",
248
+ include_comments=False,
249
+ include_tables=True,
250
+ include_links=output_format == "markdown",
251
+ include_formatting=output_format == "markdown",
252
+ deduplicate=True,
253
+ favor_precision=True,
254
+ with_metadata=False,
255
+ config=TRAFILATURA_CONFIG,
256
+ )
257
+ if not content:
258
+ return None, ""
259
+ if output_format == "text":
260
+ content = markdownish_to_text(content)
261
+ return fallback_title(source), clean_text(content)
262
+ except Exception:
263
+ # Extraction libraries operate only on already-fetched bytes. Treat
264
+ # parser-specific failures as a miss so the bounded local fallback can
265
+ # run without changing network behavior.
266
+ return None, ""
267
+
268
+
269
+ def prune_boilerplate(document):
270
+ for node in document.xpath("//script|//style|//noscript|//nav|//header|//footer|//aside|//form|//svg|//canvas|//template"):
271
+ node.drop_tree()
272
+ for node in document.xpath("//*"):
273
+ if not isinstance(node.tag, str):
274
+ continue
275
+ tokens = set(re.split(r"[^a-z0-9]+", f"{node.get('id', '')} {node.get('class', '')}".lower()))
276
+ role = node.get("role", "").lower()
277
+ if tokens.intersection(BOILERPLATE_TOKENS) or role in {"banner", "complementary", "contentinfo", "dialog", "navigation"}:
278
+ node.drop_tree()
279
+
280
+
281
+ def inline_content(node, output_format, base_url):
282
+ parts = [node.text or ""]
283
+ for child in node:
284
+ nested = inline_content(child, output_format, base_url)
285
+ tag = child.tag.lower() if isinstance(child.tag, str) else ""
286
+ if output_format == "markdown" and tag == "a":
287
+ href = urllib.parse.urljoin(base_url, child.get("href", ""))
288
+ parsed = urllib.parse.urlsplit(href)
289
+ nested = f"[{nested}]({href})" if nested and parsed.scheme in ("http", "https") and parsed.hostname else nested
290
+ elif output_format == "markdown" and tag in ("strong", "b") and nested:
291
+ nested = f"**{nested}**"
292
+ elif output_format == "markdown" and tag in ("em", "i") and nested:
293
+ nested = f"*{nested}*"
294
+ elif output_format == "markdown" and tag == "code" and nested:
295
+ nested = f"`{nested}`"
296
+ elif tag == "br":
297
+ nested = f"\n{nested}"
298
+ parts.extend((nested, child.tail or ""))
299
+ return clean_text("".join(parts))
300
+
301
+
302
+ def heuristic_html(source, final_url, output_format):
303
+ document = parsed_html(source)
304
+ title = fallback_title(source)
305
+ prune_boilerplate(document)
306
+ candidates = document.xpath("//article|//main")
307
+ body = document.find("body")
308
+ root = max(candidates, key=lambda node: len(node.text_content()), default=body if body is not None else document)
309
+ lines = []
310
+ blocks = root.xpath(".//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6 or self::p or self::li or self::blockquote or self::pre or self::table]")
311
+ for node in blocks:
312
+ tag = node.tag.lower()
313
+ if tag == "p" and node.xpath("ancestor::li|ancestor::blockquote"):
314
+ continue
315
+ if tag == "table":
316
+ rows = []
317
+ for row in node.xpath(".//tr"):
318
+ cells = [inline_content(cell, output_format, final_url) for cell in row.xpath("./th|./td")]
319
+ if cells:
320
+ rows.append(cells)
321
+ if rows:
322
+ if output_format == "markdown":
323
+ width = max(len(row) for row in rows)
324
+ normalized = [row + [""] * (width - len(row)) for row in rows]
325
+ lines.append("| " + " | ".join(normalized[0]) + " |")
326
+ lines.append("| " + " | ".join("---" for _ in range(width)) + " |")
327
+ lines.extend("| " + " | ".join(row) + " |" for row in normalized[1:])
328
+ else:
329
+ lines.extend("\t".join(row) for row in rows)
330
+ continue
331
+ text = inline_content(node, output_format, final_url)
332
+ if not text:
333
+ continue
334
+ if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
335
+ lines.append(f"{'#' * int(tag[1])} {text}" if output_format == "markdown" else text)
336
+ elif tag == "li":
337
+ lines.append(f"- {text}" if output_format == "markdown" else f"• {text}")
338
+ elif tag == "blockquote":
339
+ lines.append(f"> {text}" if output_format == "markdown" else text)
340
+ elif tag == "pre":
341
+ lines.append(f"```\n{text}\n```" if output_format == "markdown" else text)
342
+ else:
343
+ lines.append(text)
344
+ content = clean_text("\n\n".join(lines))
345
+ if not content:
346
+ content = clean_text(root.text_content())
347
+ return title, content
348
+
349
+
350
+ def readability_html(source, final_url, output_format):
351
+ try:
352
+ readable = ReadabilityDocument(source, url=final_url, min_text_length=25, retry_length=250)
353
+ title = clean_text(readable.short_title()) or None
354
+ summary = readable.summary(html_partial=True)
355
+ fallback_title_value, content = heuristic_html(summary, final_url, output_format)
356
+ return title or fallback_title_value, content
357
+ except Exception:
358
+ # Readability is an optional quality fallback, not a new failure mode.
359
+ return None, ""
360
+
361
+
362
+ def extract_html(data, charset, final_url, output_format):
363
+ source = decoded_html(data, charset)
364
+ title, content = trafilatura_html(source, final_url, output_format)
365
+ if meaningful_html(content):
366
+ return title or fallback_title(source), content, "local-html"
367
+ readability_title, readability_content = readability_html(source, final_url, output_format)
368
+ if meaningful_html(readability_content):
369
+ return readability_title or title or fallback_title(source), readability_content, "local-html-readability"
370
+ heuristic_title, heuristic_content = heuristic_html(source, final_url, output_format)
371
+ choices = [
372
+ (content, title, "local-html"),
373
+ (readability_content, readability_title, "local-html-readability"),
374
+ (heuristic_content, heuristic_title, "local-html-heuristic"),
375
+ ]
376
+ best_content, best_title, method = max(choices, key=lambda choice: len(choice[0]))
377
+ return best_title or fallback_title(source), best_content, method
378
+
379
+
380
+ def structured_html_signals(source):
381
+ document = parsed_html(source)
382
+ signals, seen = [], set()
383
+
384
+ def add(label, value):
385
+ if len(signals) >= 24 or isinstance(value, (dict, list, bool)) or value is None:
386
+ return
387
+ rendered = clean_text(unescape(str(value)))[:300]
388
+ if not rendered:
389
+ return
390
+ if rendered.startswith(("http://", "https://")):
391
+ rendered = rendered.rstrip("/").rsplit("/", 1)[-1]
392
+ key = (label.casefold(), rendered.casefold())
393
+ if key in seen:
394
+ return
395
+ seen.add(key)
396
+ signals.append((label, rendered))
397
+
398
+ def walk_json(value, depth=0, visited=None):
399
+ if depth > 8 or len(signals) >= 24:
400
+ return
401
+ visited = visited if visited is not None else [0]
402
+ visited[0] += 1
403
+ if visited[0] > 2_000:
404
+ return
405
+ if isinstance(value, dict):
406
+ for key, child in value.items():
407
+ normalized = re.sub(r"[^a-z0-9]", "", str(key).lower())
408
+ if normalized in STRUCTURED_KEYS:
409
+ add(STRUCTURED_KEYS[normalized], child)
410
+ walk_json(child, depth + 1, visited)
411
+ elif isinstance(value, list):
412
+ for child in value[:100]:
413
+ walk_json(child, depth + 1, visited)
414
+
415
+ for script in document.xpath("//script[translate(@type, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='application/ld+json']")[:20]:
416
+ raw = script.text or ""
417
+ if len(raw) > 100_000:
418
+ continue
419
+ try:
420
+ walk_json(json.loads(raw))
421
+ except (TypeError, ValueError):
422
+ continue
423
+
424
+ for node in document.xpath("//meta[@content]")[:500]:
425
+ name = (node.get("property") or node.get("name") or "").lower()
426
+ normalized = re.sub(r"[^a-z0-9]", "", name)
427
+ for key, label in STRUCTURED_KEYS.items():
428
+ if normalized.endswith(key) and any(token in name for token in ("product", "offer", "price", "article")):
429
+ add(label, node.get("content"))
430
+ break
431
+
432
+ context_pattern = re.compile(r"(?:price|quote|ticker|rate|market)", re.IGNORECASE)
433
+ value_pattern = re.compile(r"(?:\d|[$\u20ac\u00a3\u00a5])")
434
+ for node in document.xpath("//*[@itemprop or @data-price or @data-last-price or @aria-label or @title]")[:1_000]:
435
+ for item in (node.get("itemprop") or "").split():
436
+ normalized = re.sub(r"[^a-z0-9]", "", item.lower())
437
+ if normalized in STRUCTURED_KEYS:
438
+ add(STRUCTURED_KEYS[normalized], node.get("content") or node.get("value") or node.text_content())
439
+ for attribute, label in (("data-price", "Price"), ("data-last-price", "Price")):
440
+ if node.get(attribute):
441
+ add(label, node.get(attribute))
442
+ context = " ".join((node.get("id") or "", node.get("class") or "", node.get("itemprop") or ""))
443
+ for attribute in ("aria-label", "title"):
444
+ value = node.get(attribute) or ""
445
+ if value_pattern.search(value) and (context_pattern.search(value) or context_pattern.search(context)):
446
+ add("Accessible market data", value)
447
+ return signals
448
+
449
+
450
+ def append_structured_signals(content, signals, output_format):
451
+ novel = []
452
+ existing = content.casefold()
453
+ for label, value in signals:
454
+ if len(value) >= 3 and value.casefold() in existing:
455
+ continue
456
+ novel.append((label, value))
457
+ if not novel:
458
+ return content
459
+ if output_format == "markdown":
460
+ section = "## Page data\n\n" + "\n".join(f"- {label}: {value}" for label, value in novel)
461
+ else:
462
+ section = "Page data\n" + "\n".join(f"{label}: {value}" for label, value in novel)
463
+ return clean_text(f"{content}\n\n{section}" if content else section)
464
+
465
+
466
+ def extract_rendered(payload):
467
+ final_url = public_url(payload["finalUrl"])
468
+ source = payload["html"]
469
+ if not isinstance(source, str):
470
+ raise WebError("web_invalid_content", "Rendered HTML must be text.")
471
+ data = source.encode("utf-8")
472
+ if len(data) > MAX_RENDERED_HTML:
473
+ raise WebError("web_response_too_large", f"Rendered HTML exceeds the {MAX_RENDERED_HTML}-byte limit.")
474
+ output_format = payload.get("format", "markdown")
475
+ extracted_title, content, _ = extract_html(data, "utf-8", final_url, output_format)
476
+ content = append_structured_signals(content, structured_html_signals(source), output_format)
477
+ content = clean_text(unescape(content))
478
+ max_chars = payload["maxChars"]
479
+ truncated = bool(payload.get("sourceTruncated")) or len(content) > max_chars
480
+ return {
481
+ "url": final_url,
482
+ "finalUrl": final_url,
483
+ "title": clean_text(payload.get("title") or "") or extracted_title,
484
+ "contentType": str(payload.get("contentType") or "text/html").split(";", 1)[0].lower(),
485
+ "extractionMethod": "browser",
486
+ "content": content[:max_chars],
487
+ "truncated": truncated,
488
+ }
489
+
490
+
491
+ def extract_pdf(data):
492
+ try:
493
+ reader = PdfReader(io.BytesIO(data), strict=False)
494
+ title = reader.metadata.title if reader.metadata else None
495
+ return title, clean_text("\n\n".join((page.extract_text() or "") for page in reader.pages))
496
+ except Exception as error:
497
+ raise WebError("web_invalid_content", f"Could not extract PDF text: {error}") from error
498
+
499
+
500
+ def extract(payload):
501
+ final_url, content_type, charset, data = fetch(payload["url"])
502
+ method, title = "local-text", None
503
+ if content_type in ("text/html", "application/xhtml+xml"):
504
+ title, content, method = extract_html(data, charset, final_url, payload.get("format", "markdown"))
505
+ elif content_type == "application/pdf" or data.startswith(b"%PDF-"):
506
+ title, content = extract_pdf(data)
507
+ content_type, method = "application/pdf", "local-pdf"
508
+ elif content_type == "application/json" or content_type.endswith("+json"):
509
+ try:
510
+ content = json.dumps(json.loads(data.decode(charset)), indent=2, ensure_ascii=False)
511
+ except (ValueError, LookupError) as error:
512
+ raise WebError("web_invalid_content", f"Invalid JSON response: {error}") from error
513
+ method = "local-json"
514
+ elif content_type in ("application/xml", "text/xml") or content_type.endswith("+xml"):
515
+ try:
516
+ content = etree.tostring(etree.fromstring(data), pretty_print=True, encoding="unicode")
517
+ except etree.XMLSyntaxError as error:
518
+ raise WebError("web_invalid_content", f"Invalid XML response: {error}") from error
519
+ method = "local-xml"
520
+ elif content_type.startswith("text/"):
521
+ try:
522
+ content = data.decode(charset, errors="replace")
523
+ except LookupError as error:
524
+ raise WebError("web_invalid_content", f"Unsupported text encoding: {charset}") from error
525
+ else:
526
+ raise WebError("web_unsupported_content_type", f"Unsupported content type: {content_type}")
527
+ content = clean_text(unescape(content))
528
+ max_chars = payload["maxChars"]
529
+ truncated = len(content) > max_chars
530
+ content = content[:max_chars]
531
+ # Sparse HTML is a signal for render:auto; raw text formats are never browser-rendered.
532
+ browser_recommended = method.startswith("local-html") and (
533
+ len(content) < 80 or bool(re.search(r"(?:enable|requires?) javascript|javascript is disabled", content, re.IGNORECASE))
534
+ )
535
+ return {"url": final_url, "finalUrl": final_url, "title": title, "contentType": content_type, "extractionMethod": method, "content": content, "truncated": truncated, "browserRecommended": browser_recommended}
536
+
537
+
538
+ def search(payload):
539
+ require_online()
540
+ proxy = os.environ.get("QUBICL_PROXY_URL")
541
+ last = None
542
+ for attempt in range(2):
543
+ try:
544
+ rows = DDGS(proxy=proxy, timeout=TIMEOUT).text(payload["query"], max_results=payload["limit"])
545
+ results = []
546
+ for row in list(rows)[:payload["limit"]]:
547
+ if not isinstance(row, dict):
548
+ raise WebError("web_provider_malformed", "DDGS returned a malformed result.")
549
+ url = row.get("href") or row.get("url")
550
+ title = row.get("title")
551
+ if not isinstance(url, str) or not isinstance(title, str):
552
+ continue
553
+ parsed = urllib.parse.urlsplit(url)
554
+ if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None:
555
+ continue
556
+ results.append({"title": title[:500], "url": url[:8192], "description": str(row.get("body") or row.get("description") or "")[:2000]})
557
+ return {"query": payload["query"], "provider": "ddgs", "results": results}
558
+ except WebError:
559
+ raise
560
+ except Exception as error:
561
+ last = error
562
+ message = str(error).lower()
563
+ if "429" in message or "ratelimit" in message or "rate limit" in message:
564
+ raise WebError("web_rate_limited", "DDGS was rate-limited by its public upstream.") from error
565
+ if attempt == 0:
566
+ time.sleep(0.25)
567
+ raise WebError("web_upstream_error", f"DDGS search failed after bounded retry: {last}")
568
+
569
+
570
+ def main():
571
+ try:
572
+ payload = json.load(sys.stdin)
573
+ operation = sys.argv[1]
574
+ if operation == "search":
575
+ result = search(payload)
576
+ elif operation == "extract":
577
+ result = extract(payload)
578
+ elif operation == "extract-rendered":
579
+ result = extract_rendered(payload)
580
+ else:
581
+ raise WebError("web_provider_failure", "Unknown web provider operation.")
582
+ print(json.dumps(result, ensure_ascii=False))
583
+ except WebError as error:
584
+ print(json.dumps({"error": {"code": error.code, "message": str(error)}}))
585
+ raise SystemExit(2)
586
+ except Exception as error:
587
+ print(json.dumps({"error": {"code": "web_provider_failure", "message": str(error)}}))
588
+ raise SystemExit(2)
589
+
590
+
591
+ if __name__ == "__main__":
592
+ main()
@@ -0,0 +1,37 @@
1
+ # Exact, reviewable runtime closure for the isolated Qubicl web service.
2
+ ddgs==9.14.4
3
+ pypdf==6.16.1
4
+ trafilatura==2.2.0
5
+ readability-lxml==0.8.4.1
6
+ click==8.4.2
7
+ fake-useragent==2.2.0
8
+ httpx==0.28.1
9
+ httpcore==1.0.9
10
+ socksio==1.0.0
11
+ lxml==6.1.2
12
+ primp==1.3.1
13
+ h2==4.4.1
14
+ anyio==4.14.2
15
+ idna==3.19
16
+ brotli==1.2.0
17
+ certifi==2026.7.22
18
+ h11==0.16.0
19
+ hpack==4.2.0
20
+ hyperframe==6.1.0
21
+ typing_extensions==4.16.0
22
+ babel==2.18.0
23
+ chardet==7.6.0
24
+ charset-normalizer==3.5.1
25
+ courlan==1.4.0
26
+ cssselect==1.5.0
27
+ dateparser==1.4.2
28
+ htmldate==1.10.0
29
+ jusText==3.0.2
30
+ lxml_html_clean==0.4.5
31
+ python-dateutil==2.9.0.post0
32
+ pytz==2026.3.post1
33
+ regex==2026.7.19
34
+ six==1.17.0
35
+ tld==0.13.2
36
+ tzlocal==5.4.4
37
+ urllib3==2.7.0
@@ -15,7 +15,9 @@ LABEL org.opencontainers.image.title="Qubicl gateway" \
15
15
  org.opencontainers.image.created="$QUBICL_CREATED"
16
16
 
17
17
  WORKDIR /app
18
- RUN rm -rf \
18
+ RUN apk upgrade --no-cache libcrypto3 libssl3 \
19
+ && rm -rf \
20
+ /opt/yarn-v1.22.22 \
19
21
  /usr/local/lib/node_modules/corepack \
20
22
  /usr/local/lib/node_modules/npm \
21
23
  && rm -f \