hydrafetch 0.2.0__tar.gz → 0.4.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.2.0 → hydrafetch-0.4.0}/PKG-INFO +23 -3
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/README.md +22 -2
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/pyproject.toml +7 -1
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/src/hydrafetch/__init__.py +1 -1
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/src/hydrafetch/_client.py +63 -4
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/tests/test_client.py +49 -2
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/uv.lock +1 -1
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/.github/workflows/ci.yml +0 -0
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/.github/workflows/publish.yml +0 -0
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/.gitignore +0 -0
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/LICENSE +0 -0
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/src/hydrafetch/_schema.py +0 -0
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/src/hydrafetch/errors.py +0 -0
- {hydrafetch-0.2.0 → hydrafetch-0.4.0}/src/hydrafetch/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hydrafetch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official Python client for the Hydrafetch web data API. Turn any URL into clean Markdown and structured data.
|
|
5
5
|
Project-URL: Homepage, https://hydrafetch.com
|
|
6
6
|
Project-URL: Documentation, https://docs.hydrafetch.com
|
|
@@ -48,7 +48,7 @@ page = hf.scrape("https://example.com/article")
|
|
|
48
48
|
print(page["markdown"])
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Copy your key from [app.hydrafetch.com](https://app.hydrafetch.com). The constructor reads `HYDRAFETCH_API_KEY` when no key is passed.
|
|
52
52
|
|
|
53
53
|
Both clients are context managers, which closes the connection pool deterministically:
|
|
54
54
|
|
|
@@ -171,10 +171,29 @@ for r in res["results"]:
|
|
|
171
171
|
hf.brand("stripe.com") # logos, colours, fonts, socials
|
|
172
172
|
hf.logo("stripe.com", theme="dark", type="icon") # one asset
|
|
173
173
|
hf.styleguide("stripe.com") # computed design system
|
|
174
|
+
hf.styleguide("https://stripe.com/pricing", maxAge=0) # one page, read fresh
|
|
174
175
|
```
|
|
175
176
|
|
|
176
177
|
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
|
|
|
179
|
+
## Rating results
|
|
180
|
+
|
|
181
|
+
Tell us when a result was wrong, so it gets looked at. It is free, and rating the same request again replaces the earlier rating.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
page = hf.scrape("https://stripe.com/pricing")
|
|
185
|
+
hf.rate_result(
|
|
186
|
+
"bad",
|
|
187
|
+
request_id=hf.last_request_id,
|
|
188
|
+
reasons=["missing_content"],
|
|
189
|
+
note="The enterprise tier is missing",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
hf.rate_result("good", url="https://stripe.com/pricing") # by URL, last 24 hours
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Reasons are `missing_content`, `wrong_content`, `blocked`, `slow`, `formatting` and `other`.
|
|
196
|
+
|
|
178
197
|
## Async
|
|
179
198
|
|
|
180
199
|
`AsyncHydrafetch` mirrors the same surface. Use it when several calls can run concurrently.
|
|
@@ -254,13 +273,14 @@ hf = Hydrafetch(
|
|
|
254
273
|
| `extract(urls, **options)` | dict with `"results"` | 5 per URL |
|
|
255
274
|
| `brand(domain)` | brand dict | 5 |
|
|
256
275
|
| `logo(domain, **options)` | logo dict | 1 |
|
|
257
|
-
| `styleguide(
|
|
276
|
+
| `styleguide(domain_or_url, **options)` | design system dict | 10 |
|
|
258
277
|
| `screenshot(url, **options)` | screenshot dict | 5 |
|
|
259
278
|
| `images(url)`, `links(url)` | page assets | 1 |
|
|
260
279
|
| `crawl(url, **options)` | job dict, polled to completion | 1 per page |
|
|
261
280
|
| `batch(urls, **options)` | job dict, polled to completion | 1 per page |
|
|
262
281
|
| `start_crawl`, `start_batch` | job id `str` | 1 per page |
|
|
263
282
|
| `crawl_status(id)`, `batch_status(id)` | job dict | free |
|
|
283
|
+
| `rate_result(rating, request_id=, url=, reasons=, note=)` | rating dict | free |
|
|
264
284
|
|
|
265
285
|
Failed requests are not billed. Pricing does not vary with page difficulty, so there is no render, stealth or proxy option to set.
|
|
266
286
|
|
|
@@ -25,7 +25,7 @@ page = hf.scrape("https://example.com/article")
|
|
|
25
25
|
print(page["markdown"])
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Copy your key from [app.hydrafetch.com](https://app.hydrafetch.com). The constructor reads `HYDRAFETCH_API_KEY` when no key is passed.
|
|
29
29
|
|
|
30
30
|
Both clients are context managers, which closes the connection pool deterministically:
|
|
31
31
|
|
|
@@ -148,10 +148,29 @@ for r in res["results"]:
|
|
|
148
148
|
hf.brand("stripe.com") # logos, colours, fonts, socials
|
|
149
149
|
hf.logo("stripe.com", theme="dark", type="icon") # one asset
|
|
150
150
|
hf.styleguide("stripe.com") # computed design system
|
|
151
|
+
hf.styleguide("https://stripe.com/pricing", maxAge=0) # one page, read fresh
|
|
151
152
|
```
|
|
152
153
|
|
|
153
154
|
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
|
|
|
156
|
+
## Rating results
|
|
157
|
+
|
|
158
|
+
Tell us when a result was wrong, so it gets looked at. It is free, and rating the same request again replaces the earlier rating.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
page = hf.scrape("https://stripe.com/pricing")
|
|
162
|
+
hf.rate_result(
|
|
163
|
+
"bad",
|
|
164
|
+
request_id=hf.last_request_id,
|
|
165
|
+
reasons=["missing_content"],
|
|
166
|
+
note="The enterprise tier is missing",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
hf.rate_result("good", url="https://stripe.com/pricing") # by URL, last 24 hours
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Reasons are `missing_content`, `wrong_content`, `blocked`, `slow`, `formatting` and `other`.
|
|
173
|
+
|
|
155
174
|
## Async
|
|
156
175
|
|
|
157
176
|
`AsyncHydrafetch` mirrors the same surface. Use it when several calls can run concurrently.
|
|
@@ -231,13 +250,14 @@ hf = Hydrafetch(
|
|
|
231
250
|
| `extract(urls, **options)` | dict with `"results"` | 5 per URL |
|
|
232
251
|
| `brand(domain)` | brand dict | 5 |
|
|
233
252
|
| `logo(domain, **options)` | logo dict | 1 |
|
|
234
|
-
| `styleguide(
|
|
253
|
+
| `styleguide(domain_or_url, **options)` | design system dict | 10 |
|
|
235
254
|
| `screenshot(url, **options)` | screenshot dict | 5 |
|
|
236
255
|
| `images(url)`, `links(url)` | page assets | 1 |
|
|
237
256
|
| `crawl(url, **options)` | job dict, polled to completion | 1 per page |
|
|
238
257
|
| `batch(urls, **options)` | job dict, polled to completion | 1 per page |
|
|
239
258
|
| `start_crawl`, `start_batch` | job id `str` | 1 per page |
|
|
240
259
|
| `crawl_status(id)`, `batch_status(id)` | job dict | free |
|
|
260
|
+
| `rate_result(rating, request_id=, url=, reasons=, note=)` | rating dict | free |
|
|
241
261
|
|
|
242
262
|
Failed requests are not billed. Pricing does not vary with page difficulty, so there is no render, stealth or proxy option to set.
|
|
243
263
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hydrafetch"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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"
|
|
@@ -46,3 +46,9 @@ target-version = "py39"
|
|
|
46
46
|
# PYI019 wants typing.Self on the context managers. Self landed in 3.11 and this
|
|
47
47
|
# package supports 3.9, so the bound TypeVars stay until the floor moves.
|
|
48
48
|
ignore = ["PYI019"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.pyupgrade]
|
|
51
|
+
# `from __future__ import annotations` normally makes `X | None` safe on 3.9, which is why ruff
|
|
52
|
+
# offers to rewrite Optional. Pydantic evaluates annotations at runtime, so on 3.9 the rewrite
|
|
53
|
+
# turns a working model into a TypeError at import. Only the 3.9 CI leg catches it.
|
|
54
|
+
keep-runtime-typing = true
|
|
@@ -88,10 +88,39 @@ class _Base:
|
|
|
88
88
|
self._base_url = base_url.rstrip("/")
|
|
89
89
|
self._timeout = timeout
|
|
90
90
|
self._max_retries = max_retries
|
|
91
|
+
self.last_request_id: str | None = None
|
|
92
|
+
|
|
93
|
+
def _rate_body(
|
|
94
|
+
self,
|
|
95
|
+
rating: str,
|
|
96
|
+
request_id: str | None,
|
|
97
|
+
url: str | None,
|
|
98
|
+
reasons: Iterable[str] | None,
|
|
99
|
+
note: str | None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
if request_id is None and url is None:
|
|
102
|
+
raise HydrafetchError(
|
|
103
|
+
"Pass request_id or url to say which result you are rating.",
|
|
104
|
+
code="VALIDATION_ERROR",
|
|
105
|
+
status=400,
|
|
106
|
+
)
|
|
107
|
+
return _drop_none(
|
|
108
|
+
{
|
|
109
|
+
"requestId": request_id,
|
|
110
|
+
"url": url,
|
|
111
|
+
"rating": rating,
|
|
112
|
+
"reasons": list(reasons) if reasons is not None else None,
|
|
113
|
+
"note": note,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
91
116
|
|
|
92
117
|
def _scrape_body(self, url: str, options: Mapping[str, Any]) -> dict[str, Any]:
|
|
93
118
|
return {"url": url, **_drop_none(options)}
|
|
94
119
|
|
|
120
|
+
def _styleguide_params(self, target: str, options: Mapping[str, Any]) -> dict[str, Any]:
|
|
121
|
+
key = "url" if target.lower().startswith(("http://", "https://")) else "domain"
|
|
122
|
+
return {key: target, **_drop_none(options)}
|
|
123
|
+
|
|
95
124
|
def _extract_body(
|
|
96
125
|
self, urls: str | Iterable[str], options: Mapping[str, Any]
|
|
97
126
|
) -> dict[str, Any]:
|
|
@@ -146,6 +175,7 @@ class Hydrafetch(_Base):
|
|
|
146
175
|
"or use the crawl and batch job endpoints for long work."
|
|
147
176
|
) from exc
|
|
148
177
|
|
|
178
|
+
self.last_request_id = response.headers.get("x-request-id") or self.last_request_id
|
|
149
179
|
try:
|
|
150
180
|
return _unwrap(response)
|
|
151
181
|
except HydrafetchError as err:
|
|
@@ -158,6 +188,18 @@ class Hydrafetch(_Base):
|
|
|
158
188
|
def scrape(self, url: str, **options: Any) -> dict[str, Any]:
|
|
159
189
|
return self._request("POST", "/v1/web/scrape", json=self._scrape_body(url, options))
|
|
160
190
|
|
|
191
|
+
def rate_result(
|
|
192
|
+
self,
|
|
193
|
+
rating: str,
|
|
194
|
+
*,
|
|
195
|
+
request_id: str | None = None,
|
|
196
|
+
url: str | None = None,
|
|
197
|
+
reasons: Iterable[str] | None = None,
|
|
198
|
+
note: str | None = None,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
body = self._rate_body(rating, request_id, url, reasons, note)
|
|
201
|
+
return self._request("POST", "/v1/web/feedback", json=body)
|
|
202
|
+
|
|
161
203
|
def map(self, url: str, **options: Any) -> dict[str, Any]:
|
|
162
204
|
return self._request("POST", "/v1/web/map", json=self._scrape_body(url, options))
|
|
163
205
|
|
|
@@ -173,8 +215,10 @@ class Hydrafetch(_Base):
|
|
|
173
215
|
def logo(self, domain: str, **options: Any) -> dict[str, Any]:
|
|
174
216
|
return self._request("GET", "/v1/web/brand/logo", params={"domain": domain, **options})
|
|
175
217
|
|
|
176
|
-
def styleguide(self,
|
|
177
|
-
return self._request(
|
|
218
|
+
def styleguide(self, domain_or_url: str, **options: Any) -> dict[str, Any]:
|
|
219
|
+
return self._request(
|
|
220
|
+
"GET", "/v1/web/styleguide", params=self._styleguide_params(domain_or_url, options)
|
|
221
|
+
)
|
|
178
222
|
|
|
179
223
|
def screenshot(self, url: str, **options: Any) -> dict[str, Any]:
|
|
180
224
|
return self._request("POST", "/v1/web/screenshot", json=self._scrape_body(url, options))
|
|
@@ -289,6 +333,7 @@ class AsyncHydrafetch(_Base):
|
|
|
289
333
|
"or use the crawl and batch job endpoints for long work."
|
|
290
334
|
) from exc
|
|
291
335
|
|
|
336
|
+
self.last_request_id = response.headers.get("x-request-id") or self.last_request_id
|
|
292
337
|
try:
|
|
293
338
|
return _unwrap(response)
|
|
294
339
|
except HydrafetchError as err:
|
|
@@ -301,6 +346,18 @@ class AsyncHydrafetch(_Base):
|
|
|
301
346
|
async def scrape(self, url: str, **options: Any) -> dict[str, Any]:
|
|
302
347
|
return await self._request("POST", "/v1/web/scrape", json=self._scrape_body(url, options))
|
|
303
348
|
|
|
349
|
+
async def rate_result(
|
|
350
|
+
self,
|
|
351
|
+
rating: str,
|
|
352
|
+
*,
|
|
353
|
+
request_id: str | None = None,
|
|
354
|
+
url: str | None = None,
|
|
355
|
+
reasons: Iterable[str] | None = None,
|
|
356
|
+
note: str | None = None,
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
body = self._rate_body(rating, request_id, url, reasons, note)
|
|
359
|
+
return await self._request("POST", "/v1/web/feedback", json=body)
|
|
360
|
+
|
|
304
361
|
async def map(self, url: str, **options: Any) -> dict[str, Any]:
|
|
305
362
|
return await self._request("POST", "/v1/web/map", json=self._scrape_body(url, options))
|
|
306
363
|
|
|
@@ -322,8 +379,10 @@ class AsyncHydrafetch(_Base):
|
|
|
322
379
|
"GET", "/v1/web/brand/logo", params={"domain": domain, **options}
|
|
323
380
|
)
|
|
324
381
|
|
|
325
|
-
async def styleguide(self,
|
|
326
|
-
return await self._request(
|
|
382
|
+
async def styleguide(self, domain_or_url: str, **options: Any) -> dict[str, Any]:
|
|
383
|
+
return await self._request(
|
|
384
|
+
"GET", "/v1/web/styleguide", params=self._styleguide_params(domain_or_url, options)
|
|
385
|
+
)
|
|
327
386
|
|
|
328
387
|
async def screenshot(self, url: str, **options: Any) -> dict[str, Any]:
|
|
329
388
|
return await self._request(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, Optional
|
|
5
5
|
|
|
6
6
|
import httpx
|
|
7
7
|
import pytest
|
|
@@ -102,6 +102,16 @@ class TestRequestShaping:
|
|
|
102
102
|
assert rec.last.url.params["theme"] == "dark"
|
|
103
103
|
assert not rec.last.content
|
|
104
104
|
|
|
105
|
+
def test_styleguide_reads_a_domain_or_one_page(self) -> None:
|
|
106
|
+
rec = Recorder({"success": True, "data": {}})
|
|
107
|
+
hf = make_client(rec)
|
|
108
|
+
hf.styleguide("stripe.com")
|
|
109
|
+
assert rec.last.url.params["domain"] == "stripe.com"
|
|
110
|
+
hf.styleguide("https://stripe.com/pricing", maxAge=0)
|
|
111
|
+
assert rec.last.url.params["url"] == "https://stripe.com/pricing"
|
|
112
|
+
assert rec.last.url.params["maxAge"] == "0"
|
|
113
|
+
assert "domain" not in rec.last.url.params
|
|
114
|
+
|
|
105
115
|
|
|
106
116
|
class TestErrors:
|
|
107
117
|
def test_surfaces_code_status_and_request_id(self) -> None:
|
|
@@ -238,7 +248,7 @@ def test_extract_still_takes_plain_json_schema():
|
|
|
238
248
|
|
|
239
249
|
|
|
240
250
|
class Node(BaseModel):
|
|
241
|
-
child: Node
|
|
251
|
+
child: Optional[Node] = None
|
|
242
252
|
|
|
243
253
|
|
|
244
254
|
def test_extract_rejects_a_recursive_model_rather_than_sending_a_ref():
|
|
@@ -246,3 +256,40 @@ def test_extract_rejects_a_recursive_model_rather_than_sending_a_ref():
|
|
|
246
256
|
with pytest.raises(HydrafetchError) as err:
|
|
247
257
|
client.extract("https://example.com", schema=Node)
|
|
248
258
|
assert err.value.code == "SCHEMA_CONVERSION_FAILED"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_rate_result_posts_the_rating_for_the_last_request() -> None:
|
|
262
|
+
seen: list[httpx.Request] = []
|
|
263
|
+
|
|
264
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
265
|
+
seen.append(request)
|
|
266
|
+
if request.url.path == "/v1/web/feedback":
|
|
267
|
+
return ok({"success": True, "data": {"id": "f1", "requestId": "r1", "rating": "bad"}})
|
|
268
|
+
return httpx.Response(
|
|
269
|
+
200, json={"success": True, "data": {}}, headers={"x-request-id": "r1"}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
client = make_client(handler)
|
|
273
|
+
client.scrape("https://a.com")
|
|
274
|
+
assert client.last_request_id == "r1"
|
|
275
|
+
|
|
276
|
+
out = client.rate_result(
|
|
277
|
+
"bad", request_id=client.last_request_id, reasons=["missing_content"], note="no prices"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
assert out == {"id": "f1", "requestId": "r1", "rating": "bad"}
|
|
281
|
+
assert json.loads(seen[-1].content) == {
|
|
282
|
+
"requestId": "r1",
|
|
283
|
+
"rating": "bad",
|
|
284
|
+
"reasons": ["missing_content"],
|
|
285
|
+
"note": "no prices",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_rate_result_can_rate_by_url_and_needs_a_target() -> None:
|
|
290
|
+
rec = Recorder({"success": True, "data": {"id": "f2", "requestId": "r2", "rating": "good"}})
|
|
291
|
+
client = make_client(rec)
|
|
292
|
+
client.rate_result("good", url="https://a.com")
|
|
293
|
+
assert json.loads(rec.last.content) == {"url": "https://a.com", "rating": "good"}
|
|
294
|
+
with pytest.raises(HydrafetchError):
|
|
295
|
+
client.rate_result("good")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|