summaryrefslogtreecommitdiff
path: root/skills/web-search/web_search.py
blob: 7889f96c2e1c454c8d5da9d3b103e4d8e7857502 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#!/usr/bin/env python3
"""
Search the web via the Kagi Search API and print plain-text results.

The API key is read from $KAGI_API_KEY.
Exits 2 on rate-limit (retry later), 1 on other errors.
"""

import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass

ENDPOINT = "https://kagi.com/api/v1/search"
API_KEY_ENV = "KAGI_API_KEY"


@dataclass
class SearchResult:
    title: str
    url: str
    snippet: str = ""


def fail(message: str, exit_code: int = 1) -> None:
    print(f"web_search: {message}", file=sys.stderr)
    sys.exit(exit_code)


def error_message(err: urllib.error.HTTPError) -> str:
    try:
        payload = json.loads(err.read().decode("utf-8", "replace"))
        details = payload.get("errors") or payload.get("error") or [{}]
        first = details[0]
        bits = [b for b in (first.get("code"), first.get("message")) if b]
        if bits:
            return "; ".join(bits)
    except (ValueError, AttributeError, IndexError):
        pass
    return err.reason or "unknown error"


def fetch(query: str, limit: int) -> dict:
    key = os.environ.get(API_KEY_ENV, "").strip()
    if not key:
        fail(f"{API_KEY_ENV} is not set", exit_code=1)
    body = json.dumps({"query": query, "limit": limit}).encode()
    request = urllib.request.Request(
        ENDPOINT,
        data=body,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {key}",
        },
    )
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.loads(response.read().decode("utf-8", "replace"))


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:
        payload = fetch(query, args.n)
    except urllib.error.HTTPError as err:
        if err.code == 429:
            fail("rate-limited (HTTP 429); try later", exit_code=2)
        fail(f"HTTP {err.code}: {error_message(err)}")
    except OSError as err:
        fail(f"network error: {err}")

    results = [
        SearchResult(
            title=item.get("title", "").strip(),
            url=item.get("url", "").strip(),
            snippet=" ".join(item.get("snippet", "").split()),
        )
        for item in payload.get("data", {}).get("search", [])
    ]
    results = [r for r in results if r.title and r.url]

    if not results:
        print(f"no results for: {query}")
        return

    for number, result in enumerate(results[: args.n], 1):
        print(f"{number}. {result.title}\n   {result.url}")
        if result.snippet:
            print(f"   {result.snippet}")


if __name__ == "__main__":
    main()