webfetch-io 1.0.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
+ MIT License
2
+
3
+ Copyright (c) 2026 Tuxxin
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,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: webfetch-io
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the webfetch.io scraping API.
5
+ Author: Tuxxin
6
+ License: MIT
7
+ Project-URL: Documentation, https://webfetch.io/documentation
8
+ Project-URL: Source, https://github.com/tuxxin/webfetch-sdk
9
+ Keywords: scraping,scraper,crawler,headless,extraction,webfetch
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.24
18
+ Dynamic: license-file
19
+
20
+ # webfetch-io
21
+
22
+ Official Python client for the [webfetch.io](https://webfetch.io) scraping API.
23
+ Python 3.9+, sync and async, one dependency (`httpx`).
24
+
25
+ ```bash
26
+ pip install webfetch-io
27
+ ```
28
+
29
+ ## Use
30
+
31
+ ```python
32
+ from webfetch import WebfetchClient
33
+
34
+ client = WebfetchClient("wfk_...") # required; free key at webfetch.io/signup
35
+
36
+ md = client.markdown("https://example.com")
37
+
38
+ data = client.extract("https://quotes.toscrape.com/js/", {
39
+ "quotes": {
40
+ "selector": ".quote",
41
+ "type": "list",
42
+ "output": {"text": ".text", "author": ".author"},
43
+ },
44
+ })
45
+
46
+ result = client.fetch("https://example.com", render_js=False, block_ads=True)
47
+ print(result.status, result.credits, result.resolved_url)
48
+ ```
49
+
50
+ Async is the same surface, awaited:
51
+
52
+ ```python
53
+ from webfetch import AsyncWebfetchClient
54
+
55
+ async with AsyncWebfetchClient("wfk_...") as client:
56
+ result = await client.fetch("https://example.com", render_js=False)
57
+ ```
58
+
59
+ ## Errors
60
+
61
+ ```python
62
+ from webfetch import WebfetchApiError, WebfetchRateLimitError
63
+
64
+ try:
65
+ client.fetch(url)
66
+ except WebfetchRateLimitError as e:
67
+ time.sleep(e.retry_after or 5)
68
+ except WebfetchApiError as e:
69
+ print(e.status, e.code) # e.code is stable and safe to branch on
70
+ ```
71
+
72
+ A failed call is never billed.
73
+
74
+ ## Credits
75
+
76
+ `render_js` defaults to **True**, which costs 5 credits. Pass `render_js=False`
77
+ for a plain HTTP fetch at 1 credit. `result.credits` reports what was charged.
78
+
79
+ MIT licensed. Full reference at <https://webfetch.io/documentation>.
@@ -0,0 +1,60 @@
1
+ # webfetch-io
2
+
3
+ Official Python client for the [webfetch.io](https://webfetch.io) scraping API.
4
+ Python 3.9+, sync and async, one dependency (`httpx`).
5
+
6
+ ```bash
7
+ pip install webfetch-io
8
+ ```
9
+
10
+ ## Use
11
+
12
+ ```python
13
+ from webfetch import WebfetchClient
14
+
15
+ client = WebfetchClient("wfk_...") # required; free key at webfetch.io/signup
16
+
17
+ md = client.markdown("https://example.com")
18
+
19
+ data = client.extract("https://quotes.toscrape.com/js/", {
20
+ "quotes": {
21
+ "selector": ".quote",
22
+ "type": "list",
23
+ "output": {"text": ".text", "author": ".author"},
24
+ },
25
+ })
26
+
27
+ result = client.fetch("https://example.com", render_js=False, block_ads=True)
28
+ print(result.status, result.credits, result.resolved_url)
29
+ ```
30
+
31
+ Async is the same surface, awaited:
32
+
33
+ ```python
34
+ from webfetch import AsyncWebfetchClient
35
+
36
+ async with AsyncWebfetchClient("wfk_...") as client:
37
+ result = await client.fetch("https://example.com", render_js=False)
38
+ ```
39
+
40
+ ## Errors
41
+
42
+ ```python
43
+ from webfetch import WebfetchApiError, WebfetchRateLimitError
44
+
45
+ try:
46
+ client.fetch(url)
47
+ except WebfetchRateLimitError as e:
48
+ time.sleep(e.retry_after or 5)
49
+ except WebfetchApiError as e:
50
+ print(e.status, e.code) # e.code is stable and safe to branch on
51
+ ```
52
+
53
+ A failed call is never billed.
54
+
55
+ ## Credits
56
+
57
+ `render_js` defaults to **True**, which costs 5 credits. Pass `render_js=False`
58
+ for a plain HTTP fetch at 1 credit. `result.credits` reports what was charged.
59
+
60
+ MIT licensed. Full reference at <https://webfetch.io/documentation>.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "webfetch-io"
7
+ version = "1.0.0"
8
+ description = "Official Python client for the webfetch.io scraping API."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Tuxxin" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["httpx>=0.24"]
14
+ keywords = ["scraping", "scraper", "crawler", "headless", "extraction", "webfetch"]
15
+ classifiers = [
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ ]
21
+
22
+ [project.urls]
23
+ Documentation = "https://webfetch.io/documentation"
24
+ Source = "https://github.com/tuxxin/webfetch-sdk"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["webfetch*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """Official Python client for the webfetch.io scraping API."""
2
+
3
+ from .client import (
4
+ WebfetchClient,
5
+ AsyncWebfetchClient,
6
+ WebfetchResult,
7
+ WebfetchError,
8
+ WebfetchApiError,
9
+ WebfetchRateLimitError,
10
+ )
11
+
12
+ __all__ = [
13
+ "WebfetchClient",
14
+ "AsyncWebfetchClient",
15
+ "WebfetchResult",
16
+ "WebfetchError",
17
+ "WebfetchApiError",
18
+ "WebfetchRateLimitError",
19
+ ]
20
+ __version__ = "1.0.0"
@@ -0,0 +1,261 @@
1
+ """
2
+ Sync and async clients for the webfetch.io scraping API.
3
+
4
+ The API is ScrapingBee-compatible, so parameter names, defaults and credit costs
5
+ match. Keyword arguments are accepted in Python's snake_case, which is also the
6
+ wire format, so nothing is translated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json as _json
12
+ from typing import Any, Dict, Mapping, Optional
13
+
14
+ import httpx
15
+
16
+ DEFAULT_BASE_URL = "https://api.webfetch.io/v1/"
17
+ DEFAULT_TIMEOUT = 180.0
18
+
19
+ # Sent as stringified JSON rather than as a nested query structure.
20
+ _JSON_PARAMS = ("extract_rules", "js_scenario")
21
+
22
+
23
+ class WebfetchError(Exception):
24
+ """Anything went wrong."""
25
+
26
+
27
+ class WebfetchApiError(WebfetchError):
28
+ """The API answered with an error status. Never billed."""
29
+
30
+ def __init__(self, message: str, status: int, code: Optional[str] = None, body: str = ""):
31
+ super().__init__(message)
32
+ self.status = status
33
+ #: A stable machine-readable code, worth branching on.
34
+ self.code = code
35
+ self.body = body
36
+
37
+
38
+ class WebfetchRateLimitError(WebfetchApiError):
39
+ """429 (the per-minute rate or the concurrency cap), or 503 with Retry-After
40
+ (free capacity is busy, code free_tier_busy). Neither is billed; wait and retry."""
41
+
42
+ def __init__(self, message: str, status: int, code=None, body: str = "",
43
+ retry_after: Optional[int] = None):
44
+ super().__init__(message, status, code, body)
45
+ #: Seconds to wait, from Retry-After. None if the header was absent.
46
+ self.retry_after = retry_after
47
+
48
+
49
+ class WebfetchResult:
50
+ """A successful call."""
51
+
52
+ __slots__ = ("content", "credits", "status", "resolved_url", "headers",
53
+ "proxy_pool", "proxy_downgraded", "screenshot_url", "tag")
54
+
55
+ def __init__(self, response: httpx.Response, requested_url: str):
56
+ h = response.headers
57
+ self.content: str = response.text
58
+ #: Credits actually charged, from Wf-Cost.
59
+ self.credits: int = int(h.get("Wf-Cost", 0) or 0)
60
+ #: The status the target returned, from Wf-Initial-Status-Code.
61
+ self.status: int = int(h.get("Wf-Initial-Status-Code", response.status_code) or 0)
62
+ self.resolved_url: str = h.get("Wf-Resolved-Url") or requested_url
63
+ self.headers: Dict[str, str] = dict(h)
64
+ self.proxy_pool: Optional[str] = h.get("Wf-Proxy-Pool")
65
+ #: True when the requested proxy pool was unavailable and another pool served it.
66
+ #: ``credits`` says what was charged.
67
+ self.proxy_downgraded: bool = h.get("Wf-Proxy-Downgraded") == "true"
68
+ self.screenshot_url: Optional[str] = h.get("Wf-Screenshot-Url")
69
+ self.tag: Optional[str] = h.get("Wf-Tag")
70
+
71
+ def json(self) -> Any:
72
+ """Parse the body as JSON. Useful with extract_rules or json_response."""
73
+ try:
74
+ return _json.loads(self.content)
75
+ except ValueError as exc:
76
+ raise WebfetchError(f"The response body is not JSON: {exc}") from exc
77
+
78
+ def __str__(self) -> str:
79
+ return self.content
80
+
81
+ def __repr__(self) -> str:
82
+ return (f"<WebfetchResult status={self.status} credits={self.credits} "
83
+ f"bytes={len(self.content)} url={self.resolved_url!r}>")
84
+
85
+
86
+ def _build(url: str, options: Mapping[str, Any]) -> Dict[str, str]:
87
+ if not url or not isinstance(url, str):
88
+ raise WebfetchError("url is required and must be a string")
89
+ params: Dict[str, str] = {"url": url}
90
+ for key, value in options.items():
91
+ if value is None or key == "headers":
92
+ continue
93
+ if key in _JSON_PARAMS and isinstance(value, (dict, list)):
94
+ params[key] = _json.dumps(value, separators=(",", ":"))
95
+ elif isinstance(value, bool):
96
+ params[key] = "true" if value else "false"
97
+ else:
98
+ params[key] = str(value)
99
+ return params
100
+
101
+
102
+ def _headers(api_key: Optional[str], options: Mapping[str, Any]) -> Dict[str, str]:
103
+ # Fail here rather than spending a round trip on a request that cannot
104
+ # succeed: the API has no keyless tier.
105
+ if not api_key:
106
+ raise WebfetchError(
107
+ "An API key is required. Get a free one at https://webfetch.io/signup"
108
+ )
109
+ out = {"Accept": "*/*", "Authorization": f"Bearer {api_key}"}
110
+ # Forwarded headers carry a Wf- prefix, stripped before they reach the target.
111
+ for k, v in (options.get("headers") or {}).items():
112
+ out[f"Wf-{k}"] = str(v)
113
+ return out
114
+
115
+
116
+ def _raise_for_error(response: httpx.Response) -> None:
117
+ if response.status_code < 400:
118
+ return
119
+ body = response.text
120
+ code = None
121
+ message = body
122
+ try:
123
+ parsed = _json.loads(body)
124
+ code = parsed.get("code")
125
+ message = parsed.get("error", body)
126
+ except ValueError:
127
+ pass
128
+ ra = response.headers.get("Retry-After")
129
+ if response.status_code == 429 or (response.status_code == 503 and ra):
130
+ raise WebfetchRateLimitError(message, response.status_code, code, body,
131
+ int(ra) if ra and ra.isdigit() else None)
132
+ raise WebfetchApiError(message, response.status_code, code, body)
133
+
134
+
135
+ class WebfetchClient:
136
+ """Synchronous client.
137
+
138
+ >>> client = WebfetchClient("wfk_...")
139
+ >>> result = client.fetch("https://example.com", render_js=False)
140
+ >>> result.credits
141
+ 1
142
+ """
143
+
144
+ def __init__(self, api_key: Optional[str] = None, *,
145
+ base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT,
146
+ client: Optional[httpx.Client] = None):
147
+ self.api_key = api_key
148
+ self.base_url = base_url if base_url.endswith("/") else base_url + "/"
149
+ self.timeout = timeout
150
+ self._client = client or httpx.Client(timeout=timeout, follow_redirects=False)
151
+ self._owns_client = client is None
152
+
153
+ def fetch(self, url: str, **options: Any) -> WebfetchResult:
154
+ """Fetch a URL. Keyword arguments are the documented API parameters."""
155
+ params = _build(url, options)
156
+ if options.get("headers"):
157
+ params["forward_headers"] = "true"
158
+ try:
159
+ response = self._client.get(self.base_url, params=params,
160
+ headers=_headers(self.api_key, options))
161
+ except httpx.TimeoutException as exc:
162
+ raise WebfetchError(f"The request timed out after {self.timeout}s") from exc
163
+ except httpx.HTTPError as exc:
164
+ raise WebfetchError(f"The request failed: {exc}") from exc
165
+ _raise_for_error(response)
166
+ return WebfetchResult(response, url)
167
+
168
+ def markdown(self, url: str, **options: Any) -> str:
169
+ """The page content as markdown, with the chrome stripped."""
170
+ options["return_page_markdown"] = True
171
+ return self.fetch(url, **options).content
172
+
173
+ def text(self, url: str, **options: Any) -> str:
174
+ """The page content as plain text."""
175
+ options["return_page_text"] = True
176
+ return self.fetch(url, **options).content
177
+
178
+ def extract(self, url: str, rules: Mapping[str, Any], **options: Any) -> Any:
179
+ """Run extract_rules and return the parsed JSON."""
180
+ options["extract_rules"] = dict(rules)
181
+ return self.fetch(url, **options).json()
182
+
183
+ def usage(self) -> Dict[str, Any]:
184
+ """Current credit and concurrency state. Requires a key."""
185
+ if not self.api_key:
186
+ raise WebfetchError("usage() requires an API key")
187
+ response = self._client.get(self.base_url + "usage",
188
+ headers={"Authorization": f"Bearer {self.api_key}"})
189
+ _raise_for_error(response)
190
+ return response.json()
191
+
192
+ def close(self) -> None:
193
+ if self._owns_client:
194
+ self._client.close()
195
+
196
+ def __enter__(self) -> "WebfetchClient":
197
+ return self
198
+
199
+ def __exit__(self, *exc: Any) -> None:
200
+ self.close()
201
+
202
+
203
+ class AsyncWebfetchClient:
204
+ """Asynchronous client. The same surface, awaited.
205
+
206
+ >>> async with AsyncWebfetchClient("wfk_...") as client:
207
+ ... result = await client.fetch("https://example.com", render_js=False)
208
+ """
209
+
210
+ def __init__(self, api_key: Optional[str] = None, *,
211
+ base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT,
212
+ client: Optional[httpx.AsyncClient] = None):
213
+ self.api_key = api_key
214
+ self.base_url = base_url if base_url.endswith("/") else base_url + "/"
215
+ self.timeout = timeout
216
+ self._client = client or httpx.AsyncClient(timeout=timeout, follow_redirects=False)
217
+ self._owns_client = client is None
218
+
219
+ async def fetch(self, url: str, **options: Any) -> WebfetchResult:
220
+ params = _build(url, options)
221
+ if options.get("headers"):
222
+ params["forward_headers"] = "true"
223
+ try:
224
+ response = await self._client.get(self.base_url, params=params,
225
+ headers=_headers(self.api_key, options))
226
+ except httpx.TimeoutException as exc:
227
+ raise WebfetchError(f"The request timed out after {self.timeout}s") from exc
228
+ except httpx.HTTPError as exc:
229
+ raise WebfetchError(f"The request failed: {exc}") from exc
230
+ _raise_for_error(response)
231
+ return WebfetchResult(response, url)
232
+
233
+ async def markdown(self, url: str, **options: Any) -> str:
234
+ options["return_page_markdown"] = True
235
+ return (await self.fetch(url, **options)).content
236
+
237
+ async def text(self, url: str, **options: Any) -> str:
238
+ options["return_page_text"] = True
239
+ return (await self.fetch(url, **options)).content
240
+
241
+ async def extract(self, url: str, rules: Mapping[str, Any], **options: Any) -> Any:
242
+ options["extract_rules"] = dict(rules)
243
+ return (await self.fetch(url, **options)).json()
244
+
245
+ async def usage(self) -> Dict[str, Any]:
246
+ if not self.api_key:
247
+ raise WebfetchError("usage() requires an API key")
248
+ response = await self._client.get(self.base_url + "usage",
249
+ headers={"Authorization": f"Bearer {self.api_key}"})
250
+ _raise_for_error(response)
251
+ return response.json()
252
+
253
+ async def aclose(self) -> None:
254
+ if self._owns_client:
255
+ await self._client.aclose()
256
+
257
+ async def __aenter__(self) -> "AsyncWebfetchClient":
258
+ return self
259
+
260
+ async def __aexit__(self, *exc: Any) -> None:
261
+ await self.aclose()
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: webfetch-io
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the webfetch.io scraping API.
5
+ Author: Tuxxin
6
+ License: MIT
7
+ Project-URL: Documentation, https://webfetch.io/documentation
8
+ Project-URL: Source, https://github.com/tuxxin/webfetch-sdk
9
+ Keywords: scraping,scraper,crawler,headless,extraction,webfetch
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.24
18
+ Dynamic: license-file
19
+
20
+ # webfetch-io
21
+
22
+ Official Python client for the [webfetch.io](https://webfetch.io) scraping API.
23
+ Python 3.9+, sync and async, one dependency (`httpx`).
24
+
25
+ ```bash
26
+ pip install webfetch-io
27
+ ```
28
+
29
+ ## Use
30
+
31
+ ```python
32
+ from webfetch import WebfetchClient
33
+
34
+ client = WebfetchClient("wfk_...") # required; free key at webfetch.io/signup
35
+
36
+ md = client.markdown("https://example.com")
37
+
38
+ data = client.extract("https://quotes.toscrape.com/js/", {
39
+ "quotes": {
40
+ "selector": ".quote",
41
+ "type": "list",
42
+ "output": {"text": ".text", "author": ".author"},
43
+ },
44
+ })
45
+
46
+ result = client.fetch("https://example.com", render_js=False, block_ads=True)
47
+ print(result.status, result.credits, result.resolved_url)
48
+ ```
49
+
50
+ Async is the same surface, awaited:
51
+
52
+ ```python
53
+ from webfetch import AsyncWebfetchClient
54
+
55
+ async with AsyncWebfetchClient("wfk_...") as client:
56
+ result = await client.fetch("https://example.com", render_js=False)
57
+ ```
58
+
59
+ ## Errors
60
+
61
+ ```python
62
+ from webfetch import WebfetchApiError, WebfetchRateLimitError
63
+
64
+ try:
65
+ client.fetch(url)
66
+ except WebfetchRateLimitError as e:
67
+ time.sleep(e.retry_after or 5)
68
+ except WebfetchApiError as e:
69
+ print(e.status, e.code) # e.code is stable and safe to branch on
70
+ ```
71
+
72
+ A failed call is never billed.
73
+
74
+ ## Credits
75
+
76
+ `render_js` defaults to **True**, which costs 5 credits. Pass `render_js=False`
77
+ for a plain HTTP fetch at 1 credit. `result.credits` reports what was charged.
78
+
79
+ MIT licensed. Full reference at <https://webfetch.io/documentation>.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ webfetch/__init__.py
5
+ webfetch/client.py
6
+ webfetch_io.egg-info/PKG-INFO
7
+ webfetch_io.egg-info/SOURCES.txt
8
+ webfetch_io.egg-info/dependency_links.txt
9
+ webfetch_io.egg-info/requires.txt
10
+ webfetch_io.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx>=0.24
@@ -0,0 +1 @@
1
+ webfetch