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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hydrafetch
3
- Version: 0.2.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
- Create a key at [app.hydrafetch.com](https://app.hydrafetch.com). The constructor reads `HYDRAFETCH_API_KEY` when no key is passed.
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(domain)` | design system dict | 10 |
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
- Create a key at [app.hydrafetch.com](https://app.hydrafetch.com). The constructor reads `HYDRAFETCH_API_KEY` when no key is passed.
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(domain)` | design system dict | 10 |
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.2.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
@@ -4,4 +4,4 @@ from ._client import AsyncHydrafetch, Hydrafetch
4
4
  from .errors import HydrafetchError, HydrafetchTimeout
5
5
 
6
6
  __all__ = ["AsyncHydrafetch", "Hydrafetch", "HydrafetchError", "HydrafetchTimeout"]
7
- __version__ = "0.1.0"
7
+ __version__ = "0.4.0"
@@ -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, domain: str) -> dict[str, Any]:
177
- return self._request("GET", "/v1/web/styleguide", params={"domain": domain})
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, domain: str) -> dict[str, Any]:
326
- return await self._request("GET", "/v1/web/styleguide", params={"domain": domain})
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 | None = None
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")
@@ -143,7 +143,7 @@ wheels = [
143
143
 
144
144
  [[package]]
145
145
  name = "hydrafetch"
146
- version = "0.2.0"
146
+ version = "0.4.0"
147
147
  source = { editable = "." }
148
148
  dependencies = [
149
149
  { name = "httpx" },
File without changes
File without changes