kiwimatecoder 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/web.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Web helpers: HTML-to-text, local-address detection, fetching, and search.
|
|
2
|
+
|
|
3
|
+
The pure helpers (:func:`html_to_text`, :func:`is_local_address`,
|
|
4
|
+
:func:`parse_ddg_results`) are kept free of network access so they can be
|
|
5
|
+
unit-tested from fixture strings. Network failures surface as
|
|
6
|
+
:class:`WebError` with a message that is safe to show the model; raw
|
|
7
|
+
``httpx`` errors are never leaked.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ipaddress
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from html.parser import HTMLParser
|
|
16
|
+
from urllib.parse import parse_qs, unquote, urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from kiwimatecoder import network
|
|
21
|
+
|
|
22
|
+
USER_AGENT = (
|
|
23
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
24
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
25
|
+
"Chrome/124.0 Safari/537.36 KiwiMateCoder/0.1"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
#: A UTF-8 character can take up to four bytes, so a byte cap of
|
|
29
|
+
#: ``max_chars * 4`` guarantees at least ``max_chars`` characters are readable.
|
|
30
|
+
MAX_BYTES_PER_CHAR = 4
|
|
31
|
+
|
|
32
|
+
DDG_HTML_URL = "https://html.duckduckgo.com/html/"
|
|
33
|
+
|
|
34
|
+
DEFAULT_SEARCH_COUNT = 5
|
|
35
|
+
MAX_SEARCH_COUNT = 10
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WebError(Exception):
|
|
39
|
+
"""A user-facing web failure with a ready-to-show message."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# HTML to text
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
_SKIP_TAGS = frozenset({"script", "style", "noscript", "svg", "template"})
|
|
47
|
+
_BLOCK_TAGS = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"address",
|
|
50
|
+
"article",
|
|
51
|
+
"aside",
|
|
52
|
+
"blockquote",
|
|
53
|
+
"dd",
|
|
54
|
+
"details",
|
|
55
|
+
"div",
|
|
56
|
+
"dl",
|
|
57
|
+
"dt",
|
|
58
|
+
"fieldset",
|
|
59
|
+
"figcaption",
|
|
60
|
+
"figure",
|
|
61
|
+
"footer",
|
|
62
|
+
"form",
|
|
63
|
+
"h1",
|
|
64
|
+
"h2",
|
|
65
|
+
"h3",
|
|
66
|
+
"h4",
|
|
67
|
+
"h5",
|
|
68
|
+
"h6",
|
|
69
|
+
"header",
|
|
70
|
+
"hr",
|
|
71
|
+
"li",
|
|
72
|
+
"main",
|
|
73
|
+
"nav",
|
|
74
|
+
"ol",
|
|
75
|
+
"p",
|
|
76
|
+
"pre",
|
|
77
|
+
"section",
|
|
78
|
+
"summary",
|
|
79
|
+
"table",
|
|
80
|
+
"tbody",
|
|
81
|
+
"td",
|
|
82
|
+
"tfoot",
|
|
83
|
+
"th",
|
|
84
|
+
"thead",
|
|
85
|
+
"tr",
|
|
86
|
+
"ul",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
_WHITESPACE = re.compile(r"[ \t\f\v\xa0]+")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _TextExtractor(HTMLParser):
|
|
93
|
+
"""Collect visible text and the document title from HTML."""
|
|
94
|
+
|
|
95
|
+
def __init__(self) -> None:
|
|
96
|
+
super().__init__(convert_charrefs=True)
|
|
97
|
+
self._chunks: list[str] = []
|
|
98
|
+
self._title_chunks: list[str] = []
|
|
99
|
+
self._skip_depth = 0
|
|
100
|
+
self._in_title = False
|
|
101
|
+
|
|
102
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
103
|
+
tag = tag.lower()
|
|
104
|
+
if tag in _SKIP_TAGS:
|
|
105
|
+
self._skip_depth += 1
|
|
106
|
+
return
|
|
107
|
+
if self._skip_depth:
|
|
108
|
+
return
|
|
109
|
+
if tag == "title":
|
|
110
|
+
self._in_title = True
|
|
111
|
+
return
|
|
112
|
+
if tag == "br" or tag in _BLOCK_TAGS:
|
|
113
|
+
self._chunks.append("\n")
|
|
114
|
+
|
|
115
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
116
|
+
tag = tag.lower()
|
|
117
|
+
if tag in _SKIP_TAGS or self._skip_depth:
|
|
118
|
+
return
|
|
119
|
+
if tag == "br" or tag in _BLOCK_TAGS:
|
|
120
|
+
self._chunks.append("\n")
|
|
121
|
+
|
|
122
|
+
def handle_endtag(self, tag: str) -> None:
|
|
123
|
+
tag = tag.lower()
|
|
124
|
+
if tag in _SKIP_TAGS:
|
|
125
|
+
self._skip_depth = max(0, self._skip_depth - 1)
|
|
126
|
+
return
|
|
127
|
+
if self._skip_depth:
|
|
128
|
+
return
|
|
129
|
+
if tag == "title":
|
|
130
|
+
self._in_title = False
|
|
131
|
+
return
|
|
132
|
+
if tag in _BLOCK_TAGS:
|
|
133
|
+
self._chunks.append("\n")
|
|
134
|
+
|
|
135
|
+
def handle_data(self, data: str) -> None:
|
|
136
|
+
if self._skip_depth:
|
|
137
|
+
return
|
|
138
|
+
if self._in_title:
|
|
139
|
+
self._title_chunks.append(data)
|
|
140
|
+
return
|
|
141
|
+
self._chunks.append(data)
|
|
142
|
+
|
|
143
|
+
def title(self) -> str | None:
|
|
144
|
+
title = _WHITESPACE.sub(" ", "".join(self._title_chunks)).strip()
|
|
145
|
+
return title or None
|
|
146
|
+
|
|
147
|
+
def text(self) -> str:
|
|
148
|
+
lines = [
|
|
149
|
+
_WHITESPACE.sub(" ", line).strip()
|
|
150
|
+
for line in "".join(self._chunks).splitlines()
|
|
151
|
+
]
|
|
152
|
+
text = "\n".join(lines)
|
|
153
|
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def html_to_text(html: str) -> tuple[str | None, str]:
|
|
157
|
+
"""Convert ``html`` to ``(title, text)``.
|
|
158
|
+
|
|
159
|
+
Script/style/noscript/svg content is dropped, block tags become newlines,
|
|
160
|
+
entities are decoded, and runs of more than two blank lines collapse to
|
|
161
|
+
one blank line. The title is ``None`` when the document has none.
|
|
162
|
+
"""
|
|
163
|
+
parser = _TextExtractor()
|
|
164
|
+
try:
|
|
165
|
+
parser.feed(html)
|
|
166
|
+
parser.close()
|
|
167
|
+
except Exception: # HTMLParser is forgiving; never break a tool call
|
|
168
|
+
pass
|
|
169
|
+
return parser.title(), parser.text()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Local / private address detection
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _host_of(url: str) -> str:
|
|
178
|
+
raw = str(url).strip()
|
|
179
|
+
if not raw:
|
|
180
|
+
return ""
|
|
181
|
+
# ``urlsplit`` reads "localhost:8080" as scheme="localhost"; retry as a
|
|
182
|
+
# host-relative form when there is no explicit scheme separator.
|
|
183
|
+
parsed = urlsplit(raw if "://" in raw else f"//{raw}")
|
|
184
|
+
return (parsed.hostname or "").strip().lower().rstrip(".")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def is_local_address(url: str) -> bool:
|
|
188
|
+
"""Whether ``url`` points at localhost, a private IP, or a ``.local`` name."""
|
|
189
|
+
host = _host_of(url)
|
|
190
|
+
if not host:
|
|
191
|
+
return False
|
|
192
|
+
if host in {"localhost", "localhost.localdomain"} or host.endswith(
|
|
193
|
+
(".local", ".localhost")
|
|
194
|
+
):
|
|
195
|
+
return True
|
|
196
|
+
try:
|
|
197
|
+
address = ipaddress.ip_address(host)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return False
|
|
200
|
+
return (
|
|
201
|
+
address.is_loopback
|
|
202
|
+
or address.is_private
|
|
203
|
+
or address.is_link_local
|
|
204
|
+
or address.is_reserved
|
|
205
|
+
or address.is_unspecified
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def clean_url(url: str) -> str:
|
|
210
|
+
"""Validate an http(s) URL, raising :class:`WebError` for anything else."""
|
|
211
|
+
cleaned = str(url).strip()
|
|
212
|
+
if not cleaned:
|
|
213
|
+
raise WebError("A non-empty URL is required.")
|
|
214
|
+
parsed = urlsplit(cleaned)
|
|
215
|
+
if parsed.scheme not in {"http", "https"}:
|
|
216
|
+
raise WebError("Only http:// and https:// URLs are supported.")
|
|
217
|
+
if not parsed.hostname:
|
|
218
|
+
raise WebError(f"Could not parse a host from '{cleaned}'.")
|
|
219
|
+
return cleaned
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Fetching
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _mime_type(content_type: str) -> str:
|
|
228
|
+
return content_type.split(";", 1)[0].strip().lower()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_textual(mime: str) -> bool:
|
|
232
|
+
if not mime:
|
|
233
|
+
return True
|
|
234
|
+
if mime.startswith("text/"):
|
|
235
|
+
return True
|
|
236
|
+
if mime in {
|
|
237
|
+
"application/json",
|
|
238
|
+
"application/ld+json",
|
|
239
|
+
"application/xml",
|
|
240
|
+
"application/xhtml+xml",
|
|
241
|
+
"application/javascript",
|
|
242
|
+
"application/x-javascript",
|
|
243
|
+
"application/rss+xml",
|
|
244
|
+
"application/atom+xml",
|
|
245
|
+
}:
|
|
246
|
+
return True
|
|
247
|
+
return mime.endswith(("+json", "+xml"))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _is_html(mime: str) -> bool:
|
|
251
|
+
return mime in {"text/html", "application/xhtml+xml"} or mime.endswith("+html")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _decode(body: bytes, charset: str) -> str:
|
|
255
|
+
try:
|
|
256
|
+
return body.decode(charset or "utf-8", errors="replace")
|
|
257
|
+
except (LookupError, TypeError):
|
|
258
|
+
return body.decode("utf-8", errors="replace")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def fetch_url(
|
|
262
|
+
url: str,
|
|
263
|
+
*,
|
|
264
|
+
timeout: float,
|
|
265
|
+
max_chars: int,
|
|
266
|
+
allow_local: bool,
|
|
267
|
+
transport: httpx.BaseTransport | None = None,
|
|
268
|
+
) -> tuple[str, str]:
|
|
269
|
+
"""Fetch ``url`` and return ``(final_url, text)``.
|
|
270
|
+
|
|
271
|
+
Redirects are followed; the body is read up to a byte cap derived from
|
|
272
|
+
``max_chars``. HTML is converted to text, ``text/*`` is decoded, and
|
|
273
|
+
anything else is summarized as ``[binary content: <type>, <n> bytes]``.
|
|
274
|
+
Failures raise :class:`WebError` (never a raw ``httpx`` exception).
|
|
275
|
+
"""
|
|
276
|
+
if network.offline_enabled():
|
|
277
|
+
raise WebError(network.offline_message("web fetch"))
|
|
278
|
+
cleaned = clean_url(url)
|
|
279
|
+
if is_local_address(cleaned) and not allow_local:
|
|
280
|
+
raise WebError(
|
|
281
|
+
"Refusing to fetch a local or private address. Enable it with "
|
|
282
|
+
"/config web allow-local on (or config web allow-local on)."
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
byte_cap = max(1024, int(max_chars) * MAX_BYTES_PER_CHAR)
|
|
286
|
+
final_url = cleaned
|
|
287
|
+
mime = ""
|
|
288
|
+
charset = "utf-8"
|
|
289
|
+
body = b""
|
|
290
|
+
try:
|
|
291
|
+
with httpx.Client(
|
|
292
|
+
timeout=timeout,
|
|
293
|
+
transport=transport,
|
|
294
|
+
follow_redirects=True,
|
|
295
|
+
**network.current_options(),
|
|
296
|
+
) as client:
|
|
297
|
+
with client.stream(
|
|
298
|
+
"GET",
|
|
299
|
+
cleaned,
|
|
300
|
+
headers={
|
|
301
|
+
"User-Agent": USER_AGENT,
|
|
302
|
+
"Accept": (
|
|
303
|
+
"text/html,application/xhtml+xml,text/plain;q=0.9,"
|
|
304
|
+
"*/*;q=0.5"
|
|
305
|
+
),
|
|
306
|
+
},
|
|
307
|
+
) as response:
|
|
308
|
+
final_url = str(response.url)
|
|
309
|
+
if response.status_code >= 400:
|
|
310
|
+
raise WebError(
|
|
311
|
+
f"HTTP {response.status_code} when fetching {final_url}."
|
|
312
|
+
)
|
|
313
|
+
mime = _mime_type(response.headers.get("content-type", ""))
|
|
314
|
+
charset = response.charset_encoding or "utf-8"
|
|
315
|
+
data = bytearray()
|
|
316
|
+
for chunk in response.iter_bytes():
|
|
317
|
+
data.extend(chunk)
|
|
318
|
+
if len(data) >= byte_cap:
|
|
319
|
+
del data[byte_cap:]
|
|
320
|
+
break
|
|
321
|
+
body = bytes(data)
|
|
322
|
+
except WebError:
|
|
323
|
+
raise
|
|
324
|
+
except httpx.HTTPError as exc:
|
|
325
|
+
raise WebError(
|
|
326
|
+
f"Could not fetch {cleaned}: {exc.__class__.__name__}."
|
|
327
|
+
) from exc
|
|
328
|
+
|
|
329
|
+
if not _is_textual(mime):
|
|
330
|
+
return final_url, f"[binary content: {mime or 'unknown'}, {len(body)} bytes]"
|
|
331
|
+
|
|
332
|
+
text = _decode(body, charset)
|
|
333
|
+
if _is_html(mime):
|
|
334
|
+
_title, text = html_to_text(text)
|
|
335
|
+
return final_url, text
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# Search (DuckDuckGo HTML endpoint)
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class SearchResult:
|
|
345
|
+
"""One search hit."""
|
|
346
|
+
|
|
347
|
+
title: str
|
|
348
|
+
url: str
|
|
349
|
+
snippet: str = ""
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class _DDGParser(HTMLParser):
|
|
353
|
+
"""Pull ``result__a`` / ``result__snippet`` / ``result__url`` blocks out of
|
|
354
|
+
the DuckDuckGo HTML results page."""
|
|
355
|
+
|
|
356
|
+
_FIELDS = {"title", "snippet", "display"}
|
|
357
|
+
|
|
358
|
+
def __init__(self) -> None:
|
|
359
|
+
super().__init__(convert_charrefs=True)
|
|
360
|
+
self.results: list[dict[str, str]] = []
|
|
361
|
+
self._current: dict[str, str] | None = None
|
|
362
|
+
self._capture: str | None = None
|
|
363
|
+
self._depth = 0
|
|
364
|
+
|
|
365
|
+
def _start_capture(self, field: str) -> None:
|
|
366
|
+
self._capture = field
|
|
367
|
+
self._depth = 1
|
|
368
|
+
|
|
369
|
+
def _finish(self) -> None:
|
|
370
|
+
if self._current is not None:
|
|
371
|
+
self.results.append(self._current)
|
|
372
|
+
self._current = None
|
|
373
|
+
self._capture = None
|
|
374
|
+
self._depth = 0
|
|
375
|
+
|
|
376
|
+
def finish(self) -> None:
|
|
377
|
+
"""Flush the final in-progress result block."""
|
|
378
|
+
self._finish()
|
|
379
|
+
|
|
380
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
381
|
+
if self._capture is not None:
|
|
382
|
+
self._depth += 1
|
|
383
|
+
if tag != "a":
|
|
384
|
+
return
|
|
385
|
+
attributes = {key.lower(): value or "" for key, value in attrs}
|
|
386
|
+
classes = set(attributes.get("class", "").split())
|
|
387
|
+
if "result__a" in classes:
|
|
388
|
+
self._finish()
|
|
389
|
+
self._current = {
|
|
390
|
+
"title": "",
|
|
391
|
+
"url": attributes.get("href", ""),
|
|
392
|
+
"display": "",
|
|
393
|
+
"snippet": "",
|
|
394
|
+
}
|
|
395
|
+
self._start_capture("title")
|
|
396
|
+
elif self._current is not None and "result__snippet" in classes:
|
|
397
|
+
self._start_capture("snippet")
|
|
398
|
+
elif self._current is not None and "result__url" in classes:
|
|
399
|
+
self._start_capture("display")
|
|
400
|
+
|
|
401
|
+
def handle_endtag(self, tag: str) -> None:
|
|
402
|
+
if self._capture is None:
|
|
403
|
+
return
|
|
404
|
+
if tag == "a" and self._depth <= 1:
|
|
405
|
+
self._capture = None
|
|
406
|
+
self._depth = 0
|
|
407
|
+
return
|
|
408
|
+
self._depth = max(0, self._depth - 1)
|
|
409
|
+
if self._depth == 0:
|
|
410
|
+
self._capture = None
|
|
411
|
+
|
|
412
|
+
def handle_data(self, data: str) -> None:
|
|
413
|
+
if self._capture is None or self._current is None:
|
|
414
|
+
return
|
|
415
|
+
if self._capture in self._FIELDS:
|
|
416
|
+
self._current[self._capture] += data
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _collapse(value: str) -> str:
|
|
420
|
+
return re.sub(r"\s+", " ", value).strip()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _unwrap_ddg_url(raw: str) -> str:
|
|
424
|
+
"""Undo DuckDuckGo's ``/l/?uddg=...`` click-tracking wrapper."""
|
|
425
|
+
url = raw.strip()
|
|
426
|
+
if not url:
|
|
427
|
+
return ""
|
|
428
|
+
if url.startswith("//"):
|
|
429
|
+
url = "https:" + url
|
|
430
|
+
parsed = urlsplit(url)
|
|
431
|
+
if parsed.path.startswith("/l/") or "uddg=" in parsed.query:
|
|
432
|
+
values = parse_qs(parsed.query).get("uddg")
|
|
433
|
+
if values and values[0]:
|
|
434
|
+
value = values[0]
|
|
435
|
+
return value if value.startswith(("http://", "https://")) else unquote(value)
|
|
436
|
+
return url
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def parse_ddg_results(html: str, limit: int = DEFAULT_SEARCH_COUNT) -> list[SearchResult]:
|
|
440
|
+
"""Parse a DuckDuckGo HTML results page into :class:`SearchResult`s."""
|
|
441
|
+
parser = _DDGParser()
|
|
442
|
+
parser.feed(html)
|
|
443
|
+
parser.close()
|
|
444
|
+
parser.finish()
|
|
445
|
+
|
|
446
|
+
results: list[SearchResult] = []
|
|
447
|
+
for raw in parser.results:
|
|
448
|
+
title = _collapse(raw.get("title", ""))
|
|
449
|
+
url = _unwrap_ddg_url(raw.get("url") or raw.get("display", ""))
|
|
450
|
+
if not title or not url:
|
|
451
|
+
continue
|
|
452
|
+
results.append(
|
|
453
|
+
SearchResult(title=title, url=url, snippet=_collapse(raw.get("snippet", "")))
|
|
454
|
+
)
|
|
455
|
+
if len(results) >= max(0, limit):
|
|
456
|
+
break
|
|
457
|
+
return results
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def search_web(
|
|
461
|
+
query: str,
|
|
462
|
+
*,
|
|
463
|
+
count: int = DEFAULT_SEARCH_COUNT,
|
|
464
|
+
timeout: float,
|
|
465
|
+
provider: str,
|
|
466
|
+
api_key: str | None = None,
|
|
467
|
+
transport: httpx.BaseTransport | None = None,
|
|
468
|
+
) -> list[SearchResult]:
|
|
469
|
+
"""Search the web and return up to ``count`` results.
|
|
470
|
+
|
|
471
|
+
Only the DuckDuckGo HTML endpoint is supported for now; the provider and
|
|
472
|
+
optional API key are parameters so alternate backends can slot in later.
|
|
473
|
+
Failures raise :class:`WebError`.
|
|
474
|
+
"""
|
|
475
|
+
del api_key # reserved for providers that need a key
|
|
476
|
+
if network.offline_enabled():
|
|
477
|
+
raise WebError(network.offline_message("web search"))
|
|
478
|
+
cleaned = str(query).strip()
|
|
479
|
+
if not cleaned:
|
|
480
|
+
raise WebError("Search query must not be empty.")
|
|
481
|
+
if provider != "duckduckgo":
|
|
482
|
+
raise WebError(
|
|
483
|
+
f"Unknown search provider '{provider}'. Only 'duckduckgo' is supported."
|
|
484
|
+
)
|
|
485
|
+
limit = max(1, min(int(count), MAX_SEARCH_COUNT))
|
|
486
|
+
try:
|
|
487
|
+
with httpx.Client(
|
|
488
|
+
timeout=timeout,
|
|
489
|
+
transport=transport,
|
|
490
|
+
follow_redirects=True,
|
|
491
|
+
headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
|
|
492
|
+
**network.current_options(),
|
|
493
|
+
) as client:
|
|
494
|
+
response = client.get(DDG_HTML_URL, params={"q": cleaned})
|
|
495
|
+
except httpx.HTTPError as exc:
|
|
496
|
+
raise WebError(f"Web search failed: {exc.__class__.__name__}.") from exc
|
|
497
|
+
if response.status_code != 200:
|
|
498
|
+
raise WebError(f"Search provider returned HTTP {response.status_code}.")
|
|
499
|
+
return parse_ddg_results(response.text, limit=limit)
|