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.
- package/README.md +129 -19
- package/dist/SBOM.spdx.json +222 -0
- package/dist/THIRD_PARTY_NOTICES.txt +244 -0
- package/dist/assets/chromium-seccomp.json +181 -0
- package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
- package/dist/assets/computer/Dockerfile +191 -49
- package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
- package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
- package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
- package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
- package/dist/assets/computer/browser-skills-requirements.txt +12 -0
- package/dist/assets/computer/chromium-wrapper.sh +12 -0
- package/dist/assets/computer/control.mjs +112 -52
- package/dist/assets/computer/entrypoint.sh +272 -35
- package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
- package/dist/assets/computer/manifests/browser.json +72 -0
- package/dist/assets/computer/manifests/computer.json +76 -0
- package/dist/assets/computer/manifests/file-system.json +42 -0
- package/dist/assets/computer/manifests/workstation.json +78 -0
- package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
- package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
- package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
- package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
- package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
- package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
- package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
- package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
- package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
- package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
- package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
- package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
- package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
- package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
- package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
- package/dist/assets/computer/skills/core-catalog.json +246 -0
- package/dist/assets/computer/skills-requirements.txt +18 -0
- package/dist/assets/computer/web-provider.py +592 -0
- package/dist/assets/computer/web-requirements.txt +37 -0
- package/dist/assets/gateway/Dockerfile +3 -1
- package/dist/assets/gateway/gateway.mjs +69 -40
- package/dist/assets/image-catalog.json +183 -0
- package/dist/qubicl.mjs +297 -167
- 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
|
|
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 \
|