hydrafetch 0.3.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.3.0 → hydrafetch-0.4.0}/PKG-INFO +21 -2
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/README.md +20 -1
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/pyproject.toml +1 -1
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/src/hydrafetch/__init__.py +1 -1
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/src/hydrafetch/_client.py +51 -0
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/tests/test_client.py +37 -0
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/uv.lock +1 -1
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/.github/workflows/ci.yml +0 -0
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/.github/workflows/publish.yml +0 -0
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/.gitignore +0 -0
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/LICENSE +0 -0
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/src/hydrafetch/_schema.py +0 -0
- {hydrafetch-0.3.0 → hydrafetch-0.4.0}/src/hydrafetch/errors.py +0 -0
- {hydrafetch-0.3.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
|
|
|
@@ -176,6 +176,24 @@ hf.styleguide("https://stripe.com/pricing", maxAge=0) # one page, read fresh
|
|
|
176
176
|
|
|
177
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.
|
|
178
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
|
+
|
|
179
197
|
## Async
|
|
180
198
|
|
|
181
199
|
`AsyncHydrafetch` mirrors the same surface. Use it when several calls can run concurrently.
|
|
@@ -262,6 +280,7 @@ hf = Hydrafetch(
|
|
|
262
280
|
| `batch(urls, **options)` | job dict, polled to completion | 1 per page |
|
|
263
281
|
| `start_crawl`, `start_batch` | job id `str` | 1 per page |
|
|
264
282
|
| `crawl_status(id)`, `batch_status(id)` | job dict | free |
|
|
283
|
+
| `rate_result(rating, request_id=, url=, reasons=, note=)` | rating dict | free |
|
|
265
284
|
|
|
266
285
|
Failed requests are not billed. Pricing does not vary with page difficulty, so there is no render, stealth or proxy option to set.
|
|
267
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
|
|
|
@@ -153,6 +153,24 @@ hf.styleguide("https://stripe.com/pricing", maxAge=0) # one page, read fresh
|
|
|
153
153
|
|
|
154
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.
|
|
155
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
|
+
|
|
156
174
|
## Async
|
|
157
175
|
|
|
158
176
|
`AsyncHydrafetch` mirrors the same surface. Use it when several calls can run concurrently.
|
|
@@ -239,6 +257,7 @@ hf = Hydrafetch(
|
|
|
239
257
|
| `batch(urls, **options)` | job dict, polled to completion | 1 per page |
|
|
240
258
|
| `start_crawl`, `start_batch` | job id `str` | 1 per page |
|
|
241
259
|
| `crawl_status(id)`, `batch_status(id)` | job dict | free |
|
|
260
|
+
| `rate_result(rating, request_id=, url=, reasons=, note=)` | rating dict | free |
|
|
242
261
|
|
|
243
262
|
Failed requests are not billed. Pricing does not vary with page difficulty, so there is no render, stealth or proxy option to set.
|
|
244
263
|
|
|
@@ -88,6 +88,31 @@ 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)}
|
|
@@ -150,6 +175,7 @@ class Hydrafetch(_Base):
|
|
|
150
175
|
"or use the crawl and batch job endpoints for long work."
|
|
151
176
|
) from exc
|
|
152
177
|
|
|
178
|
+
self.last_request_id = response.headers.get("x-request-id") or self.last_request_id
|
|
153
179
|
try:
|
|
154
180
|
return _unwrap(response)
|
|
155
181
|
except HydrafetchError as err:
|
|
@@ -162,6 +188,18 @@ class Hydrafetch(_Base):
|
|
|
162
188
|
def scrape(self, url: str, **options: Any) -> dict[str, Any]:
|
|
163
189
|
return self._request("POST", "/v1/web/scrape", json=self._scrape_body(url, options))
|
|
164
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
|
+
|
|
165
203
|
def map(self, url: str, **options: Any) -> dict[str, Any]:
|
|
166
204
|
return self._request("POST", "/v1/web/map", json=self._scrape_body(url, options))
|
|
167
205
|
|
|
@@ -295,6 +333,7 @@ class AsyncHydrafetch(_Base):
|
|
|
295
333
|
"or use the crawl and batch job endpoints for long work."
|
|
296
334
|
) from exc
|
|
297
335
|
|
|
336
|
+
self.last_request_id = response.headers.get("x-request-id") or self.last_request_id
|
|
298
337
|
try:
|
|
299
338
|
return _unwrap(response)
|
|
300
339
|
except HydrafetchError as err:
|
|
@@ -307,6 +346,18 @@ class AsyncHydrafetch(_Base):
|
|
|
307
346
|
async def scrape(self, url: str, **options: Any) -> dict[str, Any]:
|
|
308
347
|
return await self._request("POST", "/v1/web/scrape", json=self._scrape_body(url, options))
|
|
309
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
|
+
|
|
310
361
|
async def map(self, url: str, **options: Any) -> dict[str, Any]:
|
|
311
362
|
return await self._request("POST", "/v1/web/map", json=self._scrape_body(url, options))
|
|
312
363
|
|
|
@@ -256,3 +256,40 @@ def test_extract_rejects_a_recursive_model_rather_than_sending_a_ref():
|
|
|
256
256
|
with pytest.raises(HydrafetchError) as err:
|
|
257
257
|
client.extract("https://example.com", schema=Node)
|
|
258
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
|