datafuel 0.5.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.
- datafuel/__init__.py +165 -0
- datafuel/_client.py +1327 -0
- datafuel/_core.py +556 -0
- datafuel/errors.py +167 -0
- datafuel/models.py +659 -0
- datafuel/py.typed +0 -0
- datafuel-0.5.0.dist-info/METADATA +329 -0
- datafuel-0.5.0.dist-info/RECORD +10 -0
- datafuel-0.5.0.dist-info/WHEEL +4 -0
- datafuel-0.5.0.dist-info/licenses/LICENSE +21 -0
datafuel/_client.py
ADDED
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
"""The sync and async clients. They only move bytes; :mod:`datafuel._core` decides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import AsyncIterator, Iterator
|
|
10
|
+
from datetime import date
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
16
|
+
|
|
17
|
+
from . import _core as core
|
|
18
|
+
from .errors import (
|
|
19
|
+
APIError,
|
|
20
|
+
DataFuelError,
|
|
21
|
+
NoAPIKey,
|
|
22
|
+
TransportError,
|
|
23
|
+
WaitTimeout,
|
|
24
|
+
api_error,
|
|
25
|
+
)
|
|
26
|
+
from .models import (
|
|
27
|
+
AIProvider,
|
|
28
|
+
Analytics,
|
|
29
|
+
BalanceSplit,
|
|
30
|
+
CancelResult,
|
|
31
|
+
Capabilities,
|
|
32
|
+
CrawlPage,
|
|
33
|
+
CrawlResult,
|
|
34
|
+
CrawlResultsPage,
|
|
35
|
+
CrawlStatus,
|
|
36
|
+
Health,
|
|
37
|
+
JobResults,
|
|
38
|
+
JobsPage,
|
|
39
|
+
JobStatus,
|
|
40
|
+
JSInstruction,
|
|
41
|
+
Profile,
|
|
42
|
+
ProtectionCheck,
|
|
43
|
+
Proxy,
|
|
44
|
+
ProxyCountry,
|
|
45
|
+
ProxyLocation,
|
|
46
|
+
Result,
|
|
47
|
+
SiteMap,
|
|
48
|
+
Status,
|
|
49
|
+
TasksPage,
|
|
50
|
+
TransactionsPage,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_Model = TypeVar("_Model", bound=BaseModel)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Base:
|
|
57
|
+
"""Configuration and wire formatting shared by both clients."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
api_key: str | None = None,
|
|
62
|
+
*,
|
|
63
|
+
base_url: str = core.DEFAULT_BASE_URL,
|
|
64
|
+
timeout: float | None = core.DEFAULT_TIMEOUT,
|
|
65
|
+
max_retries: int = 2,
|
|
66
|
+
poll_interval: float = 2.0,
|
|
67
|
+
user_agent: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self.api_key = api_key or os.environ.get("DATAFUEL_API_KEY", "")
|
|
70
|
+
self.base_url = base_url.rstrip("/")
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
self.max_retries = max(max_retries, 0)
|
|
73
|
+
# A non-positive interval would poll in a tight loop against the API.
|
|
74
|
+
self.poll_interval = poll_interval if poll_interval > 0 else 2.0
|
|
75
|
+
sdk = f"datafuel-py/{core.VERSION}"
|
|
76
|
+
self.user_agent = f"{user_agent} {sdk}" if user_agent else sdk
|
|
77
|
+
|
|
78
|
+
def _request_args(self, request: core.Request, timeout: float | None) -> dict[str, Any]:
|
|
79
|
+
if not self.api_key:
|
|
80
|
+
raise NoAPIKey("no API key: pass one to the client or set DATAFUEL_API_KEY")
|
|
81
|
+
return {
|
|
82
|
+
"method": request.method,
|
|
83
|
+
"url": self.base_url + request.path,
|
|
84
|
+
"params": request.params,
|
|
85
|
+
"json": request.body,
|
|
86
|
+
"headers": core.headers(self.api_key, self.user_agent, request),
|
|
87
|
+
"timeout": self.timeout if timeout is None else timeout,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _parse(response: httpx.Response, degraded_ok: bool = False) -> Any:
|
|
92
|
+
try:
|
|
93
|
+
body: Any = response.json() if response.content else None
|
|
94
|
+
except ValueError:
|
|
95
|
+
body = response.text
|
|
96
|
+
if (
|
|
97
|
+
degraded_ok
|
|
98
|
+
and response.status_code == 503
|
|
99
|
+
and isinstance(body, dict)
|
|
100
|
+
and "status" in body
|
|
101
|
+
):
|
|
102
|
+
return body
|
|
103
|
+
if response.status_code >= 400:
|
|
104
|
+
raise api_error(response.status_code, body, _retry_after(response))
|
|
105
|
+
return body
|
|
106
|
+
|
|
107
|
+
def _budget(self, timeout: float | None) -> float:
|
|
108
|
+
"""How long a synchronous call may keep waiting for its result."""
|
|
109
|
+
if timeout is not None:
|
|
110
|
+
return timeout
|
|
111
|
+
return self.timeout if self.timeout is not None else math.inf
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _model(model: type[_Model], body: Any) -> _Model:
|
|
115
|
+
"""Parse an answer, turning a surprise into a DataFuelError."""
|
|
116
|
+
try:
|
|
117
|
+
return model.model_validate(body)
|
|
118
|
+
except ValidationError as exc:
|
|
119
|
+
raise DataFuelError(f"unexpected answer from the API: {exc}") from exc
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _models(model: type[_Model], body: Any) -> list[_Model]:
|
|
123
|
+
try:
|
|
124
|
+
return TypeAdapter(list[model]).validate_python(body) # type: ignore[valid-type]
|
|
125
|
+
except ValidationError as exc:
|
|
126
|
+
raise DataFuelError(f"unexpected answer from the API: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _field(body: Any, name: str) -> Any:
|
|
130
|
+
if not isinstance(body, dict) or body.get(name) is None:
|
|
131
|
+
raise DataFuelError(f"unexpected answer from the API: no {name!r} field")
|
|
132
|
+
return body[name]
|
|
133
|
+
|
|
134
|
+
def _str_field(self, body: Any, name: str) -> str:
|
|
135
|
+
return str(self._field(body, name))
|
|
136
|
+
|
|
137
|
+
def _int_field(self, body: Any, name: str) -> int:
|
|
138
|
+
value = self._field(body, name)
|
|
139
|
+
try:
|
|
140
|
+
return int(value)
|
|
141
|
+
except (TypeError, ValueError) as exc:
|
|
142
|
+
raise DataFuelError(
|
|
143
|
+
f"unexpected answer from the API: {name!r} is not a number"
|
|
144
|
+
) from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _retry_after(response: httpx.Response) -> float:
|
|
148
|
+
try:
|
|
149
|
+
return float(response.headers.get("Retry-After", 0))
|
|
150
|
+
except ValueError:
|
|
151
|
+
return 0.0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
#: The API rejects a longer key with 400; catching it here saves the round trip.
|
|
155
|
+
MAX_IDEMPOTENCY_KEY = 255
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _key(explicit: str | None) -> str:
|
|
159
|
+
if explicit and len(explicit) > MAX_IDEMPOTENCY_KEY:
|
|
160
|
+
raise ValueError(f"idempotency_key is longer than {MAX_IDEMPOTENCY_KEY} characters")
|
|
161
|
+
return explicit or core.new_idempotency_key()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class DataFuel(_Base):
|
|
165
|
+
"""Client for the DataFuel scraping API.
|
|
166
|
+
|
|
167
|
+
>>> df = DataFuel(os.environ["DATAFUEL_API_KEY"])
|
|
168
|
+
>>> df.markdown("https://example.com")
|
|
169
|
+
|
|
170
|
+
Pick the call by the shape of the work: one URL is :meth:`scrape`, a site's
|
|
171
|
+
URL list is :meth:`map`, many pages from a start URL is :meth:`crawl`, a
|
|
172
|
+
list of known URLs is :meth:`run_job`, a question for an AI engine is
|
|
173
|
+
:meth:`ask`, a Google search is :meth:`search`.
|
|
174
|
+
|
|
175
|
+
Every write carries an ``Idempotency-Key``, generated per request, so a
|
|
176
|
+
retry attaches to the task already running instead of charging twice.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
api_key: str | None = None,
|
|
182
|
+
*,
|
|
183
|
+
base_url: str = core.DEFAULT_BASE_URL,
|
|
184
|
+
timeout: float | None = core.DEFAULT_TIMEOUT,
|
|
185
|
+
max_retries: int = 2,
|
|
186
|
+
poll_interval: float = 2.0,
|
|
187
|
+
user_agent: str | None = None,
|
|
188
|
+
http_client: httpx.Client | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
super().__init__(
|
|
191
|
+
api_key,
|
|
192
|
+
base_url=base_url,
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
max_retries=max_retries,
|
|
195
|
+
poll_interval=poll_interval,
|
|
196
|
+
user_agent=user_agent,
|
|
197
|
+
)
|
|
198
|
+
# Redirects stay off: httpx does not strip a custom header on a
|
|
199
|
+
# cross-host redirect, so X-API-Key would follow the target.
|
|
200
|
+
self._http = http_client or httpx.Client(follow_redirects=False)
|
|
201
|
+
self._owns_http = http_client is None
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> DataFuel:
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *exc: Any) -> None:
|
|
207
|
+
self.close()
|
|
208
|
+
|
|
209
|
+
def close(self) -> None:
|
|
210
|
+
if self._owns_http:
|
|
211
|
+
self._http.close()
|
|
212
|
+
|
|
213
|
+
# --- transport -----------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def _send(self, request: core.Request, timeout: float | None = None) -> Any:
|
|
216
|
+
args = self._request_args(request, timeout)
|
|
217
|
+
for attempt in range(self.max_retries + 1):
|
|
218
|
+
try:
|
|
219
|
+
return self._parse(self._http.request(**args), request.degraded_ok)
|
|
220
|
+
except httpx.HTTPError as exc:
|
|
221
|
+
error: Exception = TransportError(str(exc))
|
|
222
|
+
except APIError as exc:
|
|
223
|
+
error = exc
|
|
224
|
+
last = attempt >= self.max_retries
|
|
225
|
+
if last or not request.retryable or not core.should_retry(error):
|
|
226
|
+
raise error
|
|
227
|
+
time.sleep(core.backoff(attempt, getattr(error, "retry_after", 0.0)))
|
|
228
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
229
|
+
|
|
230
|
+
def _run_task(self, request: core.Request, timeout: float | None) -> Result:
|
|
231
|
+
deadline = time.monotonic() + self._budget(timeout)
|
|
232
|
+
attempt = 0
|
|
233
|
+
while True:
|
|
234
|
+
body = self._send(request, timeout)
|
|
235
|
+
if not core.still_processing(body):
|
|
236
|
+
break
|
|
237
|
+
delay = core.poll_delay(attempt)
|
|
238
|
+
if time.monotonic() + delay >= deadline:
|
|
239
|
+
raise WaitTimeout(
|
|
240
|
+
"the task is still processing; re-send under the same "
|
|
241
|
+
"idempotency key to pick it up",
|
|
242
|
+
id=request.idempotency_key,
|
|
243
|
+
)
|
|
244
|
+
time.sleep(delay)
|
|
245
|
+
attempt += 1
|
|
246
|
+
result = self._model(Result, body)
|
|
247
|
+
result.raise_for_status()
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
# --- pages ---------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def scrape(
|
|
253
|
+
self,
|
|
254
|
+
url: str,
|
|
255
|
+
*,
|
|
256
|
+
proxy: Proxy | None = None,
|
|
257
|
+
idempotency_key: str | None = None,
|
|
258
|
+
timeout: float | None = None,
|
|
259
|
+
**options: Any,
|
|
260
|
+
) -> Result:
|
|
261
|
+
"""Fetch one URL and block until the result is ready.
|
|
262
|
+
|
|
263
|
+
Raises :class:`~datafuel.Blocked` when the target refused the request
|
|
264
|
+
and :class:`~datafuel.TaskFailed` otherwise; both carry the ``result``.
|
|
265
|
+
|
|
266
|
+
Options:
|
|
267
|
+
format: ``html`` (API default), ``markdown``, ``json``, ``png``, ``jpeg``;
|
|
268
|
+
see :class:`~datafuel.Format` for the rest.
|
|
269
|
+
js_rendering: render in a real browser. Slower, 5x credits on a
|
|
270
|
+
Basic proxy. Leave it off unless the page comes back empty.
|
|
271
|
+
wait_for / wait_for_timeout_ms: CSS selector to wait for.
|
|
272
|
+
js_instructions: browser actions run after load, a list of single-action
|
|
273
|
+
dicts run in order, e.g. ``[{"click": "#more"}, {"wait_ms": 1000}]``;
|
|
274
|
+
an action may repeat. A dict keyed by action is still accepted, but its
|
|
275
|
+
order is not guaranteed. :meth:`js_instructions` lists the actions.
|
|
276
|
+
Needs ``js_rendering``.
|
|
277
|
+
block_resource: resource type the browser must not load, e.g. ``Image``,
|
|
278
|
+
or a list of them.
|
|
279
|
+
main_content_only / include_images: markdown only.
|
|
280
|
+
``include_images=False`` drops images and saves tokens.
|
|
281
|
+
extract: ``{"price": ".price", "links": "a @href"}`` — name to CSS
|
|
282
|
+
selector, ``" @attr"`` reads an attribute. Returns JSON.
|
|
283
|
+
extract_regex: the same with RE2 patterns on the raw HTML.
|
|
284
|
+
template: built-in extractors, comma separated: images, links,
|
|
285
|
+
headings, phone_numbers, emails, meta_tags, tables, schema_org, all.
|
|
286
|
+
method / body / content_type / headers / header_order / cookies:
|
|
287
|
+
the shape of the HTTP request sent to the target.
|
|
288
|
+
user_agent / user_agent_type: chrome, firefox, safari, edge.
|
|
289
|
+
ai: an :class:`~datafuel.AI` to post-process the page with an LLM.
|
|
290
|
+
:meth:`ai_providers` lists the providers and models.
|
|
291
|
+
"""
|
|
292
|
+
request = core.build_scrape(
|
|
293
|
+
url, proxy=proxy, options=options, idempotency_key=_key(idempotency_key)
|
|
294
|
+
)
|
|
295
|
+
return self._run_task(request, timeout)
|
|
296
|
+
|
|
297
|
+
def markdown(self, url: str, **kwargs: Any) -> str:
|
|
298
|
+
"""The one-liner: the page as LLM-ready markdown, images dropped."""
|
|
299
|
+
return self.scrape(url, format="markdown", include_images=False, **kwargs).text
|
|
300
|
+
|
|
301
|
+
def get_task(self, task_id: str, *, timeout: float | None = None) -> Result:
|
|
302
|
+
"""Return a task by id, e.g. one created by a job or a crawl.
|
|
303
|
+
|
|
304
|
+
A task that is still running comes back with ``pending`` true.
|
|
305
|
+
"""
|
|
306
|
+
body = self._send(core.Request("GET", f"/task/{core.path_segment(task_id)}"), timeout)
|
|
307
|
+
if core.still_processing(body):
|
|
308
|
+
return Result(id=task_id, status=Status.PROCESSING)
|
|
309
|
+
result = self._model(Result, body)
|
|
310
|
+
result.raise_for_status()
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
def wait_task(self, task_id: str, *, timeout: float | None = None) -> Result:
|
|
314
|
+
"""Poll a task until it is done. ``timeout`` None waits indefinitely.
|
|
315
|
+
|
|
316
|
+
Raises :class:`~datafuel.TaskFailed` or :class:`~datafuel.Blocked`
|
|
317
|
+
when the task failed.
|
|
318
|
+
"""
|
|
319
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
320
|
+
while True:
|
|
321
|
+
result = self.get_task(task_id)
|
|
322
|
+
if not result.pending:
|
|
323
|
+
return result
|
|
324
|
+
if deadline is not None and time.monotonic() + self.poll_interval >= deadline:
|
|
325
|
+
raise WaitTimeout(f"task {task_id} is still running", id=task_id)
|
|
326
|
+
time.sleep(self.poll_interval)
|
|
327
|
+
|
|
328
|
+
def ask(
|
|
329
|
+
self,
|
|
330
|
+
prompt: str,
|
|
331
|
+
*,
|
|
332
|
+
engine: str,
|
|
333
|
+
idempotency_key: str | None = None,
|
|
334
|
+
timeout: float | None = None,
|
|
335
|
+
**options: Any,
|
|
336
|
+
) -> Result:
|
|
337
|
+
"""Send a prompt to an AI engine and return its answer.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
engine: openai, gemini, google_ai_mode, perplexity, copilot or duck_ai.
|
|
341
|
+
``duck_ai`` answers with ``{text, citations}`` in ``Result.data``.
|
|
342
|
+
model: duck_ai only: gpt-6-luna (default), gpt-5.4-mini,
|
|
343
|
+
claude-haiku-4-5, mistral-small-2603, tinfoil/gpt-oss-120b or
|
|
344
|
+
tinfoil/gemma4-31b. Any other engine or model is a 400.
|
|
345
|
+
websearch: let the engine search the web. On duck_ai it only affects
|
|
346
|
+
the tinfoil/* models; other duck_ai models ignore it.
|
|
347
|
+
follow_up: a second prompt sent after the first answer. Only openai
|
|
348
|
+
and perplexity support it; other engines fail the task, refunded.
|
|
349
|
+
country: proxy country for the engine session.
|
|
350
|
+
location: where the engine should answer for; defaults to the exit country.
|
|
351
|
+
format: result format.
|
|
352
|
+
"""
|
|
353
|
+
request = core.build_ask(
|
|
354
|
+
prompt, options={"engine": engine, **options}, idempotency_key=_key(idempotency_key)
|
|
355
|
+
)
|
|
356
|
+
return self._run_task(request, timeout)
|
|
357
|
+
|
|
358
|
+
def search(
|
|
359
|
+
self,
|
|
360
|
+
query: str,
|
|
361
|
+
*,
|
|
362
|
+
idempotency_key: str | None = None,
|
|
363
|
+
timeout: float | None = None,
|
|
364
|
+
**options: Any,
|
|
365
|
+
) -> Result:
|
|
366
|
+
"""Run a Google search and return the results page.
|
|
367
|
+
|
|
368
|
+
10 credits per result page, whatever the proxy; pages Google did not
|
|
369
|
+
serve are refunded. ``Result.data`` holds the structured results with
|
|
370
|
+
the default ``json`` format, and its ``page`` is the number of pages
|
|
371
|
+
returned. With ``ai_overview=True`` it carries ``ai_overview``:
|
|
372
|
+
``{text, products: [{title, price, source, rating, reviews, link}],
|
|
373
|
+
references: [{title, source, snippet, url, redirect}]}``.
|
|
374
|
+
|
|
375
|
+
Options:
|
|
376
|
+
country / language: Google's ``gl`` / ``hl``, e.g. ``us`` / ``en``.
|
|
377
|
+
page: number of result pages, 1 to 10 (more is capped at 10). They
|
|
378
|
+
come back merged into one ``organic`` list with running
|
|
379
|
+
positions; the other sections come from page 1.
|
|
380
|
+
google_domain: e.g. ``google.co.uk``. Default ``google.com``.
|
|
381
|
+
proxy_country: accepted but not used yet. Searches leave through
|
|
382
|
+
DataFuel's own pool; target a market with ``country`` and ``language``.
|
|
383
|
+
location / uule / lat + lon (+ radius): search origin, one of them.
|
|
384
|
+
cr, lr, tbs, safe (``active`` / ``off``), nfpr, filter, uds,
|
|
385
|
+
kgmid, si, ludocid, lsig, ibp: Google's own parameters.
|
|
386
|
+
device: ``desktop`` (default) or ``mobile``. Mobile results have
|
|
387
|
+
no ``total_results``.
|
|
388
|
+
ai_overview: also return Google's AI overview. Default ``False``.
|
|
389
|
+
format: ``json`` (default), ``markdown`` or ``html``.
|
|
390
|
+
"""
|
|
391
|
+
request = core.build_search(query, options=options, idempotency_key=_key(idempotency_key))
|
|
392
|
+
return self._run_task(request, timeout)
|
|
393
|
+
|
|
394
|
+
def map(
|
|
395
|
+
self,
|
|
396
|
+
url: str,
|
|
397
|
+
*,
|
|
398
|
+
proxy: Proxy | None = None,
|
|
399
|
+
idempotency_key: str | None = None,
|
|
400
|
+
timeout: float | None = None,
|
|
401
|
+
**options: Any,
|
|
402
|
+
) -> SiteMap:
|
|
403
|
+
"""List the URLs of a site without scraping them.
|
|
404
|
+
|
|
405
|
+
One credit per call on a Basic proxy, however many links come back.
|
|
406
|
+
Use it before a crawl to see how big a section is.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
search: keep only links whose URL or title contains this.
|
|
410
|
+
sitemap: map only this sitemap, from a previous ``SiteMap.sitemaps``.
|
|
411
|
+
limit: default 5000, capped at 10000.
|
|
412
|
+
include_subdomains / ignore_sitemap / sitemap_only: discovery switches.
|
|
413
|
+
"""
|
|
414
|
+
request = core.build_map(url, proxy=proxy, idempotency_key=_key(idempotency_key), **options)
|
|
415
|
+
task = self._run_task(request, timeout)
|
|
416
|
+
site = self._model(SiteMap, task.require_data())
|
|
417
|
+
site.task = task
|
|
418
|
+
return site
|
|
419
|
+
|
|
420
|
+
# --- crawl ---------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
def start_crawl(
|
|
423
|
+
self,
|
|
424
|
+
url: str,
|
|
425
|
+
*,
|
|
426
|
+
proxy: Proxy | None = None,
|
|
427
|
+
idempotency_key: str | None = None,
|
|
428
|
+
timeout: float | None = None,
|
|
429
|
+
**options: Any,
|
|
430
|
+
) -> str:
|
|
431
|
+
"""Queue a crawl and return its id immediately.
|
|
432
|
+
|
|
433
|
+
Follows links from the start URL and scrapes every page. Pages are
|
|
434
|
+
charged like single scrapes when they are queued; failed and blocked
|
|
435
|
+
pages are refunded.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
max_pages: default 100, max 10000. The hard budget of the crawl.
|
|
439
|
+
max_depth: default 3, max 10. The start URL is depth 0.
|
|
440
|
+
include_paths / exclude_paths: RE2 on path?query; exclude wins.
|
|
441
|
+
include_subdomains / allow_backward_links: widen the default, which
|
|
442
|
+
follows only same-host URLs under the start URL's path.
|
|
443
|
+
concurrency: pages in flight, default 5.
|
|
444
|
+
**options: the page options of :meth:`scrape`, minus ``ai``.
|
|
445
|
+
"""
|
|
446
|
+
crawl_options = {k: options.pop(k) for k in list(options) if k in _CRAWL_KEYS}
|
|
447
|
+
request = core.build_crawl(
|
|
448
|
+
url,
|
|
449
|
+
proxy=proxy,
|
|
450
|
+
options=options,
|
|
451
|
+
idempotency_key=_key(idempotency_key),
|
|
452
|
+
**crawl_options,
|
|
453
|
+
)
|
|
454
|
+
body = self._send(request, timeout)
|
|
455
|
+
return self._str_field(body, "job_id")
|
|
456
|
+
|
|
457
|
+
def get_crawl(self, crawl_id: str, *, timeout: float | None = None) -> CrawlStatus:
|
|
458
|
+
"""Return the progress of a crawl."""
|
|
459
|
+
body = self._send(core.Request("GET", f"/crawl/{core.path_segment(crawl_id)}"), timeout)
|
|
460
|
+
return self._model(CrawlStatus, body)
|
|
461
|
+
|
|
462
|
+
def crawl_results(
|
|
463
|
+
self,
|
|
464
|
+
crawl_id: str,
|
|
465
|
+
*,
|
|
466
|
+
cursor: str | None = None,
|
|
467
|
+
limit: int | None = None,
|
|
468
|
+
timeout: float | None = None,
|
|
469
|
+
) -> CrawlResultsPage:
|
|
470
|
+
"""One page of results, in discovery order. ``limit`` 0 uses the API default."""
|
|
471
|
+
body = self._send(core.crawl_results_request(crawl_id, cursor, limit), timeout)
|
|
472
|
+
return self._model(CrawlResultsPage, body)
|
|
473
|
+
|
|
474
|
+
def crawl_pages(self, crawl_id: str, *, limit: int | None = None) -> Iterator[CrawlPage]:
|
|
475
|
+
"""Iterate over every page of a crawl, fetching result pages as needed.
|
|
476
|
+
|
|
477
|
+
Readable while the crawl runs; unfinished pages report ``pending``.
|
|
478
|
+
"""
|
|
479
|
+
cursor: str | None = None
|
|
480
|
+
seen: set[str] = set()
|
|
481
|
+
while True:
|
|
482
|
+
batch = self.crawl_results(crawl_id, cursor=cursor, limit=limit)
|
|
483
|
+
yield from batch.pages
|
|
484
|
+
cursor = batch.next_cursor
|
|
485
|
+
if not cursor:
|
|
486
|
+
return
|
|
487
|
+
if cursor in seen:
|
|
488
|
+
raise DataFuelError("the API repeated a crawl cursor; stopping to avoid a loop")
|
|
489
|
+
seen.add(cursor)
|
|
490
|
+
|
|
491
|
+
def wait_crawl(self, crawl_id: str, *, timeout: float | None = None) -> CrawlStatus:
|
|
492
|
+
"""Poll until the crawl is done. ``timeout`` None waits indefinitely."""
|
|
493
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
494
|
+
while True:
|
|
495
|
+
status = self.get_crawl(crawl_id)
|
|
496
|
+
if status.done:
|
|
497
|
+
return status
|
|
498
|
+
if deadline is not None and time.monotonic() + self.poll_interval >= deadline:
|
|
499
|
+
raise WaitTimeout(f"crawl {crawl_id} is still running", id=crawl_id, status=status)
|
|
500
|
+
time.sleep(self.poll_interval)
|
|
501
|
+
|
|
502
|
+
def cancel_crawl(self, crawl_id: str, *, timeout: float | None = None) -> CancelResult:
|
|
503
|
+
"""Stop a crawl. Pages not yet started are refunded.
|
|
504
|
+
|
|
505
|
+
Raises :class:`~datafuel.JobNotCancellable` when it already finished.
|
|
506
|
+
"""
|
|
507
|
+
body = self._send(core.cancel_request("crawl", crawl_id), timeout)
|
|
508
|
+
return self._model(CancelResult, body)
|
|
509
|
+
|
|
510
|
+
def crawl(self, url: str, *, timeout: float | None = None, **kwargs: Any) -> CrawlResult:
|
|
511
|
+
"""Start a crawl, wait for it, and return every page.
|
|
512
|
+
|
|
513
|
+
Check ``stop_reason``: ``insufficient_credits`` means it ended early.
|
|
514
|
+
For large crawls prefer :meth:`start_crawl` + :meth:`wait_crawl` +
|
|
515
|
+
:meth:`crawl_pages`, which stream instead of holding every page in
|
|
516
|
+
memory. On :class:`~datafuel.WaitTimeout` the exception carries the id:
|
|
517
|
+
the crawl keeps running and billing, so pick it up again.
|
|
518
|
+
"""
|
|
519
|
+
crawl_id = self.start_crawl(url, **kwargs)
|
|
520
|
+
status = self.wait_crawl(crawl_id, timeout=timeout)
|
|
521
|
+
return CrawlResult(id=crawl_id, status=status, pages=list(self.crawl_pages(crawl_id)))
|
|
522
|
+
|
|
523
|
+
# --- jobs ----------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def create_job(
|
|
526
|
+
self,
|
|
527
|
+
urls: list[str],
|
|
528
|
+
*,
|
|
529
|
+
proxy: Proxy | None = None,
|
|
530
|
+
sequential: bool = False,
|
|
531
|
+
idempotency_key: str | None = None,
|
|
532
|
+
timeout: float | None = None,
|
|
533
|
+
**options: Any,
|
|
534
|
+
) -> str:
|
|
535
|
+
"""Queue a batch of known URLs and return the job id.
|
|
536
|
+
|
|
537
|
+
Cheaper and more predictable than a crawl when you already have the
|
|
538
|
+
URLs. ``sequential`` runs them one after the other; the default runs
|
|
539
|
+
them concurrently, bounded by the account's concurrency limit.
|
|
540
|
+
"""
|
|
541
|
+
request = core.build_job(
|
|
542
|
+
urls,
|
|
543
|
+
proxy=proxy,
|
|
544
|
+
sequential=sequential,
|
|
545
|
+
options=options,
|
|
546
|
+
idempotency_key=_key(idempotency_key),
|
|
547
|
+
)
|
|
548
|
+
body = self._send(request, timeout)
|
|
549
|
+
return self._str_field(body, "id")
|
|
550
|
+
|
|
551
|
+
def create_ask_job(
|
|
552
|
+
self,
|
|
553
|
+
prompts: list[str],
|
|
554
|
+
*,
|
|
555
|
+
engine: str,
|
|
556
|
+
sequential: bool = False,
|
|
557
|
+
idempotency_key: str | None = None,
|
|
558
|
+
timeout: float | None = None,
|
|
559
|
+
**options: Any,
|
|
560
|
+
) -> str:
|
|
561
|
+
"""Queue a batch of prompts for an AI engine and return the job id."""
|
|
562
|
+
request = core.build_ask_job(
|
|
563
|
+
prompts,
|
|
564
|
+
sequential=sequential,
|
|
565
|
+
options={"engine": engine, **options},
|
|
566
|
+
idempotency_key=_key(idempotency_key),
|
|
567
|
+
)
|
|
568
|
+
body = self._send(request, timeout)
|
|
569
|
+
return self._str_field(body, "id")
|
|
570
|
+
|
|
571
|
+
def create_search_job(
|
|
572
|
+
self,
|
|
573
|
+
queries: list[str],
|
|
574
|
+
*,
|
|
575
|
+
sequential: bool = False,
|
|
576
|
+
idempotency_key: str | None = None,
|
|
577
|
+
timeout: float | None = None,
|
|
578
|
+
**options: Any,
|
|
579
|
+
) -> str:
|
|
580
|
+
"""Queue a batch of Google searches and return the job id.
|
|
581
|
+
|
|
582
|
+
Takes the options of :meth:`search`. Needs at least two queries.
|
|
583
|
+
"""
|
|
584
|
+
request = core.build_search_job(
|
|
585
|
+
queries,
|
|
586
|
+
sequential=sequential,
|
|
587
|
+
options=options,
|
|
588
|
+
idempotency_key=_key(idempotency_key),
|
|
589
|
+
)
|
|
590
|
+
body = self._send(request, timeout)
|
|
591
|
+
return self._str_field(body, "id")
|
|
592
|
+
|
|
593
|
+
def get_job(self, job_id: str, *, timeout: float | None = None) -> JobStatus:
|
|
594
|
+
"""Return the progress of a job."""
|
|
595
|
+
body = self._send(core.Request("GET", f"/job/{core.path_segment(job_id)}"), timeout)
|
|
596
|
+
return self._model(JobStatus, body)
|
|
597
|
+
|
|
598
|
+
def job_results(self, job_id: str, *, timeout: float | None = None) -> JobResults:
|
|
599
|
+
"""Return the per-task results of a job.
|
|
600
|
+
|
|
601
|
+
A job completes even when some of its tasks failed: check ``task.ok``
|
|
602
|
+
or call ``task.raise_for_status()`` per task.
|
|
603
|
+
"""
|
|
604
|
+
body = self._send(core.Request("GET", f"/job/{core.path_segment(job_id)}/results"), timeout)
|
|
605
|
+
results = self._model(JobResults, body)
|
|
606
|
+
results.id = job_id
|
|
607
|
+
return results
|
|
608
|
+
|
|
609
|
+
def wait_job(self, job_id: str, *, timeout: float | None = None) -> JobStatus:
|
|
610
|
+
"""Poll until the job is done. ``timeout`` None waits indefinitely."""
|
|
611
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
612
|
+
while True:
|
|
613
|
+
status = self.get_job(job_id)
|
|
614
|
+
if status.done:
|
|
615
|
+
return status
|
|
616
|
+
if deadline is not None and time.monotonic() + self.poll_interval >= deadline:
|
|
617
|
+
raise WaitTimeout(f"job {job_id} is still running", id=job_id, status=status)
|
|
618
|
+
time.sleep(self.poll_interval)
|
|
619
|
+
|
|
620
|
+
def run_job(
|
|
621
|
+
self, urls: list[str], *, timeout: float | None = None, **kwargs: Any
|
|
622
|
+
) -> JobResults:
|
|
623
|
+
"""Create a job, wait for it, and return its results."""
|
|
624
|
+
job_id = self.create_job(urls, **kwargs)
|
|
625
|
+
self.wait_job(job_id, timeout=timeout)
|
|
626
|
+
return self.job_results(job_id)
|
|
627
|
+
|
|
628
|
+
def run_ask_job(
|
|
629
|
+
self, prompts: list[str], *, engine: str, timeout: float | None = None, **kwargs: Any
|
|
630
|
+
) -> JobResults:
|
|
631
|
+
"""Create a prompt job, wait for it, and return its results."""
|
|
632
|
+
job_id = self.create_ask_job(prompts, engine=engine, **kwargs)
|
|
633
|
+
self.wait_job(job_id, timeout=timeout)
|
|
634
|
+
return self.job_results(job_id)
|
|
635
|
+
|
|
636
|
+
def run_search_job(
|
|
637
|
+
self, queries: list[str], *, timeout: float | None = None, **kwargs: Any
|
|
638
|
+
) -> JobResults:
|
|
639
|
+
"""Create a search job, wait for it, and return its results."""
|
|
640
|
+
job_id = self.create_search_job(queries, **kwargs)
|
|
641
|
+
self.wait_job(job_id, timeout=timeout)
|
|
642
|
+
return self.job_results(job_id)
|
|
643
|
+
|
|
644
|
+
def cancel_job(self, job_id: str, *, timeout: float | None = None) -> CancelResult:
|
|
645
|
+
"""Stop a job. Tasks not yet started are refunded.
|
|
646
|
+
|
|
647
|
+
Raises :class:`~datafuel.JobNotCancellable` when it already finished.
|
|
648
|
+
"""
|
|
649
|
+
body = self._send(core.cancel_request("job", job_id), timeout)
|
|
650
|
+
return self._model(CancelResult, body)
|
|
651
|
+
|
|
652
|
+
def list_jobs(
|
|
653
|
+
self,
|
|
654
|
+
*,
|
|
655
|
+
status: Status | str | None = None,
|
|
656
|
+
type: str | None = None,
|
|
657
|
+
start_date: date | str | None = None,
|
|
658
|
+
end_date: date | str | None = None,
|
|
659
|
+
limit: int | None = None,
|
|
660
|
+
cursor: str | None = None,
|
|
661
|
+
timeout: float | None = None,
|
|
662
|
+
) -> JobsPage:
|
|
663
|
+
"""One page of your jobs and crawls, newest first.
|
|
664
|
+
|
|
665
|
+
Dates are UTC days, ``end_date`` inclusive. ``limit`` defaults to 50
|
|
666
|
+
(max 200). Pass ``next_cursor`` back as ``cursor`` until it is None.
|
|
667
|
+
"""
|
|
668
|
+
request = core.list_request(
|
|
669
|
+
"/job",
|
|
670
|
+
status=status,
|
|
671
|
+
type=type,
|
|
672
|
+
start_date=start_date,
|
|
673
|
+
end_date=end_date,
|
|
674
|
+
limit=limit,
|
|
675
|
+
cursor=cursor,
|
|
676
|
+
)
|
|
677
|
+
return self._model(JobsPage, self._send(request, timeout))
|
|
678
|
+
|
|
679
|
+
def list_tasks(
|
|
680
|
+
self,
|
|
681
|
+
*,
|
|
682
|
+
status: Status | str | None = None,
|
|
683
|
+
type: str | None = None,
|
|
684
|
+
job_id: str | None = None,
|
|
685
|
+
start_date: date | str | None = None,
|
|
686
|
+
end_date: date | str | None = None,
|
|
687
|
+
limit: int | None = None,
|
|
688
|
+
cursor: str | None = None,
|
|
689
|
+
timeout: float | None = None,
|
|
690
|
+
) -> TasksPage:
|
|
691
|
+
"""One page of your tasks, newest first, including those of jobs and crawls.
|
|
692
|
+
|
|
693
|
+
Items carry no result: fetch it with :meth:`get_task`. Filters and
|
|
694
|
+
paging as in :meth:`list_jobs`, plus ``job_id``.
|
|
695
|
+
"""
|
|
696
|
+
request = core.list_request(
|
|
697
|
+
"/task",
|
|
698
|
+
status=status,
|
|
699
|
+
type=type,
|
|
700
|
+
job_id=job_id,
|
|
701
|
+
start_date=start_date,
|
|
702
|
+
end_date=end_date,
|
|
703
|
+
limit=limit,
|
|
704
|
+
cursor=cursor,
|
|
705
|
+
)
|
|
706
|
+
return self._model(TasksPage, self._send(request, timeout))
|
|
707
|
+
|
|
708
|
+
# --- account -------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
def capabilities(self, *, timeout: float | None = None) -> Capabilities:
|
|
711
|
+
"""Which task types and LLM engines are switched on right now."""
|
|
712
|
+
body = self._send(core.Request("GET", "/config/capabilities"), timeout)
|
|
713
|
+
return self._model(Capabilities, body)
|
|
714
|
+
|
|
715
|
+
def balance(self, *, timeout: float | None = None) -> int:
|
|
716
|
+
"""Remaining credits: plan and pay-as-you-go together."""
|
|
717
|
+
body = self._send(core.Request("GET", "/users/@me/balance"), timeout)
|
|
718
|
+
return self._int_field(body, "balance")
|
|
719
|
+
|
|
720
|
+
def balance_split(self, *, timeout: float | None = None) -> BalanceSplit:
|
|
721
|
+
"""Remaining credits by pool: plan credits (spent first) and pay-as-you-go credits."""
|
|
722
|
+
body = self._send(core.Request("GET", "/users/@me/balance"), timeout)
|
|
723
|
+
return self._model(BalanceSplit, body)
|
|
724
|
+
|
|
725
|
+
def transactions(
|
|
726
|
+
self,
|
|
727
|
+
*,
|
|
728
|
+
operation: str | None = None,
|
|
729
|
+
start_date: date | str | None = None,
|
|
730
|
+
end_date: date | str | None = None,
|
|
731
|
+
page: int | None = None,
|
|
732
|
+
limit: int | None = None,
|
|
733
|
+
timeout: float | None = None,
|
|
734
|
+
) -> TransactionsPage:
|
|
735
|
+
"""Credit movements, newest first: credit pack purchases, usage, refunds, expiry.
|
|
736
|
+
|
|
737
|
+
``page`` starts at 1, ``limit`` defaults to 10 (max 200). ``sums``
|
|
738
|
+
totals each operation over the whole range, not just this page.
|
|
739
|
+
"""
|
|
740
|
+
request = core.transactions_request(operation, start_date, end_date, page, limit)
|
|
741
|
+
return self._model(TransactionsPage, self._send(request, timeout))
|
|
742
|
+
|
|
743
|
+
def me(self, *, timeout: float | None = None) -> Profile:
|
|
744
|
+
"""The account behind the API key."""
|
|
745
|
+
body = self._send(core.Request("GET", "/users/@me"), timeout)
|
|
746
|
+
return self._model(Profile, body)
|
|
747
|
+
|
|
748
|
+
def reset_api_key(self, *, timeout: float | None = None) -> str:
|
|
749
|
+
"""Revoke the current key and return the new one.
|
|
750
|
+
|
|
751
|
+
This is the only time the new key is shown. The client keeps using the
|
|
752
|
+
revoked one: store the new key and build a new client with it.
|
|
753
|
+
"""
|
|
754
|
+
body = self._send(core.Request("POST", "/users/@me/api-key/reset"), timeout)
|
|
755
|
+
return self._str_field(body, "api_key")
|
|
756
|
+
|
|
757
|
+
def analytics(
|
|
758
|
+
self,
|
|
759
|
+
*,
|
|
760
|
+
start_date: date | str | None = None,
|
|
761
|
+
end_date: date | str | None = None,
|
|
762
|
+
interval: str | None = None,
|
|
763
|
+
module: str | None = None,
|
|
764
|
+
timeout: float | None = None,
|
|
765
|
+
) -> Analytics:
|
|
766
|
+
"""Task counts and credits over a date range, both ends inclusive.
|
|
767
|
+
|
|
768
|
+
Defaults to the last 30 days, ``daily``. ``interval``: hourly, daily,
|
|
769
|
+
weekly, monthly. ``module`` narrows it to one task type.
|
|
770
|
+
"""
|
|
771
|
+
request = core.analytics_request(start_date, end_date, interval, module)
|
|
772
|
+
return self._model(Analytics, self._send(request, timeout))
|
|
773
|
+
|
|
774
|
+
def check_protection(
|
|
775
|
+
self, urls: list[str], *, timeout: float | None = None
|
|
776
|
+
) -> list[ProtectionCheck]:
|
|
777
|
+
"""Which anti-bot vendor sits in front of each URL. Nothing is scraped."""
|
|
778
|
+
body = self._send(core.Request("POST", "/filter/check", body=list(urls)), timeout)
|
|
779
|
+
return self._models(ProtectionCheck, body)
|
|
780
|
+
|
|
781
|
+
def proxy_locations(
|
|
782
|
+
self, proxy_type: str | None = None, *, timeout: float | None = None
|
|
783
|
+
) -> list[ProxyCountry]:
|
|
784
|
+
"""Countries, regions and cities a proxy plan can exit from."""
|
|
785
|
+
body = self._send(core.proxy_request("/config/proxy/locations", proxy_type), timeout)
|
|
786
|
+
return self._models(ProxyCountry, body)
|
|
787
|
+
|
|
788
|
+
def proxy_asns(
|
|
789
|
+
self, country: str, *, proxy_type: str | None = None, timeout: float | None = None
|
|
790
|
+
) -> list[ProxyLocation]:
|
|
791
|
+
"""ASNs a proxy plan can exit from in ``country`` (ISO 3166-1 alpha-2)."""
|
|
792
|
+
body = self._send(
|
|
793
|
+
core.proxy_request("/config/proxy/asn", proxy_type, country=country), timeout
|
|
794
|
+
)
|
|
795
|
+
return self._models(ProxyLocation, body)
|
|
796
|
+
|
|
797
|
+
def js_instructions(self, *, timeout: float | None = None) -> list[JSInstruction]:
|
|
798
|
+
"""The browser actions ``js_instructions`` accepts, with an example each."""
|
|
799
|
+
body = self._send(core.Request("GET", "/config/js-instructions"), timeout)
|
|
800
|
+
return self._models(JSInstruction, self._field(body, "instructions"))
|
|
801
|
+
|
|
802
|
+
def ai_providers(self, *, timeout: float | None = None) -> list[AIProvider]:
|
|
803
|
+
"""The LLM providers and models :class:`~datafuel.AI` accepts."""
|
|
804
|
+
body = self._send(core.Request("GET", "/config/ai-providers"), timeout)
|
|
805
|
+
return self._models(AIProvider, self._field(body, "providers"))
|
|
806
|
+
|
|
807
|
+
def health(self, *, deep: bool = False, timeout: float | None = None) -> Health:
|
|
808
|
+
"""Whether the API is up. ``deep`` also checks the dependencies it needs to serve scrapes.
|
|
809
|
+
|
|
810
|
+
A degraded report is returned, not raised: read ``Health.ok``.
|
|
811
|
+
"""
|
|
812
|
+
return self._model(Health, self._send(core.health_request(deep), timeout))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
_CRAWL_KEYS = frozenset(
|
|
816
|
+
{
|
|
817
|
+
"max_pages",
|
|
818
|
+
"max_depth",
|
|
819
|
+
"include_paths",
|
|
820
|
+
"exclude_paths",
|
|
821
|
+
"include_subdomains",
|
|
822
|
+
"allow_backward_links",
|
|
823
|
+
"concurrency",
|
|
824
|
+
}
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
class AsyncDataFuel(_Base):
|
|
829
|
+
"""The asyncio client. Same methods as :class:`DataFuel`, awaited."""
|
|
830
|
+
|
|
831
|
+
def __init__(
|
|
832
|
+
self,
|
|
833
|
+
api_key: str | None = None,
|
|
834
|
+
*,
|
|
835
|
+
base_url: str = core.DEFAULT_BASE_URL,
|
|
836
|
+
timeout: float | None = core.DEFAULT_TIMEOUT,
|
|
837
|
+
max_retries: int = 2,
|
|
838
|
+
poll_interval: float = 2.0,
|
|
839
|
+
user_agent: str | None = None,
|
|
840
|
+
http_client: httpx.AsyncClient | None = None,
|
|
841
|
+
) -> None:
|
|
842
|
+
super().__init__(
|
|
843
|
+
api_key,
|
|
844
|
+
base_url=base_url,
|
|
845
|
+
timeout=timeout,
|
|
846
|
+
max_retries=max_retries,
|
|
847
|
+
poll_interval=poll_interval,
|
|
848
|
+
user_agent=user_agent,
|
|
849
|
+
)
|
|
850
|
+
# Redirects stay off: httpx does not strip a custom header on a
|
|
851
|
+
# cross-host redirect, so X-API-Key would follow the target.
|
|
852
|
+
self._http = http_client or httpx.AsyncClient(follow_redirects=False)
|
|
853
|
+
self._owns_http = http_client is None
|
|
854
|
+
|
|
855
|
+
async def __aenter__(self) -> AsyncDataFuel:
|
|
856
|
+
return self
|
|
857
|
+
|
|
858
|
+
async def __aexit__(
|
|
859
|
+
self,
|
|
860
|
+
exc_type: type[BaseException] | None,
|
|
861
|
+
exc: BaseException | None,
|
|
862
|
+
tb: TracebackType | None,
|
|
863
|
+
) -> None:
|
|
864
|
+
await self.aclose()
|
|
865
|
+
|
|
866
|
+
async def aclose(self) -> None:
|
|
867
|
+
if self._owns_http:
|
|
868
|
+
await self._http.aclose()
|
|
869
|
+
|
|
870
|
+
# --- transport -----------------------------------------------------
|
|
871
|
+
|
|
872
|
+
async def _send(self, request: core.Request, timeout: float | None = None) -> Any:
|
|
873
|
+
args = self._request_args(request, timeout)
|
|
874
|
+
for attempt in range(self.max_retries + 1):
|
|
875
|
+
try:
|
|
876
|
+
return self._parse(await self._http.request(**args), request.degraded_ok)
|
|
877
|
+
except httpx.HTTPError as exc:
|
|
878
|
+
error: Exception = TransportError(str(exc))
|
|
879
|
+
except APIError as exc:
|
|
880
|
+
error = exc
|
|
881
|
+
last = attempt >= self.max_retries
|
|
882
|
+
if last or not request.retryable or not core.should_retry(error):
|
|
883
|
+
raise error
|
|
884
|
+
await asyncio.sleep(core.backoff(attempt, getattr(error, "retry_after", 0.0)))
|
|
885
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
886
|
+
|
|
887
|
+
async def _run_task(self, request: core.Request, timeout: float | None) -> Result:
|
|
888
|
+
deadline = time.monotonic() + self._budget(timeout)
|
|
889
|
+
attempt = 0
|
|
890
|
+
while True:
|
|
891
|
+
body = await self._send(request, timeout)
|
|
892
|
+
if not core.still_processing(body):
|
|
893
|
+
break
|
|
894
|
+
delay = core.poll_delay(attempt)
|
|
895
|
+
if time.monotonic() + delay >= deadline:
|
|
896
|
+
raise WaitTimeout(
|
|
897
|
+
"the task is still processing; re-send under the same "
|
|
898
|
+
"idempotency key to pick it up",
|
|
899
|
+
id=request.idempotency_key,
|
|
900
|
+
)
|
|
901
|
+
await asyncio.sleep(delay)
|
|
902
|
+
attempt += 1
|
|
903
|
+
result = self._model(Result, body)
|
|
904
|
+
result.raise_for_status()
|
|
905
|
+
return result
|
|
906
|
+
|
|
907
|
+
# --- pages ---------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
async def scrape(
|
|
910
|
+
self,
|
|
911
|
+
url: str,
|
|
912
|
+
*,
|
|
913
|
+
proxy: Proxy | None = None,
|
|
914
|
+
idempotency_key: str | None = None,
|
|
915
|
+
timeout: float | None = None,
|
|
916
|
+
**options: Any,
|
|
917
|
+
) -> Result:
|
|
918
|
+
"""Fetch one URL and wait for the result. See :meth:`DataFuel.scrape`."""
|
|
919
|
+
request = core.build_scrape(
|
|
920
|
+
url, proxy=proxy, options=options, idempotency_key=_key(idempotency_key)
|
|
921
|
+
)
|
|
922
|
+
return await self._run_task(request, timeout)
|
|
923
|
+
|
|
924
|
+
async def markdown(self, url: str, **kwargs: Any) -> str:
|
|
925
|
+
"""The page as LLM-ready markdown, images dropped."""
|
|
926
|
+
result = await self.scrape(url, format="markdown", include_images=False, **kwargs)
|
|
927
|
+
return result.text
|
|
928
|
+
|
|
929
|
+
async def get_task(self, task_id: str, *, timeout: float | None = None) -> Result:
|
|
930
|
+
"""Return a task by id. See :meth:`DataFuel.get_task`."""
|
|
931
|
+
body = await self._send(core.Request("GET", f"/task/{core.path_segment(task_id)}"), timeout)
|
|
932
|
+
if core.still_processing(body):
|
|
933
|
+
return Result(id=task_id, status=Status.PROCESSING)
|
|
934
|
+
result = self._model(Result, body)
|
|
935
|
+
result.raise_for_status()
|
|
936
|
+
return result
|
|
937
|
+
|
|
938
|
+
async def wait_task(self, task_id: str, *, timeout: float | None = None) -> Result:
|
|
939
|
+
"""Poll a task until it is done. See :meth:`DataFuel.wait_task`."""
|
|
940
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
941
|
+
while True:
|
|
942
|
+
result = await self.get_task(task_id)
|
|
943
|
+
if not result.pending:
|
|
944
|
+
return result
|
|
945
|
+
if deadline is not None and time.monotonic() + self.poll_interval >= deadline:
|
|
946
|
+
raise WaitTimeout(f"task {task_id} is still running", id=task_id)
|
|
947
|
+
await asyncio.sleep(self.poll_interval)
|
|
948
|
+
|
|
949
|
+
async def ask(
|
|
950
|
+
self,
|
|
951
|
+
prompt: str,
|
|
952
|
+
*,
|
|
953
|
+
engine: str,
|
|
954
|
+
idempotency_key: str | None = None,
|
|
955
|
+
timeout: float | None = None,
|
|
956
|
+
**options: Any,
|
|
957
|
+
) -> Result:
|
|
958
|
+
"""Send a prompt to an AI engine. See :meth:`DataFuel.ask`."""
|
|
959
|
+
request = core.build_ask(
|
|
960
|
+
prompt, options={"engine": engine, **options}, idempotency_key=_key(idempotency_key)
|
|
961
|
+
)
|
|
962
|
+
return await self._run_task(request, timeout)
|
|
963
|
+
|
|
964
|
+
async def search(
|
|
965
|
+
self,
|
|
966
|
+
query: str,
|
|
967
|
+
*,
|
|
968
|
+
idempotency_key: str | None = None,
|
|
969
|
+
timeout: float | None = None,
|
|
970
|
+
**options: Any,
|
|
971
|
+
) -> Result:
|
|
972
|
+
"""Run a Google search. See :meth:`DataFuel.search`."""
|
|
973
|
+
request = core.build_search(query, options=options, idempotency_key=_key(idempotency_key))
|
|
974
|
+
return await self._run_task(request, timeout)
|
|
975
|
+
|
|
976
|
+
async def map(
|
|
977
|
+
self,
|
|
978
|
+
url: str,
|
|
979
|
+
*,
|
|
980
|
+
proxy: Proxy | None = None,
|
|
981
|
+
idempotency_key: str | None = None,
|
|
982
|
+
timeout: float | None = None,
|
|
983
|
+
**options: Any,
|
|
984
|
+
) -> SiteMap:
|
|
985
|
+
"""List the URLs of a site. See :meth:`DataFuel.map`."""
|
|
986
|
+
request = core.build_map(url, proxy=proxy, idempotency_key=_key(idempotency_key), **options)
|
|
987
|
+
task = await self._run_task(request, timeout)
|
|
988
|
+
site = self._model(SiteMap, task.require_data())
|
|
989
|
+
site.task = task
|
|
990
|
+
return site
|
|
991
|
+
|
|
992
|
+
# --- crawl ---------------------------------------------------------
|
|
993
|
+
|
|
994
|
+
async def start_crawl(
|
|
995
|
+
self,
|
|
996
|
+
url: str,
|
|
997
|
+
*,
|
|
998
|
+
proxy: Proxy | None = None,
|
|
999
|
+
idempotency_key: str | None = None,
|
|
1000
|
+
timeout: float | None = None,
|
|
1001
|
+
**options: Any,
|
|
1002
|
+
) -> str:
|
|
1003
|
+
"""Queue a crawl and return its id. See :meth:`DataFuel.start_crawl`."""
|
|
1004
|
+
crawl_options = {k: options.pop(k) for k in list(options) if k in _CRAWL_KEYS}
|
|
1005
|
+
request = core.build_crawl(
|
|
1006
|
+
url,
|
|
1007
|
+
proxy=proxy,
|
|
1008
|
+
options=options,
|
|
1009
|
+
idempotency_key=_key(idempotency_key),
|
|
1010
|
+
**crawl_options,
|
|
1011
|
+
)
|
|
1012
|
+
body = await self._send(request, timeout)
|
|
1013
|
+
return self._str_field(body, "job_id")
|
|
1014
|
+
|
|
1015
|
+
async def get_crawl(self, crawl_id: str, *, timeout: float | None = None) -> CrawlStatus:
|
|
1016
|
+
"""Return the progress of a crawl."""
|
|
1017
|
+
body = await self._send(
|
|
1018
|
+
core.Request("GET", f"/crawl/{core.path_segment(crawl_id)}"), timeout
|
|
1019
|
+
)
|
|
1020
|
+
return self._model(CrawlStatus, body)
|
|
1021
|
+
|
|
1022
|
+
async def crawl_results(
|
|
1023
|
+
self,
|
|
1024
|
+
crawl_id: str,
|
|
1025
|
+
*,
|
|
1026
|
+
cursor: str | None = None,
|
|
1027
|
+
limit: int | None = None,
|
|
1028
|
+
timeout: float | None = None,
|
|
1029
|
+
) -> CrawlResultsPage:
|
|
1030
|
+
"""One page of crawl results."""
|
|
1031
|
+
body = await self._send(core.crawl_results_request(crawl_id, cursor, limit), timeout)
|
|
1032
|
+
return self._model(CrawlResultsPage, body)
|
|
1033
|
+
|
|
1034
|
+
async def crawl_pages(
|
|
1035
|
+
self, crawl_id: str, *, limit: int | None = None
|
|
1036
|
+
) -> AsyncIterator[CrawlPage]:
|
|
1037
|
+
"""Iterate over every page of a crawl, fetching result pages as needed."""
|
|
1038
|
+
cursor: str | None = None
|
|
1039
|
+
seen: set[str] = set()
|
|
1040
|
+
while True:
|
|
1041
|
+
batch = await self.crawl_results(crawl_id, cursor=cursor, limit=limit)
|
|
1042
|
+
for page in batch.pages:
|
|
1043
|
+
yield page
|
|
1044
|
+
cursor = batch.next_cursor
|
|
1045
|
+
if not cursor:
|
|
1046
|
+
return
|
|
1047
|
+
if cursor in seen:
|
|
1048
|
+
raise DataFuelError("the API repeated a crawl cursor; stopping to avoid a loop")
|
|
1049
|
+
seen.add(cursor)
|
|
1050
|
+
|
|
1051
|
+
async def wait_crawl(self, crawl_id: str, *, timeout: float | None = None) -> CrawlStatus:
|
|
1052
|
+
"""Poll until the crawl is done. ``timeout`` None waits indefinitely."""
|
|
1053
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
1054
|
+
while True:
|
|
1055
|
+
status = await self.get_crawl(crawl_id)
|
|
1056
|
+
if status.done:
|
|
1057
|
+
return status
|
|
1058
|
+
if deadline is not None and time.monotonic() + self.poll_interval >= deadline:
|
|
1059
|
+
raise WaitTimeout(f"crawl {crawl_id} is still running", id=crawl_id, status=status)
|
|
1060
|
+
await asyncio.sleep(self.poll_interval)
|
|
1061
|
+
|
|
1062
|
+
async def cancel_crawl(self, crawl_id: str, *, timeout: float | None = None) -> CancelResult:
|
|
1063
|
+
"""Stop a crawl. See :meth:`DataFuel.cancel_crawl`."""
|
|
1064
|
+
body = await self._send(core.cancel_request("crawl", crawl_id), timeout)
|
|
1065
|
+
return self._model(CancelResult, body)
|
|
1066
|
+
|
|
1067
|
+
async def crawl(self, url: str, *, timeout: float | None = None, **kwargs: Any) -> CrawlResult:
|
|
1068
|
+
"""Start a crawl, wait for it, and return every page."""
|
|
1069
|
+
crawl_id = await self.start_crawl(url, **kwargs)
|
|
1070
|
+
status = await self.wait_crawl(crawl_id, timeout=timeout)
|
|
1071
|
+
pages = [page async for page in self.crawl_pages(crawl_id)]
|
|
1072
|
+
return CrawlResult(id=crawl_id, status=status, pages=pages)
|
|
1073
|
+
|
|
1074
|
+
# --- jobs ----------------------------------------------------------
|
|
1075
|
+
|
|
1076
|
+
async def create_job(
|
|
1077
|
+
self,
|
|
1078
|
+
urls: list[str],
|
|
1079
|
+
*,
|
|
1080
|
+
proxy: Proxy | None = None,
|
|
1081
|
+
sequential: bool = False,
|
|
1082
|
+
idempotency_key: str | None = None,
|
|
1083
|
+
timeout: float | None = None,
|
|
1084
|
+
**options: Any,
|
|
1085
|
+
) -> str:
|
|
1086
|
+
"""Queue a batch of known URLs. See :meth:`DataFuel.create_job`."""
|
|
1087
|
+
request = core.build_job(
|
|
1088
|
+
urls,
|
|
1089
|
+
proxy=proxy,
|
|
1090
|
+
sequential=sequential,
|
|
1091
|
+
options=options,
|
|
1092
|
+
idempotency_key=_key(idempotency_key),
|
|
1093
|
+
)
|
|
1094
|
+
body = await self._send(request, timeout)
|
|
1095
|
+
return self._str_field(body, "id")
|
|
1096
|
+
|
|
1097
|
+
async def create_ask_job(
|
|
1098
|
+
self,
|
|
1099
|
+
prompts: list[str],
|
|
1100
|
+
*,
|
|
1101
|
+
engine: str,
|
|
1102
|
+
sequential: bool = False,
|
|
1103
|
+
idempotency_key: str | None = None,
|
|
1104
|
+
timeout: float | None = None,
|
|
1105
|
+
**options: Any,
|
|
1106
|
+
) -> str:
|
|
1107
|
+
"""Queue a batch of prompts for an AI engine."""
|
|
1108
|
+
request = core.build_ask_job(
|
|
1109
|
+
prompts,
|
|
1110
|
+
sequential=sequential,
|
|
1111
|
+
options={"engine": engine, **options},
|
|
1112
|
+
idempotency_key=_key(idempotency_key),
|
|
1113
|
+
)
|
|
1114
|
+
body = await self._send(request, timeout)
|
|
1115
|
+
return self._str_field(body, "id")
|
|
1116
|
+
|
|
1117
|
+
async def create_search_job(
|
|
1118
|
+
self,
|
|
1119
|
+
queries: list[str],
|
|
1120
|
+
*,
|
|
1121
|
+
sequential: bool = False,
|
|
1122
|
+
idempotency_key: str | None = None,
|
|
1123
|
+
timeout: float | None = None,
|
|
1124
|
+
**options: Any,
|
|
1125
|
+
) -> str:
|
|
1126
|
+
"""Queue a batch of Google searches. See :meth:`DataFuel.create_search_job`."""
|
|
1127
|
+
request = core.build_search_job(
|
|
1128
|
+
queries,
|
|
1129
|
+
sequential=sequential,
|
|
1130
|
+
options=options,
|
|
1131
|
+
idempotency_key=_key(idempotency_key),
|
|
1132
|
+
)
|
|
1133
|
+
body = await self._send(request, timeout)
|
|
1134
|
+
return self._str_field(body, "id")
|
|
1135
|
+
|
|
1136
|
+
async def get_job(self, job_id: str, *, timeout: float | None = None) -> JobStatus:
|
|
1137
|
+
"""Return the progress of a job."""
|
|
1138
|
+
body = await self._send(core.Request("GET", f"/job/{core.path_segment(job_id)}"), timeout)
|
|
1139
|
+
return self._model(JobStatus, body)
|
|
1140
|
+
|
|
1141
|
+
async def job_results(self, job_id: str, *, timeout: float | None = None) -> JobResults:
|
|
1142
|
+
"""Return the per-task results of a job."""
|
|
1143
|
+
body = await self._send(
|
|
1144
|
+
core.Request("GET", f"/job/{core.path_segment(job_id)}/results"), timeout
|
|
1145
|
+
)
|
|
1146
|
+
results = self._model(JobResults, body)
|
|
1147
|
+
results.id = job_id
|
|
1148
|
+
return results
|
|
1149
|
+
|
|
1150
|
+
async def wait_job(self, job_id: str, *, timeout: float | None = None) -> JobStatus:
|
|
1151
|
+
"""Poll until the job is done. ``timeout`` None waits indefinitely."""
|
|
1152
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
1153
|
+
while True:
|
|
1154
|
+
status = await self.get_job(job_id)
|
|
1155
|
+
if status.done:
|
|
1156
|
+
return status
|
|
1157
|
+
if deadline is not None and time.monotonic() + self.poll_interval >= deadline:
|
|
1158
|
+
raise WaitTimeout(f"job {job_id} is still running", id=job_id, status=status)
|
|
1159
|
+
await asyncio.sleep(self.poll_interval)
|
|
1160
|
+
|
|
1161
|
+
async def run_job(
|
|
1162
|
+
self, urls: list[str], *, timeout: float | None = None, **kwargs: Any
|
|
1163
|
+
) -> JobResults:
|
|
1164
|
+
"""Create a job, wait for it, and return its results."""
|
|
1165
|
+
job_id = await self.create_job(urls, **kwargs)
|
|
1166
|
+
await self.wait_job(job_id, timeout=timeout)
|
|
1167
|
+
return await self.job_results(job_id)
|
|
1168
|
+
|
|
1169
|
+
async def run_ask_job(
|
|
1170
|
+
self, prompts: list[str], *, engine: str, timeout: float | None = None, **kwargs: Any
|
|
1171
|
+
) -> JobResults:
|
|
1172
|
+
"""Create a prompt job, wait for it, and return its results."""
|
|
1173
|
+
job_id = await self.create_ask_job(prompts, engine=engine, **kwargs)
|
|
1174
|
+
await self.wait_job(job_id, timeout=timeout)
|
|
1175
|
+
return await self.job_results(job_id)
|
|
1176
|
+
|
|
1177
|
+
async def run_search_job(
|
|
1178
|
+
self, queries: list[str], *, timeout: float | None = None, **kwargs: Any
|
|
1179
|
+
) -> JobResults:
|
|
1180
|
+
"""Create a search job, wait for it, and return its results."""
|
|
1181
|
+
job_id = await self.create_search_job(queries, **kwargs)
|
|
1182
|
+
await self.wait_job(job_id, timeout=timeout)
|
|
1183
|
+
return await self.job_results(job_id)
|
|
1184
|
+
|
|
1185
|
+
async def cancel_job(self, job_id: str, *, timeout: float | None = None) -> CancelResult:
|
|
1186
|
+
"""Stop a job. See :meth:`DataFuel.cancel_job`."""
|
|
1187
|
+
body = await self._send(core.cancel_request("job", job_id), timeout)
|
|
1188
|
+
return self._model(CancelResult, body)
|
|
1189
|
+
|
|
1190
|
+
async def list_jobs(
|
|
1191
|
+
self,
|
|
1192
|
+
*,
|
|
1193
|
+
status: Status | str | None = None,
|
|
1194
|
+
type: str | None = None,
|
|
1195
|
+
start_date: date | str | None = None,
|
|
1196
|
+
end_date: date | str | None = None,
|
|
1197
|
+
limit: int | None = None,
|
|
1198
|
+
cursor: str | None = None,
|
|
1199
|
+
timeout: float | None = None,
|
|
1200
|
+
) -> JobsPage:
|
|
1201
|
+
"""One page of your jobs and crawls. See :meth:`DataFuel.list_jobs`."""
|
|
1202
|
+
request = core.list_request(
|
|
1203
|
+
"/job",
|
|
1204
|
+
status=status,
|
|
1205
|
+
type=type,
|
|
1206
|
+
start_date=start_date,
|
|
1207
|
+
end_date=end_date,
|
|
1208
|
+
limit=limit,
|
|
1209
|
+
cursor=cursor,
|
|
1210
|
+
)
|
|
1211
|
+
return self._model(JobsPage, await self._send(request, timeout))
|
|
1212
|
+
|
|
1213
|
+
async def list_tasks(
|
|
1214
|
+
self,
|
|
1215
|
+
*,
|
|
1216
|
+
status: Status | str | None = None,
|
|
1217
|
+
type: str | None = None,
|
|
1218
|
+
job_id: str | None = None,
|
|
1219
|
+
start_date: date | str | None = None,
|
|
1220
|
+
end_date: date | str | None = None,
|
|
1221
|
+
limit: int | None = None,
|
|
1222
|
+
cursor: str | None = None,
|
|
1223
|
+
timeout: float | None = None,
|
|
1224
|
+
) -> TasksPage:
|
|
1225
|
+
"""One page of your tasks. See :meth:`DataFuel.list_tasks`."""
|
|
1226
|
+
request = core.list_request(
|
|
1227
|
+
"/task",
|
|
1228
|
+
status=status,
|
|
1229
|
+
type=type,
|
|
1230
|
+
job_id=job_id,
|
|
1231
|
+
start_date=start_date,
|
|
1232
|
+
end_date=end_date,
|
|
1233
|
+
limit=limit,
|
|
1234
|
+
cursor=cursor,
|
|
1235
|
+
)
|
|
1236
|
+
return self._model(TasksPage, await self._send(request, timeout))
|
|
1237
|
+
|
|
1238
|
+
# --- account -------------------------------------------------------
|
|
1239
|
+
|
|
1240
|
+
async def capabilities(self, *, timeout: float | None = None) -> Capabilities:
|
|
1241
|
+
"""Which task types and LLM engines are switched on right now."""
|
|
1242
|
+
body = await self._send(core.Request("GET", "/config/capabilities"), timeout)
|
|
1243
|
+
return self._model(Capabilities, body)
|
|
1244
|
+
|
|
1245
|
+
async def balance(self, *, timeout: float | None = None) -> int:
|
|
1246
|
+
"""Remaining credits: plan and pay-as-you-go together."""
|
|
1247
|
+
body = await self._send(core.Request("GET", "/users/@me/balance"), timeout)
|
|
1248
|
+
return self._int_field(body, "balance")
|
|
1249
|
+
|
|
1250
|
+
async def balance_split(self, *, timeout: float | None = None) -> BalanceSplit:
|
|
1251
|
+
"""Remaining credits by pool. See :meth:`DataFuel.balance_split`."""
|
|
1252
|
+
body = await self._send(core.Request("GET", "/users/@me/balance"), timeout)
|
|
1253
|
+
return self._model(BalanceSplit, body)
|
|
1254
|
+
|
|
1255
|
+
async def transactions(
|
|
1256
|
+
self,
|
|
1257
|
+
*,
|
|
1258
|
+
operation: str | None = None,
|
|
1259
|
+
start_date: date | str | None = None,
|
|
1260
|
+
end_date: date | str | None = None,
|
|
1261
|
+
page: int | None = None,
|
|
1262
|
+
limit: int | None = None,
|
|
1263
|
+
timeout: float | None = None,
|
|
1264
|
+
) -> TransactionsPage:
|
|
1265
|
+
"""Credit movements. See :meth:`DataFuel.transactions`."""
|
|
1266
|
+
request = core.transactions_request(operation, start_date, end_date, page, limit)
|
|
1267
|
+
return self._model(TransactionsPage, await self._send(request, timeout))
|
|
1268
|
+
|
|
1269
|
+
async def me(self, *, timeout: float | None = None) -> Profile:
|
|
1270
|
+
"""The account behind the API key."""
|
|
1271
|
+
body = await self._send(core.Request("GET", "/users/@me"), timeout)
|
|
1272
|
+
return self._model(Profile, body)
|
|
1273
|
+
|
|
1274
|
+
async def reset_api_key(self, *, timeout: float | None = None) -> str:
|
|
1275
|
+
"""Revoke the current key and return the new one."""
|
|
1276
|
+
body = await self._send(core.Request("POST", "/users/@me/api-key/reset"), timeout)
|
|
1277
|
+
return self._str_field(body, "api_key")
|
|
1278
|
+
|
|
1279
|
+
async def analytics(
|
|
1280
|
+
self,
|
|
1281
|
+
*,
|
|
1282
|
+
start_date: date | str | None = None,
|
|
1283
|
+
end_date: date | str | None = None,
|
|
1284
|
+
interval: str | None = None,
|
|
1285
|
+
module: str | None = None,
|
|
1286
|
+
timeout: float | None = None,
|
|
1287
|
+
) -> Analytics:
|
|
1288
|
+
"""Task counts and credits over a date range. See :meth:`DataFuel.analytics`."""
|
|
1289
|
+
request = core.analytics_request(start_date, end_date, interval, module)
|
|
1290
|
+
return self._model(Analytics, await self._send(request, timeout))
|
|
1291
|
+
|
|
1292
|
+
async def check_protection(
|
|
1293
|
+
self, urls: list[str], *, timeout: float | None = None
|
|
1294
|
+
) -> list[ProtectionCheck]:
|
|
1295
|
+
"""Which anti-bot vendor sits in front of each URL. Nothing is scraped."""
|
|
1296
|
+
body = await self._send(core.Request("POST", "/filter/check", body=list(urls)), timeout)
|
|
1297
|
+
return self._models(ProtectionCheck, body)
|
|
1298
|
+
|
|
1299
|
+
async def proxy_locations(
|
|
1300
|
+
self, proxy_type: str | None = None, *, timeout: float | None = None
|
|
1301
|
+
) -> list[ProxyCountry]:
|
|
1302
|
+
"""Countries, regions and cities a proxy plan can exit from."""
|
|
1303
|
+
body = await self._send(core.proxy_request("/config/proxy/locations", proxy_type), timeout)
|
|
1304
|
+
return self._models(ProxyCountry, body)
|
|
1305
|
+
|
|
1306
|
+
async def proxy_asns(
|
|
1307
|
+
self, country: str, *, proxy_type: str | None = None, timeout: float | None = None
|
|
1308
|
+
) -> list[ProxyLocation]:
|
|
1309
|
+
"""ASNs a proxy plan can exit from in ``country`` (ISO 3166-1 alpha-2)."""
|
|
1310
|
+
body = await self._send(
|
|
1311
|
+
core.proxy_request("/config/proxy/asn", proxy_type, country=country), timeout
|
|
1312
|
+
)
|
|
1313
|
+
return self._models(ProxyLocation, body)
|
|
1314
|
+
|
|
1315
|
+
async def js_instructions(self, *, timeout: float | None = None) -> list[JSInstruction]:
|
|
1316
|
+
"""The browser actions ``js_instructions`` accepts, with an example each."""
|
|
1317
|
+
body = await self._send(core.Request("GET", "/config/js-instructions"), timeout)
|
|
1318
|
+
return self._models(JSInstruction, self._field(body, "instructions"))
|
|
1319
|
+
|
|
1320
|
+
async def ai_providers(self, *, timeout: float | None = None) -> list[AIProvider]:
|
|
1321
|
+
"""The LLM providers and models :class:`~datafuel.AI` accepts."""
|
|
1322
|
+
body = await self._send(core.Request("GET", "/config/ai-providers"), timeout)
|
|
1323
|
+
return self._models(AIProvider, self._field(body, "providers"))
|
|
1324
|
+
|
|
1325
|
+
async def health(self, *, deep: bool = False, timeout: float | None = None) -> Health:
|
|
1326
|
+
"""Whether the API is up. See :meth:`DataFuel.health`."""
|
|
1327
|
+
return self._model(Health, await self._send(core.health_request(deep), timeout))
|