diff options
| author | tslil <tslil@posteo.de> | 2026-09-07 15:31:08 +0100 |
|---|---|---|
| committer | tslil <tslil@posteo.de> | 2026-09-07 15:37:06 +0100 |
| commit | afc1672a6d14d203746362a9e5863ae2bc7882b3 (patch) | |
| tree | 95a2f96a1a10c17988ede4cd700d663e0f885b58 | |
| parent | 601df114c430bbaf7d49b201295b29d6fed2b9b8 (diff) | |
[hax] store a websearch skill, let's see how well this works
| -rw-r--r-- | skills/web-search/SKILL.md | 13 | ||||
| -rwxr-xr-x | skills/web-search/web_search.py | 128 |
2 files changed, 141 insertions, 0 deletions
diff --git a/skills/web-search/SKILL.md b/skills/web-search/SKILL.md new file mode 100644 index 0000000..30d773a --- /dev/null +++ b/skills/web-search/SKILL.md @@ -0,0 +1,13 @@ +--- +description: Web search when a task needs information beyond the local project or when the user mentions searching the web. +--- + +Run the script next to this file when a task needs current or external information (docs, versions, error messages, news, "what is X"): + +```sh +~/.config/hax/skills/web-search/web_search.py [-n N] QUERY... +``` + +- Prints `N` results (default 5) as numbered `title / url / snippet` blocks, plain text. +- Exit status 2 with a message on stderr means the search backend rate-limited or served a bot challenge: wait and retry once, then tell the user that search is unavailable. +- Search in the language the user wrote in. For error messages, search the exact message text quoted. diff --git a/skills/web-search/web_search.py b/skills/web-search/web_search.py new file mode 100755 index 0000000..8124dc9 --- /dev/null +++ b/skills/web-search/web_search.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Search the web via DuckDuckGo's html endpoint and print plain-text results. + +Exits 2 on rate-limit/bot-block (retry later), 1 on other errors. +""" + +import argparse +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from html.parser import HTMLParser + +ENDPOINT = "https://html.duckduckgo.com/html/" +USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:155.0) Gecko/20100101 Firefox/155.0" + + +@dataclass +class SearchResult: + title: str + url: str + snippet: str = "" + + +class ResultsParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.results: list[SearchResult] = [] + # Title text accumulated while inside a result anchor. + self._title_parts: list[str] | None = None + self._href: str = "" + # Index of the result whose snippet we are currently inside. + self._snippet_for: int | None = None + self._snippet_parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "a": + return + classes = dict(attrs).get("class", "").split() + if "result__a" in classes: + self._title_parts = [] + self._href = dict(attrs).get("href", "") + elif "result__snippet" in classes: + self._snippet_for = len(self.results) - 1 + self._snippet_parts = [] + + def handle_data(self, data: str) -> None: + if self._title_parts is not None: + self._title_parts.append(data) + elif self._snippet_for is not None: + self._snippet_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag != "a": + return + if self._title_parts is not None: + title = " ".join("".join(self._title_parts).split()) + url = self._unwrap_redirect(self._href) + if title and url: + self.results.append(SearchResult(title, url)) + self._title_parts = None + elif self._snippet_for is not None: + if 0 <= self._snippet_for < len(self.results): + self.results[self._snippet_for].snippet = " ".join( + "".join(self._snippet_parts).split() + ) + self._snippet_for = None + + @staticmethod + def _unwrap_redirect(href: str) -> str: + parsed = urllib.parse.urlparse(href) + if parsed.netloc.endswith("duckduckgo.com") and parsed.path == "/l/": + target = urllib.parse.parse_qs(parsed.query).get("uddg", [None])[0] + return target if target else href + return href + + +def fetch(query: str) -> str: + data = urllib.parse.urlencode({"q": query}).encode() + request = urllib.request.Request( + ENDPOINT, data=data, headers={"User-Agent": USER_AGENT} + ) + with urllib.request.urlopen(request, timeout=15) as response: + return response.read().decode("utf-8", "replace") + + +def fail(message: str, exit_code: int = 1) -> None: + print(f"web_search: {message}", file=sys.stderr) + sys.exit(exit_code) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + _ = parser.add_argument( + "-n", type=int, default=5, metavar="N", help="max results (default 5)" + ) + _ = parser.add_argument("query", nargs="+", help="search terms") + args = parser.parse_args() + query = " ".join(args.query) + + try: + body = fetch(query) + except urllib.error.HTTPError as err: + if err.code in (403, 429): + fail(f"rate-limited or blocked (HTTP {err.code}); try later", exit_code=2) + fail(f"HTTP {err.code}") + except OSError as err: + fail(f"network error: {err}") + + extractor = ResultsParser() + extractor.feed(body) + + if not extractor.results: + if "anomaly" in body or "challenge" in body: + fail("bot challenge served instead of results; try later", exit_code=2) + print(f"no results for: {query}") + return + + for number, result in enumerate(extractor.results[: args.n], 1): + print(f"{number}. {result.title}\n {result.url}") + if result.snippet: + print(f" {result.snippet}") + + +if __name__ == "__main__": + main() |
