hydrafetch 0.1.0__tar.gz → 0.2.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.
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/.github/workflows/publish.yml +9 -1
- hydrafetch-0.2.0/PKG-INFO +286 -0
- hydrafetch-0.2.0/README.md +263 -0
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/pyproject.toml +2 -2
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/src/hydrafetch/_client.py +5 -17
- hydrafetch-0.2.0/src/hydrafetch/_schema.py +69 -0
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/src/hydrafetch/errors.py +0 -6
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/tests/test_client.py +46 -2
- hydrafetch-0.2.0/uv.lock +572 -0
- hydrafetch-0.1.0/PKG-INFO +0 -275
- hydrafetch-0.1.0/README.md +0 -252
- hydrafetch-0.1.0/uv.lock +0 -369
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/.github/workflows/ci.yml +0 -0
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/.gitignore +0 -0
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/LICENSE +0 -0
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/src/hydrafetch/__init__.py +0 -0
- {hydrafetch-0.1.0 → hydrafetch-0.2.0}/src/hydrafetch/py.typed +0 -0
|
@@ -11,7 +11,8 @@ jobs:
|
|
|
11
11
|
runs-on: ubuntu-latest
|
|
12
12
|
environment: pypi
|
|
13
13
|
permissions:
|
|
14
|
-
contents: read
|
|
14
|
+
# contents: write is what lets the release be created; the publish itself needs only read.
|
|
15
|
+
contents: write
|
|
15
16
|
id-token: write
|
|
16
17
|
steps:
|
|
17
18
|
- uses: actions/checkout@v4
|
|
@@ -25,3 +26,10 @@ jobs:
|
|
|
25
26
|
[ "$TAG" = "$PKG" ] || { echo "tag $TAG != pyproject $PKG"; exit 1; }
|
|
26
27
|
- run: uv build
|
|
27
28
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
29
|
+
|
|
30
|
+
# After the publish, never before: a release pointing at a version that is not on PyPI is
|
|
31
|
+
# worse than no release at all.
|
|
32
|
+
- name: Create the GitHub release
|
|
33
|
+
uses: softprops/action-gh-release@v2
|
|
34
|
+
with:
|
|
35
|
+
generate_release_notes: true
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hydrafetch
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python client for the Hydrafetch web data API. Turn any URL into clean Markdown and structured data.
|
|
5
|
+
Project-URL: Homepage, https://hydrafetch.com
|
|
6
|
+
Project-URL: Documentation, https://docs.hydrafetch.com
|
|
7
|
+
Project-URL: Source, https://github.com/Hydrafetch/python-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/Hydrafetch/python-sdk/issues
|
|
9
|
+
Author-email: Hydrafetch <team@hydrafetch.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,crawler,hydrafetch,llm,markdown,rag,structured-data,web-scraping
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx>=0.24
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# hydrafetch
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/hydrafetch/)
|
|
27
|
+
[](https://github.com/Hydrafetch/python-sdk/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/hydrafetch/)
|
|
29
|
+
|
|
30
|
+
Official Python client for the [Hydrafetch](https://hydrafetch.com) web data API.
|
|
31
|
+
|
|
32
|
+
Turn any URL into clean Markdown or schema-shaped JSON. Sync and async, fully typed, one dependency.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install hydrafetch
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from hydrafetch import Hydrafetch
|
|
44
|
+
|
|
45
|
+
hf = Hydrafetch()
|
|
46
|
+
|
|
47
|
+
page = hf.scrape("https://example.com/article")
|
|
48
|
+
print(page["markdown"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Create a key at [app.hydrafetch.com](https://app.hydrafetch.com). The constructor reads `HYDRAFETCH_API_KEY` when no key is passed.
|
|
52
|
+
|
|
53
|
+
Both clients are context managers, which closes the connection pool deterministically:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
with Hydrafetch() as hf:
|
|
57
|
+
page = hf.scrape("https://example.com")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Scraping
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
page = hf.scrape(
|
|
64
|
+
"https://example.com/article",
|
|
65
|
+
formats=["markdown", "links"],
|
|
66
|
+
onlyMainContent=True,
|
|
67
|
+
preferStructure=True,
|
|
68
|
+
blockAds=True,
|
|
69
|
+
maxAge=3_600_000,
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Option names are camelCase because they are passed to the API unchanged. Client arguments such as `api_key`, `max_retries`, `poll_interval` and `on_progress` are snake_case.
|
|
74
|
+
|
|
75
|
+
| Format | Key | Contains |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `markdown` | `markdown` | clean Markdown, the default |
|
|
78
|
+
| `html` | `html` | rendered HTML |
|
|
79
|
+
| `rawHtml` | `rawHtml` | the untouched response body |
|
|
80
|
+
| `links` | `links` | every link on the page |
|
|
81
|
+
| `structured` | `structured` | the page's own JSON-LD and microdata |
|
|
82
|
+
| `summary` | `summary` | a short summary |
|
|
83
|
+
| `json` | `json` | schema-shaped JSON, see `jsonOptions` |
|
|
84
|
+
| `brand` | `brand` | the site's brand record |
|
|
85
|
+
|
|
86
|
+
`hf.markdown(url)` returns the Markdown string directly.
|
|
87
|
+
|
|
88
|
+
## Structured extraction
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
out = hf.extract(
|
|
92
|
+
["https://example.com/product/1", "https://example.com/product/2"],
|
|
93
|
+
schema={
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {
|
|
96
|
+
"name": {"type": "string"},
|
|
97
|
+
"price_usd": {"type": "number"},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
for item in out["results"]:
|
|
103
|
+
print(item["url"], (item.get("data") or {}).get("name"))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Pass `prompt=` instead of, or alongside, `schema=` to describe the fields in plain language.
|
|
107
|
+
|
|
108
|
+
### From a pydantic model
|
|
109
|
+
|
|
110
|
+
`schema=` also takes a pydantic model, so the shape you already parse into is the shape you ask for. A `Field` description is carried through and tells the model what the field means, which is worth more on an ambiguous page than the field name alone.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from pydantic import BaseModel, Field
|
|
114
|
+
|
|
115
|
+
class Product(BaseModel):
|
|
116
|
+
name: str = Field(description="The product being sold on this page, not a recommended one")
|
|
117
|
+
price_usd: float = Field(description="Current price in USD, after any discount")
|
|
118
|
+
|
|
119
|
+
out = hf.extract("https://example.com/product/1", schema=Product)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Nested models work and are flattened before sending. pydantic is not a dependency and is imported only if you pass a model. A model that refers to itself cannot be flattened and is refused rather than sent.
|
|
123
|
+
|
|
124
|
+
## Discovery and bulk work
|
|
125
|
+
|
|
126
|
+
`map` lists a site's URLs for one credit without fetching any page.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
links = hf.map("https://example.com", limit=1000)["links"]
|
|
130
|
+
docs = [url for url in links if "/docs/" in url]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`batch` and `crawl` submit a job and poll until it finishes.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
job = hf.batch(
|
|
137
|
+
docs,
|
|
138
|
+
scrapeOptions={"formats": ["markdown"]},
|
|
139
|
+
on_progress=lambda j: print(j["status"], j.get("completed"), "/", j.get("total")),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
for page in job.get("pages", []):
|
|
143
|
+
print(page["url"], len((page.get("data") or {}).get("markdown") or ""))
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Pass a `webhook` and use `start_crawl` or `start_batch` to return immediately instead of polling.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
crawl_id = hf.start_crawl(
|
|
150
|
+
"https://example.com",
|
|
151
|
+
limit=500,
|
|
152
|
+
maxDepth=3,
|
|
153
|
+
includePaths=["/docs"],
|
|
154
|
+
webhook="https://your.app/hooks/hydrafetch",
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Search
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
res = hf.search("post-quantum TLS adoption", limit=5, scrapeResults=True)
|
|
162
|
+
|
|
163
|
+
for r in res["results"]:
|
|
164
|
+
print(r["title"], r["url"])
|
|
165
|
+
print(((r.get("data") or {}).get("markdown") or "")[:500])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Brand data
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
hf.brand("stripe.com") # logos, colours, fonts, socials
|
|
172
|
+
hf.logo("stripe.com", theme="dark", type="icon") # one asset
|
|
173
|
+
hf.styleguide("stripe.com") # computed design system
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
For logos in a browser use [`@hydrafetch/client-sdk`](https://github.com/Hydrafetch/client-sdk) with a publishable key. Those bill against logo pulls rather than credits.
|
|
177
|
+
|
|
178
|
+
## Async
|
|
179
|
+
|
|
180
|
+
`AsyncHydrafetch` mirrors the same surface. Use it when several calls can run concurrently.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
import asyncio
|
|
184
|
+
from hydrafetch import AsyncHydrafetch
|
|
185
|
+
|
|
186
|
+
async def main():
|
|
187
|
+
async with AsyncHydrafetch() as hf:
|
|
188
|
+
pages = await asyncio.gather(
|
|
189
|
+
hf.scrape("https://a.example"),
|
|
190
|
+
hf.scrape("https://b.example"),
|
|
191
|
+
)
|
|
192
|
+
return [page["markdown"] for page in pages]
|
|
193
|
+
|
|
194
|
+
asyncio.run(main())
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`start_crawl`, `crawl_status`, `start_batch` and `batch_status` are available on the async client. The polling helpers `crawl` and `batch` are sync only; on the async client, poll the status methods or use a webhook.
|
|
198
|
+
|
|
199
|
+
## Error handling
|
|
200
|
+
|
|
201
|
+
All failures raise `HydrafetchError`, carrying the API's error code, HTTP status and request id.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from hydrafetch import HydrafetchError, HydrafetchTimeout
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
page = hf.scrape(url)
|
|
208
|
+
except HydrafetchTimeout:
|
|
209
|
+
raise
|
|
210
|
+
except HydrafetchError as err:
|
|
211
|
+
if err.is_auth:
|
|
212
|
+
refresh_key()
|
|
213
|
+
elif err.is_out_of_credits:
|
|
214
|
+
top_up()
|
|
215
|
+
elif err.is_invalid_request:
|
|
216
|
+
report(err.message)
|
|
217
|
+
elif err.is_retryable:
|
|
218
|
+
enqueue(url)
|
|
219
|
+
else:
|
|
220
|
+
print(err.code, err.status, err.request_id)
|
|
221
|
+
raise
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
| Status | Meaning | Retried |
|
|
225
|
+
| --- | --- | --- |
|
|
226
|
+
| 400, 422 | invalid request | no |
|
|
227
|
+
| 401, 403 | invalid or missing key | no |
|
|
228
|
+
| 402 | out of credits | no |
|
|
229
|
+
| 404 | page does not exist | no |
|
|
230
|
+
| 429 | rate limited | yes, twice with backoff |
|
|
231
|
+
| 5xx | upstream failure | yes, twice with backoff |
|
|
232
|
+
|
|
233
|
+
A 503 from `scrape` means the origin is unreachable, usually a dead domain or a broken certificate.
|
|
234
|
+
|
|
235
|
+
## Configuration
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
hf = Hydrafetch(
|
|
239
|
+
api_key="hf_...",
|
|
240
|
+
base_url="https://api.hydrafetch.com",
|
|
241
|
+
timeout=120.0,
|
|
242
|
+
max_retries=2,
|
|
243
|
+
)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## API reference
|
|
247
|
+
|
|
248
|
+
| Method | Returns | Credits |
|
|
249
|
+
| --- | --- | --- |
|
|
250
|
+
| `scrape(url, **options)` | page dict | 1 |
|
|
251
|
+
| `markdown(url, **options)` | `str` | 1 |
|
|
252
|
+
| `map(url, **options)` | links dict | 1 |
|
|
253
|
+
| `search(query, **options)` | results dict | 1 + 1 per scraped result |
|
|
254
|
+
| `extract(urls, **options)` | dict with `"results"` | 5 per URL |
|
|
255
|
+
| `brand(domain)` | brand dict | 5 |
|
|
256
|
+
| `logo(domain, **options)` | logo dict | 1 |
|
|
257
|
+
| `styleguide(domain)` | design system dict | 10 |
|
|
258
|
+
| `screenshot(url, **options)` | screenshot dict | 5 |
|
|
259
|
+
| `images(url)`, `links(url)` | page assets | 1 |
|
|
260
|
+
| `crawl(url, **options)` | job dict, polled to completion | 1 per page |
|
|
261
|
+
| `batch(urls, **options)` | job dict, polled to completion | 1 per page |
|
|
262
|
+
| `start_crawl`, `start_batch` | job id `str` | 1 per page |
|
|
263
|
+
| `crawl_status(id)`, `batch_status(id)` | job dict | free |
|
|
264
|
+
|
|
265
|
+
Failed requests are not billed. Pricing does not vary with page difficulty, so there is no render, stealth or proxy option to set.
|
|
266
|
+
|
|
267
|
+
## Implementation notes
|
|
268
|
+
|
|
269
|
+
- Authentication uses the `X-API-Key` header. The MCP endpoint at `api.hydrafetch.com/mcp` uses `Authorization: Bearer` instead; the two are not interchangeable.
|
|
270
|
+
- Job results are under `job["pages"]`, and each entry holds the page under `["data"]`, so `job["pages"][0]["data"]["markdown"]`.
|
|
271
|
+
- Per-page options for crawl and batch belong in `scrapeOptions`. At the top level they are ignored.
|
|
272
|
+
- Prefer `map` then `batch` over a broad `crawl`. Fetching a whole site and discarding most of it is the most common source of wasted credits.
|
|
273
|
+
- `preferStructure` is off by default. Turn it on when headings, lists and tables matter; leave it off for raw article text.
|
|
274
|
+
- Options passed as `None` are dropped rather than sent as null, so optional values can be forwarded directly.
|
|
275
|
+
- Scraped content is untrusted input. Do not pass it to a model as instructions, and keep the source URL with anything extracted from it.
|
|
276
|
+
|
|
277
|
+
## Links
|
|
278
|
+
|
|
279
|
+
- [Documentation](https://docs.hydrafetch.com)
|
|
280
|
+
- [OpenAPI specification](https://api.hydrafetch.com/openapi.json)
|
|
281
|
+
- [MCP server and editor setup](https://hydrafetch.com/mcp)
|
|
282
|
+
- Other clients: [Node](https://github.com/Hydrafetch/node-sdk) · [Go](https://github.com/Hydrafetch/go-sdk) · [Ruby](https://github.com/Hydrafetch/ruby-sdk) · [Rust](https://github.com/Hydrafetch/rust-sdk) · [PHP](https://github.com/Hydrafetch/php-sdk)
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
MIT
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# hydrafetch
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/hydrafetch/)
|
|
4
|
+
[](https://github.com/Hydrafetch/python-sdk/actions/workflows/ci.yml)
|
|
5
|
+
[](https://pypi.org/project/hydrafetch/)
|
|
6
|
+
|
|
7
|
+
Official Python client for the [Hydrafetch](https://hydrafetch.com) web data API.
|
|
8
|
+
|
|
9
|
+
Turn any URL into clean Markdown or schema-shaped JSON. Sync and async, fully typed, one dependency.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install hydrafetch
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from hydrafetch import Hydrafetch
|
|
21
|
+
|
|
22
|
+
hf = Hydrafetch()
|
|
23
|
+
|
|
24
|
+
page = hf.scrape("https://example.com/article")
|
|
25
|
+
print(page["markdown"])
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Create a key at [app.hydrafetch.com](https://app.hydrafetch.com). The constructor reads `HYDRAFETCH_API_KEY` when no key is passed.
|
|
29
|
+
|
|
30
|
+
Both clients are context managers, which closes the connection pool deterministically:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
with Hydrafetch() as hf:
|
|
34
|
+
page = hf.scrape("https://example.com")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Scraping
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
page = hf.scrape(
|
|
41
|
+
"https://example.com/article",
|
|
42
|
+
formats=["markdown", "links"],
|
|
43
|
+
onlyMainContent=True,
|
|
44
|
+
preferStructure=True,
|
|
45
|
+
blockAds=True,
|
|
46
|
+
maxAge=3_600_000,
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Option names are camelCase because they are passed to the API unchanged. Client arguments such as `api_key`, `max_retries`, `poll_interval` and `on_progress` are snake_case.
|
|
51
|
+
|
|
52
|
+
| Format | Key | Contains |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `markdown` | `markdown` | clean Markdown, the default |
|
|
55
|
+
| `html` | `html` | rendered HTML |
|
|
56
|
+
| `rawHtml` | `rawHtml` | the untouched response body |
|
|
57
|
+
| `links` | `links` | every link on the page |
|
|
58
|
+
| `structured` | `structured` | the page's own JSON-LD and microdata |
|
|
59
|
+
| `summary` | `summary` | a short summary |
|
|
60
|
+
| `json` | `json` | schema-shaped JSON, see `jsonOptions` |
|
|
61
|
+
| `brand` | `brand` | the site's brand record |
|
|
62
|
+
|
|
63
|
+
`hf.markdown(url)` returns the Markdown string directly.
|
|
64
|
+
|
|
65
|
+
## Structured extraction
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
out = hf.extract(
|
|
69
|
+
["https://example.com/product/1", "https://example.com/product/2"],
|
|
70
|
+
schema={
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"name": {"type": "string"},
|
|
74
|
+
"price_usd": {"type": "number"},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
for item in out["results"]:
|
|
80
|
+
print(item["url"], (item.get("data") or {}).get("name"))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Pass `prompt=` instead of, or alongside, `schema=` to describe the fields in plain language.
|
|
84
|
+
|
|
85
|
+
### From a pydantic model
|
|
86
|
+
|
|
87
|
+
`schema=` also takes a pydantic model, so the shape you already parse into is the shape you ask for. A `Field` description is carried through and tells the model what the field means, which is worth more on an ambiguous page than the field name alone.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from pydantic import BaseModel, Field
|
|
91
|
+
|
|
92
|
+
class Product(BaseModel):
|
|
93
|
+
name: str = Field(description="The product being sold on this page, not a recommended one")
|
|
94
|
+
price_usd: float = Field(description="Current price in USD, after any discount")
|
|
95
|
+
|
|
96
|
+
out = hf.extract("https://example.com/product/1", schema=Product)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Nested models work and are flattened before sending. pydantic is not a dependency and is imported only if you pass a model. A model that refers to itself cannot be flattened and is refused rather than sent.
|
|
100
|
+
|
|
101
|
+
## Discovery and bulk work
|
|
102
|
+
|
|
103
|
+
`map` lists a site's URLs for one credit without fetching any page.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
links = hf.map("https://example.com", limit=1000)["links"]
|
|
107
|
+
docs = [url for url in links if "/docs/" in url]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`batch` and `crawl` submit a job and poll until it finishes.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
job = hf.batch(
|
|
114
|
+
docs,
|
|
115
|
+
scrapeOptions={"formats": ["markdown"]},
|
|
116
|
+
on_progress=lambda j: print(j["status"], j.get("completed"), "/", j.get("total")),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for page in job.get("pages", []):
|
|
120
|
+
print(page["url"], len((page.get("data") or {}).get("markdown") or ""))
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Pass a `webhook` and use `start_crawl` or `start_batch` to return immediately instead of polling.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
crawl_id = hf.start_crawl(
|
|
127
|
+
"https://example.com",
|
|
128
|
+
limit=500,
|
|
129
|
+
maxDepth=3,
|
|
130
|
+
includePaths=["/docs"],
|
|
131
|
+
webhook="https://your.app/hooks/hydrafetch",
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Search
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
res = hf.search("post-quantum TLS adoption", limit=5, scrapeResults=True)
|
|
139
|
+
|
|
140
|
+
for r in res["results"]:
|
|
141
|
+
print(r["title"], r["url"])
|
|
142
|
+
print(((r.get("data") or {}).get("markdown") or "")[:500])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Brand data
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
hf.brand("stripe.com") # logos, colours, fonts, socials
|
|
149
|
+
hf.logo("stripe.com", theme="dark", type="icon") # one asset
|
|
150
|
+
hf.styleguide("stripe.com") # computed design system
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
For logos in a browser use [`@hydrafetch/client-sdk`](https://github.com/Hydrafetch/client-sdk) with a publishable key. Those bill against logo pulls rather than credits.
|
|
154
|
+
|
|
155
|
+
## Async
|
|
156
|
+
|
|
157
|
+
`AsyncHydrafetch` mirrors the same surface. Use it when several calls can run concurrently.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import asyncio
|
|
161
|
+
from hydrafetch import AsyncHydrafetch
|
|
162
|
+
|
|
163
|
+
async def main():
|
|
164
|
+
async with AsyncHydrafetch() as hf:
|
|
165
|
+
pages = await asyncio.gather(
|
|
166
|
+
hf.scrape("https://a.example"),
|
|
167
|
+
hf.scrape("https://b.example"),
|
|
168
|
+
)
|
|
169
|
+
return [page["markdown"] for page in pages]
|
|
170
|
+
|
|
171
|
+
asyncio.run(main())
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`start_crawl`, `crawl_status`, `start_batch` and `batch_status` are available on the async client. The polling helpers `crawl` and `batch` are sync only; on the async client, poll the status methods or use a webhook.
|
|
175
|
+
|
|
176
|
+
## Error handling
|
|
177
|
+
|
|
178
|
+
All failures raise `HydrafetchError`, carrying the API's error code, HTTP status and request id.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from hydrafetch import HydrafetchError, HydrafetchTimeout
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
page = hf.scrape(url)
|
|
185
|
+
except HydrafetchTimeout:
|
|
186
|
+
raise
|
|
187
|
+
except HydrafetchError as err:
|
|
188
|
+
if err.is_auth:
|
|
189
|
+
refresh_key()
|
|
190
|
+
elif err.is_out_of_credits:
|
|
191
|
+
top_up()
|
|
192
|
+
elif err.is_invalid_request:
|
|
193
|
+
report(err.message)
|
|
194
|
+
elif err.is_retryable:
|
|
195
|
+
enqueue(url)
|
|
196
|
+
else:
|
|
197
|
+
print(err.code, err.status, err.request_id)
|
|
198
|
+
raise
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
| Status | Meaning | Retried |
|
|
202
|
+
| --- | --- | --- |
|
|
203
|
+
| 400, 422 | invalid request | no |
|
|
204
|
+
| 401, 403 | invalid or missing key | no |
|
|
205
|
+
| 402 | out of credits | no |
|
|
206
|
+
| 404 | page does not exist | no |
|
|
207
|
+
| 429 | rate limited | yes, twice with backoff |
|
|
208
|
+
| 5xx | upstream failure | yes, twice with backoff |
|
|
209
|
+
|
|
210
|
+
A 503 from `scrape` means the origin is unreachable, usually a dead domain or a broken certificate.
|
|
211
|
+
|
|
212
|
+
## Configuration
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
hf = Hydrafetch(
|
|
216
|
+
api_key="hf_...",
|
|
217
|
+
base_url="https://api.hydrafetch.com",
|
|
218
|
+
timeout=120.0,
|
|
219
|
+
max_retries=2,
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## API reference
|
|
224
|
+
|
|
225
|
+
| Method | Returns | Credits |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| `scrape(url, **options)` | page dict | 1 |
|
|
228
|
+
| `markdown(url, **options)` | `str` | 1 |
|
|
229
|
+
| `map(url, **options)` | links dict | 1 |
|
|
230
|
+
| `search(query, **options)` | results dict | 1 + 1 per scraped result |
|
|
231
|
+
| `extract(urls, **options)` | dict with `"results"` | 5 per URL |
|
|
232
|
+
| `brand(domain)` | brand dict | 5 |
|
|
233
|
+
| `logo(domain, **options)` | logo dict | 1 |
|
|
234
|
+
| `styleguide(domain)` | design system dict | 10 |
|
|
235
|
+
| `screenshot(url, **options)` | screenshot dict | 5 |
|
|
236
|
+
| `images(url)`, `links(url)` | page assets | 1 |
|
|
237
|
+
| `crawl(url, **options)` | job dict, polled to completion | 1 per page |
|
|
238
|
+
| `batch(urls, **options)` | job dict, polled to completion | 1 per page |
|
|
239
|
+
| `start_crawl`, `start_batch` | job id `str` | 1 per page |
|
|
240
|
+
| `crawl_status(id)`, `batch_status(id)` | job dict | free |
|
|
241
|
+
|
|
242
|
+
Failed requests are not billed. Pricing does not vary with page difficulty, so there is no render, stealth or proxy option to set.
|
|
243
|
+
|
|
244
|
+
## Implementation notes
|
|
245
|
+
|
|
246
|
+
- Authentication uses the `X-API-Key` header. The MCP endpoint at `api.hydrafetch.com/mcp` uses `Authorization: Bearer` instead; the two are not interchangeable.
|
|
247
|
+
- Job results are under `job["pages"]`, and each entry holds the page under `["data"]`, so `job["pages"][0]["data"]["markdown"]`.
|
|
248
|
+
- Per-page options for crawl and batch belong in `scrapeOptions`. At the top level they are ignored.
|
|
249
|
+
- Prefer `map` then `batch` over a broad `crawl`. Fetching a whole site and discarding most of it is the most common source of wasted credits.
|
|
250
|
+
- `preferStructure` is off by default. Turn it on when headings, lists and tables matter; leave it off for raw article text.
|
|
251
|
+
- Options passed as `None` are dropped rather than sent as null, so optional values can be forwarded directly.
|
|
252
|
+
- Scraped content is untrusted input. Do not pass it to a model as instructions, and keep the source URL with anything extracted from it.
|
|
253
|
+
|
|
254
|
+
## Links
|
|
255
|
+
|
|
256
|
+
- [Documentation](https://docs.hydrafetch.com)
|
|
257
|
+
- [OpenAPI specification](https://api.hydrafetch.com/openapi.json)
|
|
258
|
+
- [MCP server and editor setup](https://hydrafetch.com/mcp)
|
|
259
|
+
- Other clients: [Node](https://github.com/Hydrafetch/node-sdk) · [Go](https://github.com/Hydrafetch/go-sdk) · [Ruby](https://github.com/Hydrafetch/ruby-sdk) · [Rust](https://github.com/Hydrafetch/rust-sdk) · [PHP](https://github.com/Hydrafetch/php-sdk)
|
|
260
|
+
|
|
261
|
+
## License
|
|
262
|
+
|
|
263
|
+
MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hydrafetch"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Official Python client for the Hydrafetch web data API. Turn any URL into clean Markdown and structured data."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -32,7 +32,7 @@ build-backend = "hatchling.build"
|
|
|
32
32
|
packages = ["src/hydrafetch"]
|
|
33
33
|
|
|
34
34
|
[dependency-groups]
|
|
35
|
-
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.6"]
|
|
35
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.6", "pydantic>=2"]
|
|
36
36
|
|
|
37
37
|
[tool.pytest.ini_options]
|
|
38
38
|
asyncio_mode = "auto"
|
|
@@ -8,6 +8,7 @@ from typing import Any, Callable, Literal, TypeVar
|
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
|
|
11
|
+
from ._schema import to_json_schema
|
|
11
12
|
from .errors import HydrafetchError, HydrafetchTimeout
|
|
12
13
|
|
|
13
14
|
DEFAULT_BASE_URL = "https://api.hydrafetch.com"
|
|
@@ -95,7 +96,10 @@ class _Base:
|
|
|
95
96
|
self, urls: str | Iterable[str], options: Mapping[str, Any]
|
|
96
97
|
) -> dict[str, Any]:
|
|
97
98
|
url_list = [urls] if isinstance(urls, str) else list(urls)
|
|
98
|
-
|
|
99
|
+
body = _drop_none(options)
|
|
100
|
+
if "schema" in body:
|
|
101
|
+
body["schema"] = to_json_schema(body["schema"])
|
|
102
|
+
return {"urls": url_list, **body}
|
|
99
103
|
|
|
100
104
|
def _job_id(self, payload: Any, *keys: str) -> str:
|
|
101
105
|
if isinstance(payload, dict):
|
|
@@ -107,8 +111,6 @@ class _Base:
|
|
|
107
111
|
|
|
108
112
|
|
|
109
113
|
class Hydrafetch(_Base):
|
|
110
|
-
"""Synchronous client. See AsyncHydrafetch for the awaitable version."""
|
|
111
|
-
|
|
112
114
|
def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
|
|
113
115
|
super().__init__(api_key, **kwargs)
|
|
114
116
|
self._http = httpx.Client(
|
|
@@ -154,43 +156,33 @@ class Hydrafetch(_Base):
|
|
|
154
156
|
raise last or HydrafetchError("Request failed")
|
|
155
157
|
|
|
156
158
|
def scrape(self, url: str, **options: Any) -> dict[str, Any]:
|
|
157
|
-
"""One page as clean Markdown and structured data. One credit."""
|
|
158
159
|
return self._request("POST", "/v1/web/scrape", json=self._scrape_body(url, options))
|
|
159
160
|
|
|
160
161
|
def map(self, url: str, **options: Any) -> dict[str, Any]:
|
|
161
|
-
"""A site's URLs from its sitemap and links, without fetching the pages. One credit."""
|
|
162
162
|
return self._request("POST", "/v1/web/map", json=self._scrape_body(url, options))
|
|
163
163
|
|
|
164
164
|
def search(self, query: str, **options: Any) -> dict[str, Any]:
|
|
165
|
-
"""Ranked web results. One credit, plus one per result when scrape_results is set."""
|
|
166
165
|
return self._request("POST", "/v1/web/search", json={"query": query, **_drop_none(options)})
|
|
167
166
|
|
|
168
167
|
def extract(self, urls: str | Iterable[str], **options: Any) -> Any:
|
|
169
|
-
"""Typed JSON from one or more URLs. Five credits per URL."""
|
|
170
168
|
return self._request("POST", "/v1/web/extract", json=self._extract_body(urls, options))
|
|
171
169
|
|
|
172
170
|
def brand(self, domain: str) -> dict[str, Any]:
|
|
173
|
-
"""A company's brand from its domain. Five credits."""
|
|
174
171
|
return self._request("GET", "/v1/web/brand", params={"domain": domain})
|
|
175
172
|
|
|
176
173
|
def logo(self, domain: str, **options: Any) -> dict[str, Any]:
|
|
177
|
-
"""One embeddable logo. One credit, a fifth of a full brand resolve."""
|
|
178
174
|
return self._request("GET", "/v1/web/brand/logo", params={"domain": domain, **options})
|
|
179
175
|
|
|
180
176
|
def styleguide(self, domain: str) -> dict[str, Any]:
|
|
181
|
-
"""A site's design system, read from computed styles in a real browser. Ten credits."""
|
|
182
177
|
return self._request("GET", "/v1/web/styleguide", params={"domain": domain})
|
|
183
178
|
|
|
184
179
|
def screenshot(self, url: str, **options: Any) -> dict[str, Any]:
|
|
185
|
-
"""A rendered PNG of the page, returned as a public URL. Five credits."""
|
|
186
180
|
return self._request("POST", "/v1/web/screenshot", json=self._scrape_body(url, options))
|
|
187
181
|
|
|
188
182
|
def images(self, url: str, **options: Any) -> dict[str, Any]:
|
|
189
|
-
"""A page's images with metadata, without rendering it. One credit."""
|
|
190
183
|
return self._request("POST", "/v1/web/images", json=self._scrape_body(url, options))
|
|
191
184
|
|
|
192
185
|
def links(self, url: str, **options: Any) -> dict[str, Any]:
|
|
193
|
-
"""A page's links. One credit."""
|
|
194
186
|
return self._request("POST", "/v1/web/links", json=self._scrape_body(url, options))
|
|
195
187
|
|
|
196
188
|
def start_crawl(self, url: str, **options: Any) -> str:
|
|
@@ -218,7 +210,6 @@ class Hydrafetch(_Base):
|
|
|
218
210
|
on_progress: Callable[[dict[str, Any]], None] | None = None,
|
|
219
211
|
**options: Any,
|
|
220
212
|
) -> dict[str, Any]:
|
|
221
|
-
"""Start a crawl and poll until it reaches a terminal state."""
|
|
222
213
|
crawl_id = self.start_crawl(url, **options)
|
|
223
214
|
return self._wait(
|
|
224
215
|
lambda: self.crawl_status(crawl_id), poll_interval, job_timeout, on_progress
|
|
@@ -233,7 +224,6 @@ class Hydrafetch(_Base):
|
|
|
233
224
|
on_progress: Callable[[dict[str, Any]], None] | None = None,
|
|
234
225
|
**options: Any,
|
|
235
226
|
) -> dict[str, Any]:
|
|
236
|
-
"""Start a batch and poll until it reaches a terminal state."""
|
|
237
227
|
batch_id = self.start_batch(urls, **options)
|
|
238
228
|
return self._wait(
|
|
239
229
|
lambda: self.batch_status(batch_id), poll_interval, job_timeout, on_progress
|
|
@@ -262,8 +252,6 @@ class Hydrafetch(_Base):
|
|
|
262
252
|
|
|
263
253
|
|
|
264
254
|
class AsyncHydrafetch(_Base):
|
|
265
|
-
"""Awaitable client with the same surface as Hydrafetch."""
|
|
266
|
-
|
|
267
255
|
def __init__(self, api_key: str | None = None, **kwargs: Any) -> None:
|
|
268
256
|
super().__init__(api_key, **kwargs)
|
|
269
257
|
self._http = httpx.AsyncClient(
|