datafuel 0.5.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ test:
8
+ runs-on: [self-hosted, datafuel]
9
+ strategy:
10
+ matrix:
11
+ python: ["3.10", "3.11", "3.12", "3.13"]
12
+ steps:
13
+ - uses: actions/checkout@v5
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: ${{ matrix.python }}
17
+ - run: pip install -e ".[dev]"
18
+ - run: ruff check .
19
+ - run: ruff format --check .
20
+ - run: mypy
21
+ - run: pytest -q
@@ -0,0 +1,59 @@
1
+ name: Release
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+ permissions:
6
+ contents: read
7
+ concurrency:
8
+ group: release
9
+ cancel-in-progress: false
10
+ jobs:
11
+ build:
12
+ runs-on: [self-hosted, datafuel]
13
+ steps:
14
+ - uses: actions/checkout@v5
15
+ with:
16
+ fetch-depth: 0
17
+ persist-credentials: false
18
+ - env:
19
+ TAG: ${{ github.ref_name }}
20
+ run: |
21
+ git rev-parse --verify -q origin/main >/dev/null || { echo "::error::origin/main was not fetched"; exit 1; }
22
+ git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { echo "::error::$TAG is not on main"; exit 1; }
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+ - env:
27
+ TAG: ${{ github.ref_name }}
28
+ run: |
29
+ version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
30
+ [ "${TAG#v}" = "$version" ] || { echo "::error::tag $TAG does not match version $version in pyproject.toml"; exit 1; }
31
+ - run: pip install -e ".[dev]" build twine
32
+ - run: ruff check .
33
+ - run: ruff format --check .
34
+ - run: mypy
35
+ - run: pytest -q
36
+ - run: python -m build
37
+ - run: twine check --strict dist/*
38
+ - uses: actions/upload-artifact@v7
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ if-no-files-found: error
43
+ retention-days: 7
44
+ publish:
45
+ needs: build
46
+ runs-on: [self-hosted, datafuel]
47
+ environment:
48
+ name: pypi
49
+ url: https://pypi.org/p/datafuel
50
+ permissions:
51
+ id-token: write
52
+ steps:
53
+ - uses: actions/download-artifact@v8
54
+ with:
55
+ name: dist
56
+ path: dist/
57
+ - uses: pypa/gh-action-pypi-publish@release/v1
58
+ with:
59
+ skip-existing: true
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
datafuel-0.5.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Datafuel
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.
@@ -0,0 +1,329 @@
1
+ Metadata-Version: 2.5
2
+ Name: datafuel
3
+ Version: 0.5.0
4
+ Summary: Python client for the DataFuel scraping API
5
+ Project-URL: Homepage, https://datafuel.ai
6
+ Project-URL: Documentation, https://docs.datafuel.ai
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Datafuel
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Classifier: Development Status :: 4 - Beta
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.10
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Programming Language :: Python :: 3.13
37
+ Classifier: Topic :: Internet :: WWW/HTTP
38
+ Classifier: Typing :: Typed
39
+ Requires-Python: >=3.10
40
+ Requires-Dist: httpx>=0.27
41
+ Requires-Dist: pydantic>=2.7
42
+ Provides-Extra: dev
43
+ Requires-Dist: mypy>=1.11; extra == 'dev'
44
+ Requires-Dist: pytest>=8; extra == 'dev'
45
+ Requires-Dist: ruff>=0.6; extra == 'dev'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # datafuel-py
49
+
50
+ Python client for the [DataFuel](https://datafuel.ai) scraping API. Python 3.10+, sync and async.
51
+
52
+ ```bash
53
+ pip install datafuel
54
+ ```
55
+
56
+ ```python
57
+ from datafuel import DataFuel
58
+
59
+ df = DataFuel() # or DataFuel("df_key_..."); reads DATAFUEL_API_KEY
60
+
61
+ markdown = df.markdown("https://example.com")
62
+ ```
63
+
64
+ ## Pick the call
65
+
66
+ | You have | Call | Blocks? |
67
+ |---|---|---|
68
+ | One URL | `scrape` / `markdown` | yes |
69
+ | A site, need its URL list | `map` | yes |
70
+ | A start URL, need many pages | `crawl`, or `start_crawl` + `wait_crawl` + `crawl_pages` | `crawl` does |
71
+ | A list of known URLs | `run_job`, or `create_job` + `wait_job` + `job_results` | `run_job` does |
72
+ | A question for an AI engine | `ask` | yes |
73
+ | A Google search | `search` | yes |
74
+ | Many searches or prompts | `run_search_job` / `run_ask_job`, or `create_search_job` / `create_ask_job` + `wait_job` + `job_results` | `run_*` does |
75
+ | Earlier jobs, tasks, usage | `list_jobs` / `list_tasks` / `analytics` / `transactions` | yes |
76
+ | What a request may contain | `js_instructions` / `ai_providers` / `proxy_locations` / `proxy_asns` / `capabilities` | yes |
77
+
78
+ Start with plain `scrape`. Turn on `js_rendering` only when the page comes back empty: it is slower and costs five times the credits on a Basic proxy. `map` a section before you `crawl` it, it costs one credit and tells you how big it is.
79
+
80
+ Every method exists on both `DataFuel` and `AsyncDataFuel`, with the same arguments:
81
+
82
+ ```python
83
+ from datafuel import AsyncDataFuel
84
+
85
+ async with AsyncDataFuel() as df:
86
+ result = await df.scrape("https://example.com")
87
+ ```
88
+
89
+ ## Scrape
90
+
91
+ ```python
92
+ from datafuel import DataFuel, Proxy, Blocked
93
+
94
+ df = DataFuel()
95
+
96
+ try:
97
+ res = df.scrape(
98
+ "https://shop.example.com/p/42",
99
+ proxy=Proxy(type="Premium", country="US"),
100
+ js_rendering=True,
101
+ wait_for="#price",
102
+ extract={"title": "h1", "price": "#price"},
103
+ )
104
+ fields = res.data # {"title": [...], "price": [...]}
105
+ except Blocked as e:
106
+ # 403/429/503 or an anti-bot wall. Refunded. e.protection names the vendor.
107
+ ...
108
+ ```
109
+
110
+ `Result` carries the metadata next to the content. Read it first:
111
+
112
+ | Field | Meaning |
113
+ |---|---|
114
+ | `status_code` | What the target answered. A 404 still completes and bills. |
115
+ | `final_url`, `redirected` | Where the page really came from. |
116
+ | `blocked`, `protection` | The target refused the request. The task failed and was refunded. |
117
+ | `credits_used` | Charged for this task, 0 when it failed. |
118
+
119
+ `res.text` returns html or markdown, `res.data` structured output, `res.image` screenshot bytes.
120
+
121
+ Page options, shared by `scrape`, jobs and crawls: `format` (`html`, `markdown`, `json`, `png`, `jpeg`; `Format` lists the rest), `js_rendering`, `wait_for`, `wait_for_timeout_ms`, `js_instructions`, `block_resource`, `main_content_only`, `include_images`, `extract`, `extract_regex`, `template`, `method`, `body`, `content_type`, `headers`, `header_order`, `cookies`, `user_agent`, `user_agent_type`, `ai`.
122
+
123
+ `js_instructions` is a list of single-action dicts. They run in the order you list them and an action can repeat:
124
+
125
+ ```python
126
+ df.scrape(
127
+ url,
128
+ js_rendering=True,
129
+ js_instructions=[
130
+ {"fill": ["input[name=q]", "laptops"]},
131
+ {"click": "button[type=submit]"},
132
+ {"wait_ms": 1000},
133
+ ],
134
+ block_resource=["Image", "Font", "Media"],
135
+ )
136
+ ```
137
+
138
+ The older form, one dict keyed by action (`{"click": "#load-more"}`), still works, but its order is not guaranteed and an action cannot repeat. `df.js_instructions()` lists every action with an example. It needs `js_rendering=True`. `block_resource` takes one resource type or a list of them.
139
+
140
+ `get_task(id)` reads any task; a task still running comes back with `pending` true. `wait_task(id)` polls until it is done.
141
+
142
+ ## AI after the scrape
143
+
144
+ Point the page at an LLM with your own key and get structured data back:
145
+
146
+ ```python
147
+ from datafuel import AI
148
+
149
+ res = df.scrape(
150
+ url,
151
+ ai=AI(
152
+ prompt="extract the product name and its price",
153
+ format={"name": "string", "price": "number"},
154
+ provider="openai", # required; df.ai_providers() lists the providers and their models
155
+ api_key=os.environ["OPENAI_API_KEY"],
156
+ ),
157
+ )
158
+ res.data # {"name": "...", "price": ...}
159
+ ```
160
+
161
+ Works on `scrape` and on URL jobs. Crawls reject it; the SDK says so before sending. `provider` is required. `model` is optional: leave it out for the provider's default, or pick one of the models `df.ai_providers()` lists; any other value is rejected with `InvalidAttributes`.
162
+
163
+ ## Crawl
164
+
165
+ ```python
166
+ crawl_id = df.start_crawl(
167
+ "https://example.com/docs",
168
+ max_pages=200,
169
+ exclude_paths=[r"\.pdf$"],
170
+ format="markdown",
171
+ )
172
+ status = df.wait_crawl(crawl_id) # status.stop_reason: max_pages, max_depth_exhausted, ...
173
+
174
+ for page in df.crawl_pages(crawl_id):
175
+ if page.pending or not page.ok:
176
+ continue # still running, or a failed page (refunded)
177
+ print(page.url, page.depth, len(page.text))
178
+ ```
179
+
180
+ `df.crawl(url, ...)` does all three in one call:
181
+
182
+ ```python
183
+ crawl = df.crawl("https://example.com/docs", max_pages=200)
184
+ print(crawl.status.stop_reason, crawl.status.total_cost, len(crawl.pages))
185
+ ```
186
+
187
+ Check `stop_reason`: `insufficient_credits` means the crawl ended early. If the wait times out, `WaitTimeout.id` still holds the crawl id — it keeps running and billing server side, so pick it up again with `wait_crawl` and `crawl_pages`.
188
+
189
+ Unset limits use the API defaults: 100 pages, depth 3, 5 pages in flight.
190
+
191
+ `df.cancel_crawl(crawl_id)` stops a crawl and returns what was refunded (`refunded_tasks`, `refunded_credits`). A crawl that already finished raises `JobNotCancellable`.
192
+
193
+ ## Jobs
194
+
195
+ ```python
196
+ results = df.run_job(urls, format="markdown")
197
+ for task in results.tasks:
198
+ if not task.ok:
199
+ continue # this URL failed, the others did not
200
+ print(task.text)
201
+ ```
202
+
203
+ `sequential=True` runs the URLs one after the other; the default runs them concurrently, bounded by your account's concurrency limit. `run_ask_job(prompts, engine=...)` does the same for a batch of prompts, `run_search_job(queries, ...)` for a batch of searches. A job needs at least two targets.
204
+
205
+ `df.cancel_job(job_id)` stops a job; tasks not yet started are refunded.
206
+
207
+ ## Ask
208
+
209
+ ```python
210
+ res = df.ask("best CRM for a 10-person team", engine="perplexity", websearch=True)
211
+ print(res.data) # the engine's answer and its sources
212
+ ```
213
+
214
+ `engine` is `openai`, `gemini`, `google_ai_mode`, `perplexity`, `copilot` or `duck_ai` (`Engine.DUCK_AI`); `df.capabilities()` says which are on right now, and `Result.engine` is the engine the task asked for. Other options: `follow_up` (a second prompt in the same conversation, `openai` and `perplexity` only; other engines fail the task, refunded), `country` (exit country) and `location` (where the engine should answer for).
215
+
216
+ `model` picks the duck.ai model and is valid only with `engine="duck_ai"`: `gpt-6-luna` (default), `gpt-5.4-mini`, `claude-haiku-4-5`, `mistral-small-2603`, `tinfoil/gpt-oss-120b` or `tinfoil/gemma4-31b`; anything else is a 400. A `duck_ai` answer is `{text, citations}` in `res.data`. On `duck_ai`, `websearch` only affects the `tinfoil/*` models; other `duck_ai` models ignore it.
217
+
218
+ ## Search
219
+
220
+ ```python
221
+ res = df.search("best CRM for startups", country="us", language="en")
222
+ results = res.data # structured results with the default json format
223
+ ```
224
+
225
+ 10 credits per result page, whatever the proxy. `page=N` (1 to 10, more is capped at 10) returns N pages merged into one `organic` list with running positions, the other sections from page 1; pages Google did not serve are refunded and `res.data["page"]` is the number returned. `format` is `json` (default), `markdown` or `html`. Searches leave through DataFuel's own pool: pick the market with `country` and `language` (`proxy_country` is accepted but not used yet). Other options: `page`, `google_domain`, one of `location` / `uule` / `lat`+`lon` (+`radius`), and Google's own `cr`, `lr`, `tbs`, `safe`, `nfpr`, `filter`, `uds`, `kgmid`, `si`, `ludocid`, `lsig`, `ibp`. Set `device="mobile"` for mobile results (default `"desktop"`; mobile has no `total_results`) and `ai_overview=True` to also get Google's AI overview (default `False`) in `res.data["ai_overview"]`: `{text, products: [{title, price, source, rating, reviews, link}], references: [{title, source, snippet, url, redirect}]}`.
226
+
227
+ ## Map
228
+
229
+ ```python
230
+ site = df.map("https://example.com", search="blog", limit=500)
231
+ for link in site.links:
232
+ print(link.url)
233
+ ```
234
+
235
+ An empty `site.links` comes with a `site.reason`. `no_links_on_page` usually means the navigation is rendered client-side.
236
+
237
+ ## Errors
238
+
239
+ ```python
240
+ import datafuel
241
+
242
+ try:
243
+ res = df.scrape(url)
244
+ except datafuel.Blocked as e: # subclass of TaskFailed
245
+ ...
246
+ except datafuel.InsufficientCredits:
247
+ ...
248
+ ```
249
+
250
+ - `NoAPIKey`: no key was passed and `DATAFUEL_API_KEY` is empty. Raised before any request.
251
+ - `APIError`: the API refused the request. Subclasses: `Unauthorized`, `Forbidden`, `InsufficientCredits`, `RateLimited`, `NotFound`, `InvalidAttributes`, `IdempotencyKeyReused`, `JobNotCancellable`, `AlreadyExists` (a create collided with an existing task or job; nothing was charged, send it again).
252
+ - `ModuleUnavailable`, `EngineUnavailable`: an operator switched a task type or LLM engine off, e.g. during a provider outage. The reason is in the message, nothing is charged, and the SDK does not retry. `df.capabilities()` lists what is on.
253
+ - `Unavailable`: any other 503, e.g. `SERVICE_UNAVAILABLE` while the key store is down. Temporary, so the SDK retries it.
254
+ - `TaskFailed`, and `Blocked` when the target refused: the API accepted the task but the page could not be scraped. The exception carries `.result`, so the envelope is still readable. Failed tasks are refunded.
255
+ - `WaitTimeout`: a wait ran out of time. `.id` picks the work back up.
256
+
257
+ Tasks inside a job or a crawl never raise on their own — check `task.ok`, or call `task.raise_for_status()`.
258
+
259
+ ## Usage and history
260
+
261
+ ```python
262
+ usage = df.analytics(start_date="2026-09-01", end_date="2026-09-30")
263
+ print(usage.summary.success_rate, usage.summary.avg_credits_per_request)
264
+
265
+ page = df.list_jobs(type="crawl", limit=20)
266
+ failed = df.list_tasks(job_id=page.jobs[0].id, status="failed")
267
+ history = df.transactions(operation="refund", limit=50)
268
+ ```
269
+
270
+ `list_jobs` and `list_tasks` run newest first; pass `next_cursor` back as `cursor` until it is `None`. Task items carry no result: call `get_task(id)` for it. Dates are `YYYY-MM-DD` strings or `date` objects in UTC, and `end_date` is inclusive. `transactions` pages with `page` and `limit`, and its `sums` total each operation over the whole range. In `analytics`, `status_code` 0 means the target never answered (timeout, DNS).
271
+
272
+ ## Account and config
273
+
274
+ ```python
275
+ df.balance() # remaining credits
276
+ df.balance_split() # the same, split into plan_balance and payg_balance
277
+ df.me() # profile, concurrency and credit limits
278
+ df.capabilities() # which task types and LLM engines are on
279
+ df.analytics(start_date="2026-09-01", interval="weekly")
280
+ df.check_protection(["https://shop.example.com/"]) # anti-bot vendor per URL, nothing scraped
281
+ df.proxy_locations("Premium") # countries, regions, cities
282
+ df.proxy_asns("US", proxy_type="Premium")
283
+ df.js_instructions() # the browser actions js_instructions accepts
284
+ df.ai_providers() # the LLM providers and models AI accepts
285
+ df.health(deep=True) # .ok is False while a dependency is down
286
+ df.reset_api_key() # returns the new key, the only time it is shown
287
+ ```
288
+
289
+ `balance` is what you can spend. It is made of plan credits and pay-as-you-go credits. Plan credits are spent first; unused ones roll over when the plan renews and expire if it is not renewed. Pay-as-you-go credits come from one-time credit packs (a `purchase` transaction), are spent after plan credits and never expire. Each transaction's `plan_amount` is the part of `amount` that moved plan credits.
290
+
291
+ ## Retries and idempotency
292
+
293
+ Every write carries an `Idempotency-Key`, generated per request unless you pass `idempotency_key=`. That makes retries safe: network errors and 429/502/503/504 answers are retried with backoff, reusing the key, so a retry attaches to the task already running instead of charging twice. Other 4xx errors are never retried.
294
+
295
+ The same key is how a synchronous task survives a slow page: when the API answers "still processing", the SDK re-sends the identical request under the identical key until the result is ready or the call's timeout is reached.
296
+
297
+ A key replays its stored result, failures included. To run a failed scrape again, send a new request rather than the same key.
298
+
299
+ ## Options
300
+
301
+ ```python
302
+ DataFuel(
303
+ api_key,
304
+ base_url="https://scraping-api.staging.datafuel.ai/api/v1",
305
+ timeout=300, # seconds; scrape blocks until the page is ready
306
+ max_retries=4,
307
+ poll_interval=5, # wait_job / wait_crawl
308
+ user_agent="my-app/1.2",
309
+ http_client=my_httpx_client,
310
+ )
311
+ ```
312
+
313
+ Keep `timeout` generous: `scrape` blocks until the page is ready, which can take minutes with `js_rendering`. Every call also takes its own `timeout=`.
314
+
315
+ If you pass your own `http_client`, leave `follow_redirects` off. httpx keeps custom headers across a redirect, so a redirect to another host would carry your `X-API-Key` to it. The clients this SDK builds have redirects disabled.
316
+
317
+ Both clients are safe to share across threads (or tasks): nothing mutates after construction.
318
+
319
+ ## Try it
320
+
321
+ ```bash
322
+ DATAFUEL_API_KEY=df_key_... python examples/quickstart.py https://example.com
323
+ ```
324
+
325
+ Guides and the full API reference: https://docs.datafuel.ai
326
+
327
+ ## License
328
+
329
+ MIT, see the `LICENSE` file.