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.
- webfetch_io-1.0.0/LICENSE +21 -0
- webfetch_io-1.0.0/PKG-INFO +79 -0
- webfetch_io-1.0.0/README.md +60 -0
- webfetch_io-1.0.0/pyproject.toml +27 -0
- webfetch_io-1.0.0/setup.cfg +4 -0
- webfetch_io-1.0.0/webfetch/__init__.py +20 -0
- webfetch_io-1.0.0/webfetch/client.py +261 -0
- webfetch_io-1.0.0/webfetch_io.egg-info/PKG-INFO +79 -0
- webfetch_io-1.0.0/webfetch_io.egg-info/SOURCES.txt +10 -0
- webfetch_io-1.0.0/webfetch_io.egg-info/dependency_links.txt +1 -0
- webfetch_io-1.0.0/webfetch_io.egg-info/requires.txt +1 -0
- webfetch_io-1.0.0/webfetch_io.egg-info/top_level.txt +1 -0
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.24
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
webfetch
|