contextfetch 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.
contextfetch/__init__.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""ContextFetch: web search and web pages as Markdown for AI agents.
|
|
2
|
+
|
|
3
|
+
A thin client for https://api.contextfetch.dev that uses only the Python standard library.
|
|
4
|
+
API reference: https://contextfetch.dev/docs/reference
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from email.message import Message
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"APIConnectionError",
|
|
20
|
+
"AnswerResponse",
|
|
21
|
+
"AuthenticationError",
|
|
22
|
+
"Balance",
|
|
23
|
+
"ContentsResponse",
|
|
24
|
+
"ContextFetch",
|
|
25
|
+
"ContextFetchError",
|
|
26
|
+
"CrawlResponse",
|
|
27
|
+
"DEFAULT_BASE_URL",
|
|
28
|
+
"FreeKey",
|
|
29
|
+
"MapResponse",
|
|
30
|
+
"Page",
|
|
31
|
+
"PaymentRequiredError",
|
|
32
|
+
"ResearchResponse",
|
|
33
|
+
"Result",
|
|
34
|
+
"SearchResponse",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
DEFAULT_BASE_URL = "https://api.contextfetch.dev"
|
|
38
|
+
|
|
39
|
+
# A contents option: True, or an options object as the API reference describes it,
|
|
40
|
+
# e.g. highlights={"query": "task groups", "highlightsPerUrl": 2}.
|
|
41
|
+
Option = Union[bool, Dict[str, Any]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---- responses (JSON as the API returns it; every key is optional) ----
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Result(TypedDict, total=False):
|
|
48
|
+
id: str
|
|
49
|
+
url: str
|
|
50
|
+
title: Optional[str]
|
|
51
|
+
score: Optional[float]
|
|
52
|
+
publishedDate: Optional[str]
|
|
53
|
+
author: Optional[str]
|
|
54
|
+
text: str # the page as Markdown
|
|
55
|
+
highlights: List[str]
|
|
56
|
+
highlightScores: List[float]
|
|
57
|
+
prunedContext: str
|
|
58
|
+
subQueries: List[str] # research: the sub-queries that found this page
|
|
59
|
+
depth: int # crawl: link hops from the start URL
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SearchResponse(TypedDict, total=False):
|
|
63
|
+
requestId: str
|
|
64
|
+
results: List[Result]
|
|
65
|
+
costDollars: Dict[str, float]
|
|
66
|
+
searchTimeMs: float
|
|
67
|
+
contextfetch: Dict[str, Any] # cache, timingsMs, tokens, sources
|
|
68
|
+
notice: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ContentsResponse(SearchResponse, total=False):
|
|
72
|
+
statuses: List[Dict[str, Any]] # one per URL: {"id", "status": "success" | "error", "error"?}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ResearchResponse(SearchResponse, total=False):
|
|
76
|
+
query: str
|
|
77
|
+
subQueries: List[Dict[str, Any]] # {"query", "status", "results"}
|
|
78
|
+
evidence: str # one Markdown document with numbered [n] sources quoted verbatim
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CrawlResponse(SearchResponse, total=False):
|
|
82
|
+
baseUrl: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Citation(TypedDict, total=False):
|
|
86
|
+
id: str
|
|
87
|
+
url: str
|
|
88
|
+
title: Optional[str]
|
|
89
|
+
publishedDate: Optional[str]
|
|
90
|
+
author: Optional[str]
|
|
91
|
+
text: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Passage(TypedDict, total=False):
|
|
95
|
+
text: str
|
|
96
|
+
score: float
|
|
97
|
+
citation: int # 1-based index into citations
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AnswerResponse(TypedDict, total=False):
|
|
101
|
+
answer: str # passages quoted verbatim, each followed by [n]; "" when nothing is relevant enough
|
|
102
|
+
type: str # always "extractive"
|
|
103
|
+
note: str
|
|
104
|
+
citations: List[Citation]
|
|
105
|
+
passages: List[Passage]
|
|
106
|
+
costDollars: Dict[str, float]
|
|
107
|
+
contextfetch: Dict[str, Any]
|
|
108
|
+
notice: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MapResponse(TypedDict, total=False):
|
|
112
|
+
requestId: str
|
|
113
|
+
baseUrl: str
|
|
114
|
+
results: List[str]
|
|
115
|
+
costDollars: Dict[str, float]
|
|
116
|
+
contextfetch: Dict[str, Any]
|
|
117
|
+
notice: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Balance(TypedDict, total=False):
|
|
121
|
+
credits: int
|
|
122
|
+
auto_reload: Dict[str, Any]
|
|
123
|
+
burn_rate_per_day: float
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class FreeKey(TypedDict, total=False):
|
|
127
|
+
apiKey: str
|
|
128
|
+
credits: int
|
|
129
|
+
buy: Dict[str, str] # pack id -> checkout link
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Page:
|
|
134
|
+
"""A page read through GET /r/{url}."""
|
|
135
|
+
|
|
136
|
+
url: str
|
|
137
|
+
markdown: str
|
|
138
|
+
raw_tokens: Optional[int] = None # X-Raw-Tokens
|
|
139
|
+
output_tokens: Optional[int] = None # X-Output-Tokens
|
|
140
|
+
token_savings: Optional[float] = None # X-Token-Savings, percent
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---- errors ----
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ContextFetchError(Exception):
|
|
147
|
+
"""An API or transport error.
|
|
148
|
+
|
|
149
|
+
status is the HTTP status (None when no response arrived), code the body's "error" field
|
|
150
|
+
(e.g. "invalid_api_key", "recall_unavailable") and body the parsed JSON body, if any.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, message: str, status: Optional[int] = None, code: Optional[str] = None, body: Any = None) -> None:
|
|
154
|
+
super().__init__(message)
|
|
155
|
+
self.message = message
|
|
156
|
+
self.status = status
|
|
157
|
+
self.code = code
|
|
158
|
+
self.body = body
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class AuthenticationError(ContextFetchError):
|
|
162
|
+
"""HTTP 401: the API key is not recognised."""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class PaymentRequiredError(ContextFetchError):
|
|
166
|
+
"""HTTP 402: the key's credits or the keyless daily allowance ran out.
|
|
167
|
+
|
|
168
|
+
Show prompt_to_user to your human and stop retrying. payment_url (the X-Payment-URL header)
|
|
169
|
+
opens Stripe Checkout in one click; packs lists every pack with its own checkout_url.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
message: str,
|
|
175
|
+
status: Optional[int] = None,
|
|
176
|
+
code: Optional[str] = None,
|
|
177
|
+
body: Any = None,
|
|
178
|
+
payment_url: Optional[str] = None,
|
|
179
|
+
prompt_to_user: Optional[str] = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
super().__init__(message, status, code, body)
|
|
182
|
+
self.payment_url = payment_url
|
|
183
|
+
self.prompt_to_user = prompt_to_user
|
|
184
|
+
self.packs: List[Dict[str, Any]] = (body.get("packs") or []) if isinstance(body, dict) else []
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class APIConnectionError(ContextFetchError):
|
|
188
|
+
"""No HTTP response: DNS, connection or timeout failure."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---- client ----
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class ContextFetch:
|
|
195
|
+
"""Client for the ContextFetch API.
|
|
196
|
+
|
|
197
|
+
api_key defaults to the CONTEXTFETCH_API_KEY environment variable. With no key the client uses the
|
|
198
|
+
keyless tier (a small daily allowance per IP); free_key() returns a starter key with no signup.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 60.0) -> None:
|
|
202
|
+
self.api_key = (api_key if api_key is not None else os.environ.get("CONTEXTFETCH_API_KEY")) or None
|
|
203
|
+
self.base_url = base_url.rstrip("/")
|
|
204
|
+
self.timeout = timeout
|
|
205
|
+
|
|
206
|
+
def search(
|
|
207
|
+
self,
|
|
208
|
+
query: str,
|
|
209
|
+
*,
|
|
210
|
+
num_results: Optional[int] = None,
|
|
211
|
+
include_domains: Optional[List[str]] = None,
|
|
212
|
+
exclude_domains: Optional[List[str]] = None,
|
|
213
|
+
start_published_date: Optional[str] = None,
|
|
214
|
+
text: Option = False,
|
|
215
|
+
highlights: Option = False,
|
|
216
|
+
pruned_context: Option = False,
|
|
217
|
+
) -> SearchResponse:
|
|
218
|
+
"""POST /v1/search. Results carry page text, highlights or prunedContext only when asked for."""
|
|
219
|
+
body = {
|
|
220
|
+
"query": query,
|
|
221
|
+
"numResults": num_results,
|
|
222
|
+
"includeDomains": include_domains,
|
|
223
|
+
"excludeDomains": exclude_domains,
|
|
224
|
+
"startPublishedDate": start_published_date,
|
|
225
|
+
"contents": _contents(text, highlights, pruned_context),
|
|
226
|
+
}
|
|
227
|
+
return self._post("/v1/search", body) # type: ignore[return-value]
|
|
228
|
+
|
|
229
|
+
def find_similar(
|
|
230
|
+
self,
|
|
231
|
+
url: str,
|
|
232
|
+
*,
|
|
233
|
+
num_results: Optional[int] = None,
|
|
234
|
+
include_domains: Optional[List[str]] = None,
|
|
235
|
+
exclude_domains: Optional[List[str]] = None,
|
|
236
|
+
start_published_date: Optional[str] = None,
|
|
237
|
+
text: Option = False,
|
|
238
|
+
highlights: Option = False,
|
|
239
|
+
pruned_context: Option = False,
|
|
240
|
+
) -> SearchResponse:
|
|
241
|
+
"""POST /v1/findSimilar: pages like url, from other domains. Same shape as search()."""
|
|
242
|
+
body = {
|
|
243
|
+
"url": url,
|
|
244
|
+
"numResults": num_results,
|
|
245
|
+
"includeDomains": include_domains,
|
|
246
|
+
"excludeDomains": exclude_domains,
|
|
247
|
+
"startPublishedDate": start_published_date,
|
|
248
|
+
"contents": _contents(text, highlights, pruned_context),
|
|
249
|
+
}
|
|
250
|
+
return self._post("/v1/findSimilar", body) # type: ignore[return-value]
|
|
251
|
+
|
|
252
|
+
def contents(
|
|
253
|
+
self,
|
|
254
|
+
urls: List[str],
|
|
255
|
+
*,
|
|
256
|
+
text: Option = True,
|
|
257
|
+
highlights: Option = False,
|
|
258
|
+
pruned_context: Option = False,
|
|
259
|
+
) -> ContentsResponse:
|
|
260
|
+
"""POST /v1/contents: pages as Markdown. URLs that fail come back in statuses and are not billed."""
|
|
261
|
+
body = {"urls": list(urls), "text": text, "highlights": highlights or None, "pruned_context": pruned_context or None}
|
|
262
|
+
return self._post("/v1/contents", body) # type: ignore[return-value]
|
|
263
|
+
|
|
264
|
+
def answer(self, query: str, *, num_results: Optional[int] = None, text: bool = False) -> AnswerResponse:
|
|
265
|
+
"""POST /v1/answer: an extractive answer, passages quoted verbatim with [n] citations. Nothing is generated."""
|
|
266
|
+
return self._post("/v1/answer", {"query": query, "numResults": num_results, "text": text or None}) # type: ignore[return-value]
|
|
267
|
+
|
|
268
|
+
def research(
|
|
269
|
+
self,
|
|
270
|
+
query: str,
|
|
271
|
+
*,
|
|
272
|
+
sub_queries: Optional[List[str]] = None,
|
|
273
|
+
num_results_per_query: Optional[int] = None,
|
|
274
|
+
) -> ResearchResponse:
|
|
275
|
+
"""POST /v1/research: several searches in one call, ranked together into an evidence pack."""
|
|
276
|
+
body = {"query": query, "subQueries": sub_queries, "numResultsPerQuery": num_results_per_query}
|
|
277
|
+
return self._post("/v1/research", body) # type: ignore[return-value]
|
|
278
|
+
|
|
279
|
+
def crawl(
|
|
280
|
+
self,
|
|
281
|
+
url: str,
|
|
282
|
+
*,
|
|
283
|
+
limit: Optional[int] = None,
|
|
284
|
+
max_depth: Optional[int] = None,
|
|
285
|
+
max_breadth: Optional[int] = None,
|
|
286
|
+
query: Optional[str] = None,
|
|
287
|
+
include_paths: Optional[List[str]] = None,
|
|
288
|
+
exclude_paths: Optional[List[str]] = None,
|
|
289
|
+
) -> CrawlResponse:
|
|
290
|
+
"""POST /v1/crawl: one site from a start URL, every page as Markdown. robots.txt is obeyed."""
|
|
291
|
+
body = {
|
|
292
|
+
"url": url,
|
|
293
|
+
"limit": limit,
|
|
294
|
+
"maxDepth": max_depth,
|
|
295
|
+
"maxBreadth": max_breadth,
|
|
296
|
+
"query": query,
|
|
297
|
+
"includePaths": include_paths,
|
|
298
|
+
"excludePaths": exclude_paths,
|
|
299
|
+
}
|
|
300
|
+
return self._post("/v1/crawl", body) # type: ignore[return-value]
|
|
301
|
+
|
|
302
|
+
def map(
|
|
303
|
+
self,
|
|
304
|
+
url: str,
|
|
305
|
+
*,
|
|
306
|
+
limit: Optional[int] = None,
|
|
307
|
+
include_paths: Optional[List[str]] = None,
|
|
308
|
+
exclude_paths: Optional[List[str]] = None,
|
|
309
|
+
) -> MapResponse:
|
|
310
|
+
"""POST /v1/map: a site's URLs from its links and sitemaps, without fetching the pages."""
|
|
311
|
+
body = {"url": url, "limit": limit, "includePaths": include_paths, "excludePaths": exclude_paths}
|
|
312
|
+
return self._post("/v1/map", body) # type: ignore[return-value]
|
|
313
|
+
|
|
314
|
+
def read(self, url: str, *, query: Optional[str] = None) -> Page:
|
|
315
|
+
"""GET /r/{url}: any public URL as Markdown. With query, only the chunks relevant to it."""
|
|
316
|
+
target = urllib.parse.urldefrag(url)[0]
|
|
317
|
+
path = "/r/" + urllib.parse.quote(target, safe=":/?&=%@+,;~!$'()*[]")
|
|
318
|
+
if query:
|
|
319
|
+
path += ("&" if "?" in target else "?") + urllib.parse.urlencode({"q": query})
|
|
320
|
+
raw, headers = self._request("GET", path)
|
|
321
|
+
return Page(
|
|
322
|
+
url=target,
|
|
323
|
+
markdown=raw.decode("utf-8", "replace"),
|
|
324
|
+
raw_tokens=_number(headers.get("X-Raw-Tokens"), int),
|
|
325
|
+
output_tokens=_number(headers.get("X-Output-Tokens"), int),
|
|
326
|
+
token_savings=_number(headers.get("X-Token-Savings"), float),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def balance(self) -> Balance:
|
|
330
|
+
"""GET /v1/balance: credits left on this key and its auto-reload settings. Free."""
|
|
331
|
+
return _json(self._request("GET", "/v1/balance")[0]) # type: ignore[return-value]
|
|
332
|
+
|
|
333
|
+
def free_key(self) -> FreeKey:
|
|
334
|
+
"""POST /v1/keys/free: a free starter key, one per network. Sent without this client's key."""
|
|
335
|
+
return _json(self._request("POST", "/v1/keys/free", auth=False)[0]) # type: ignore[return-value]
|
|
336
|
+
|
|
337
|
+
# ---- transport ----
|
|
338
|
+
|
|
339
|
+
def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
340
|
+
return _json(self._request("POST", path, {k: v for k, v in body.items() if v is not None})[0])
|
|
341
|
+
|
|
342
|
+
def _request(
|
|
343
|
+
self, method: str, path: str, body: Optional[Dict[str, Any]] = None, auth: bool = True
|
|
344
|
+
) -> Tuple[bytes, Message]:
|
|
345
|
+
headers = {"User-Agent": f"contextfetch-python/{__version__}"}
|
|
346
|
+
data = None
|
|
347
|
+
if body is not None:
|
|
348
|
+
data = json.dumps(body).encode("utf-8")
|
|
349
|
+
headers["Content-Type"] = "application/json"
|
|
350
|
+
if auth and self.api_key:
|
|
351
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
352
|
+
req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
|
|
353
|
+
try:
|
|
354
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
355
|
+
return res.read(), res.headers
|
|
356
|
+
except urllib.error.HTTPError as e:
|
|
357
|
+
raise _api_error(e.code, e.read(), e.headers) from None
|
|
358
|
+
except (urllib.error.URLError, OSError) as e: # OSError covers timeouts
|
|
359
|
+
reason = getattr(e, "reason", None) or e
|
|
360
|
+
raise APIConnectionError(f"Could not reach the ContextFetch API at {self.base_url}: {reason}") from e
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _contents(text: Option, highlights: Option, pruned_context: Option) -> Optional[Dict[str, Option]]:
|
|
364
|
+
wanted = {"text": text, "highlights": highlights, "pruned_context": pruned_context}
|
|
365
|
+
return {k: v for k, v in wanted.items() if v} or None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _json(raw: bytes) -> Dict[str, Any]:
|
|
369
|
+
try:
|
|
370
|
+
data = json.loads(raw)
|
|
371
|
+
except ValueError:
|
|
372
|
+
data = None
|
|
373
|
+
if not isinstance(data, dict):
|
|
374
|
+
raise ContextFetchError(f"The ContextFetch API returned an unreadable response: {raw[:200]!r}")
|
|
375
|
+
return data
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _number(value: Optional[str], kind: Any) -> Any:
|
|
379
|
+
try:
|
|
380
|
+
return kind(value) if value is not None else None
|
|
381
|
+
except ValueError:
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _api_error(status: int, raw: bytes, headers: Message) -> ContextFetchError:
|
|
386
|
+
try:
|
|
387
|
+
body: Any = json.loads(raw)
|
|
388
|
+
except ValueError:
|
|
389
|
+
body = None
|
|
390
|
+
info: Dict[str, Any] = body if isinstance(body, dict) else {}
|
|
391
|
+
code = info.get("error") if isinstance(info.get("error"), str) else None
|
|
392
|
+
detail = info.get("message") or info.get("detail") or code or raw.decode("utf-8", "replace")[:300]
|
|
393
|
+
if not isinstance(detail, str):
|
|
394
|
+
detail = json.dumps(detail)[:300]
|
|
395
|
+
message = f"ContextFetch API returned HTTP {status}" + (f" ({code})" if code else "") + (f": {detail}" if detail and detail != code else "")
|
|
396
|
+
if status == 401:
|
|
397
|
+
return AuthenticationError(message, status, code, body)
|
|
398
|
+
if status == 402:
|
|
399
|
+
action: Dict[str, Any] = info["action"] if isinstance(info.get("action"), dict) else {}
|
|
400
|
+
prompt = action.get("prompt_to_user")
|
|
401
|
+
if prompt and prompt not in message:
|
|
402
|
+
message += f" {prompt}"
|
|
403
|
+
return PaymentRequiredError(message, status, code, body, headers.get("X-Payment-URL") or action.get("url"), prompt)
|
|
404
|
+
return ContextFetchError(message, status, code, body)
|
contextfetch/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: contextfetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the ContextFetch API: web search and web pages as Markdown for AI agents.
|
|
5
|
+
Project-URL: Homepage, https://contextfetch.dev
|
|
6
|
+
Project-URL: Documentation, https://contextfetch.dev/docs/reference
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai-agents,contextfetch,llm,markdown,rag,search,web-search
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# contextfetch
|
|
20
|
+
|
|
21
|
+
Python client for the [ContextFetch](https://contextfetch.dev) API: web search, pages as Markdown, extractive answers, research, crawl and site maps for AI agents.
|
|
22
|
+
|
|
23
|
+
- No dependencies: standard library only (`urllib`, `json`).
|
|
24
|
+
- Typed: ships `py.typed`; responses are `TypedDict`s.
|
|
25
|
+
- Python 3.9 or newer.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install contextfetch
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quickstart
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from contextfetch import ContextFetch
|
|
37
|
+
|
|
38
|
+
client = ContextFetch() # reads CONTEXTFETCH_API_KEY; with no key it uses the keyless tier
|
|
39
|
+
|
|
40
|
+
res = client.search("python asyncio timeout", num_results=5, highlights=True, pruned_context=True)
|
|
41
|
+
for r in res["results"]:
|
|
42
|
+
print(r["url"], r.get("title"))
|
|
43
|
+
print(r.get("prunedContext", "")[:300])
|
|
44
|
+
|
|
45
|
+
page = client.read("https://docs.python.org/3/library/asyncio-task.html", query="task groups")
|
|
46
|
+
print(page.markdown)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No key yet? Get a free starter key (one per network, no signup):
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
key = ContextFetch().free_key()["apiKey"]
|
|
53
|
+
client = ContextFetch(key)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Methods
|
|
57
|
+
|
|
58
|
+
| Method | Endpoint |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `search(query, *, num_results, include_domains, exclude_domains, start_published_date, text, highlights, pruned_context)` | `POST /v1/search` |
|
|
61
|
+
| `find_similar(url, *, ...same options as search)` | `POST /v1/findSimilar` |
|
|
62
|
+
| `contents(urls, *, text=True, highlights, pruned_context)` | `POST /v1/contents` |
|
|
63
|
+
| `answer(query, *, num_results, text)` | `POST /v1/answer` (extractive: passages quoted verbatim with `[n]` citations) |
|
|
64
|
+
| `research(query, *, sub_queries, num_results_per_query)` | `POST /v1/research` |
|
|
65
|
+
| `crawl(url, *, limit, max_depth, max_breadth, query, include_paths, exclude_paths)` | `POST /v1/crawl` |
|
|
66
|
+
| `map(url, *, limit, include_paths, exclude_paths)` | `POST /v1/map` |
|
|
67
|
+
| `read(url, *, query)` | `GET /r/{url}`, returns a `Page` (`markdown`, `raw_tokens`, `output_tokens`, `token_savings`) |
|
|
68
|
+
| `balance()` | `GET /v1/balance` |
|
|
69
|
+
| `free_key()` | `POST /v1/keys/free` |
|
|
70
|
+
|
|
71
|
+
`text`, `highlights` and `pruned_context` take `True` or an options object as described in the [API reference](https://contextfetch.dev/docs/reference), for example `highlights={"query": "task groups", "highlightsPerUrl": 2}`. Responses are the API's JSON as dicts, with the API's field names (`publishedDate`, `prunedContext`, ...).
|
|
72
|
+
|
|
73
|
+
Options: `ContextFetch(api_key=None, *, base_url="https://api.contextfetch.dev", timeout=60.0)`.
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
Every error is a `ContextFetchError` with `status`, `code` (the body's `error` field) and `body`.
|
|
78
|
+
|
|
79
|
+
- `AuthenticationError`: HTTP 401, the key is not recognised.
|
|
80
|
+
- `PaymentRequiredError`: HTTP 402, credits or the keyless allowance ran out. `payment_url` is a one-click checkout link and `prompt_to_user` is a sentence to show your user; stop retrying until they pay.
|
|
81
|
+
- `APIConnectionError`: no response (DNS, connection or timeout).
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from contextfetch import ContextFetch, PaymentRequiredError
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
ContextFetch().search("rust async runtimes")
|
|
88
|
+
except PaymentRequiredError as e:
|
|
89
|
+
print(e.prompt_to_user) # includes e.payment_url
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
python -m unittest discover tests # unit tests, local fake server
|
|
96
|
+
CONTEXTFETCH_API_KEY=... python -m unittest tests.test_live -v # live smoke test, a few credits
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
contextfetch/__init__.py,sha256=2_OvZv2-_qznHgv2eg8zGFBdVfllKOzMV-8QcjBFfV4,14564
|
|
2
|
+
contextfetch/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
contextfetch-0.1.0.dist-info/METADATA,sha256=18Da5-jTXXPbDhIfcTBiLYIw2rIaERHNFc_KjmsueTg,4021
|
|
4
|
+
contextfetch-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
contextfetch-0.1.0.dist-info/licenses/LICENSE,sha256=T3hyb__jgjDQMf3kKkX2FgfWhxjxCYAZWeuGaOkSEIs,1069
|
|
6
|
+
contextfetch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ContextFetch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|