scrapy-crawio 1.1.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.
- scrapy_crawio-1.1.0/LICENSE +21 -0
- scrapy_crawio-1.1.0/PKG-INFO +125 -0
- scrapy_crawio-1.1.0/README.md +104 -0
- scrapy_crawio-1.1.0/pyproject.toml +35 -0
- scrapy_crawio-1.1.0/setup.cfg +4 -0
- scrapy_crawio-1.1.0/src/scrapy_crawio/__init__.py +5 -0
- scrapy_crawio-1.1.0/src/scrapy_crawio/middleware.py +161 -0
- scrapy_crawio-1.1.0/src/scrapy_crawio.egg-info/PKG-INFO +125 -0
- scrapy_crawio-1.1.0/src/scrapy_crawio.egg-info/SOURCES.txt +10 -0
- scrapy_crawio-1.1.0/src/scrapy_crawio.egg-info/dependency_links.txt +1 -0
- scrapy_crawio-1.1.0/src/scrapy_crawio.egg-info/requires.txt +2 -0
- scrapy_crawio-1.1.0/src/scrapy_crawio.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Crawio
|
|
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,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scrapy-crawio
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Scrapy downloader middleware for Crawio: route requests through the Crawio API, with proxies and anti-bot handling included.
|
|
5
|
+
Author-email: Crawio <support@crawio.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://crawio.com
|
|
8
|
+
Project-URL: Documentation, https://crawio.com/docs
|
|
9
|
+
Keywords: scrapy,scraping,proxy,crawler,anti-bot,crawio
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Scrapy
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: scrapy>=2.6
|
|
19
|
+
Requires-Dist: requests>=2.25
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# scrapy-crawio
|
|
23
|
+
|
|
24
|
+
Scrapy downloader middleware for [Crawio](https://crawio.com).
|
|
25
|
+
|
|
26
|
+
Your spiders make normal requests; this middleware routes them through the Crawio API, which
|
|
27
|
+
fetches each one from a healthy exit — using the cheapest route that works, up to a full browser
|
|
28
|
+
when the site's anti-bot needs one — and returns the site's own response. No spider changes, no
|
|
29
|
+
per-request proxy juggling. Only successful requests are billed.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install scrapy-crawio
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configure (`settings.py`)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
DOWNLOADER_MIDDLEWARES = {
|
|
41
|
+
"scrapy_crawio.CrawioMiddleware": 1000,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
CRAWIO_API_KEY = "rk-..." # required — dashboard -> API Key
|
|
45
|
+
CRAWIO_DOMAINS = ["example.com"] # hosts to route; omit to route every request
|
|
46
|
+
CRAWIO_API_URL = "https://api.crawio.com" # optional
|
|
47
|
+
CRAWIO_TIMEOUT = 120 # seconds, optional
|
|
48
|
+
CRAWIO_SESSION = "job-1" # optional: default sticky session id
|
|
49
|
+
|
|
50
|
+
# Recommended: let Scrapy retry the transient answers.
|
|
51
|
+
RETRY_ENABLED = True
|
|
52
|
+
RETRY_HTTP_CODES = [429, 502, 503, 504]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
Write spiders exactly as normal — the matched hosts go through Crawio:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import scrapy
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BooksSpider(scrapy.Spider):
|
|
64
|
+
name = "books"
|
|
65
|
+
|
|
66
|
+
def start_requests(self):
|
|
67
|
+
yield scrapy.Request("https://example.com/catalogue/page-1.html", callback=self.parse)
|
|
68
|
+
|
|
69
|
+
def parse(self, response):
|
|
70
|
+
self.logger.info("fetched in %s ms", response.headers.get("X-Crawio-Ms", b"").decode())
|
|
71
|
+
for href in response.css("h3 a::attr(href)").getall():
|
|
72
|
+
yield response.follow(href, callback=self.parse_item)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A page or JSON arrives as a `TextResponse`. A file (PDF, image, archive) arrives as a plain
|
|
76
|
+
`scrapy.http.Response` with its original bytes in `response.body`.
|
|
77
|
+
|
|
78
|
+
## Response headers
|
|
79
|
+
|
|
80
|
+
Every response through Crawio carries these, alongside the site's own headers:
|
|
81
|
+
|
|
82
|
+
| Header | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `X-Crawio-Request-Id` | The request's reference (`req_...`). Quote it when you contact support. |
|
|
85
|
+
| `X-Crawio-Cost` | Credits this request used: `1`, or `0` when it was not billed (blocks, errors, timeouts). |
|
|
86
|
+
| `X-Crawio-Ms` | Crawio's own time for the fetch, in milliseconds. |
|
|
87
|
+
|
|
88
|
+
Error responses (429, 502, 503, 504) carry `X-Crawio-Request-Id` and `X-Crawio-Cost` too, and a 429
|
|
89
|
+
carries `Retry-After`.
|
|
90
|
+
|
|
91
|
+
## Per-request overrides
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
scrapy.Request(url, meta={"crawio_skip": True}) # download this one directly, not through us
|
|
95
|
+
scrapy.Request(url, meta={"crawio_session": "job-2"}) # pin to a sticky session
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A sticky session keeps the same exit and the same cookies across requests: sign in once, reuse the
|
|
99
|
+
id, and the following requests stay signed in.
|
|
100
|
+
|
|
101
|
+
## Retries and billing
|
|
102
|
+
|
|
103
|
+
Every request carries an `Idempotency-Key`, so a retry reattaches to the same job instead of
|
|
104
|
+
starting — and billing — a second one. The key is stamped on the `Request`, so Scrapy's own
|
|
105
|
+
`RetryMiddleware` reuses it.
|
|
106
|
+
|
|
107
|
+
## What Crawio answers
|
|
108
|
+
|
|
109
|
+
| code | meaning |
|
|
110
|
+
|------|---------|
|
|
111
|
+
| 200 | success — the site's response is in the body |
|
|
112
|
+
| 401 | invalid API key; the request is dropped, not retried |
|
|
113
|
+
| 403 | account suspended; fix the account, do not retry |
|
|
114
|
+
| 410 | a strict sticky session lost its exit — sign in again on a new session |
|
|
115
|
+
| 429 | a plan limit: the body names which (`rate_limited`, `concurrency_limit`, `daily_quota_exceeded`, `monthly_quota_exceeded`, `credits_used_up`) and `Retry-After` says when, where the wait is known |
|
|
116
|
+
| 502 | the site blocked the request behind its anti-bot |
|
|
117
|
+
| 503 | no capacity right now; retry |
|
|
118
|
+
| 504 | the fetch timed out |
|
|
119
|
+
|
|
120
|
+
Everything except 401 comes back as a real `Response`, so `RetryMiddleware` decides what to do with
|
|
121
|
+
it and the body tells you why.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# scrapy-crawio
|
|
2
|
+
|
|
3
|
+
Scrapy downloader middleware for [Crawio](https://crawio.com).
|
|
4
|
+
|
|
5
|
+
Your spiders make normal requests; this middleware routes them through the Crawio API, which
|
|
6
|
+
fetches each one from a healthy exit — using the cheapest route that works, up to a full browser
|
|
7
|
+
when the site's anti-bot needs one — and returns the site's own response. No spider changes, no
|
|
8
|
+
per-request proxy juggling. Only successful requests are billed.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install scrapy-crawio
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Configure (`settings.py`)
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
DOWNLOADER_MIDDLEWARES = {
|
|
20
|
+
"scrapy_crawio.CrawioMiddleware": 1000,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
CRAWIO_API_KEY = "rk-..." # required — dashboard -> API Key
|
|
24
|
+
CRAWIO_DOMAINS = ["example.com"] # hosts to route; omit to route every request
|
|
25
|
+
CRAWIO_API_URL = "https://api.crawio.com" # optional
|
|
26
|
+
CRAWIO_TIMEOUT = 120 # seconds, optional
|
|
27
|
+
CRAWIO_SESSION = "job-1" # optional: default sticky session id
|
|
28
|
+
|
|
29
|
+
# Recommended: let Scrapy retry the transient answers.
|
|
30
|
+
RETRY_ENABLED = True
|
|
31
|
+
RETRY_HTTP_CODES = [429, 502, 503, 504]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
Write spiders exactly as normal — the matched hosts go through Crawio:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import scrapy
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BooksSpider(scrapy.Spider):
|
|
43
|
+
name = "books"
|
|
44
|
+
|
|
45
|
+
def start_requests(self):
|
|
46
|
+
yield scrapy.Request("https://example.com/catalogue/page-1.html", callback=self.parse)
|
|
47
|
+
|
|
48
|
+
def parse(self, response):
|
|
49
|
+
self.logger.info("fetched in %s ms", response.headers.get("X-Crawio-Ms", b"").decode())
|
|
50
|
+
for href in response.css("h3 a::attr(href)").getall():
|
|
51
|
+
yield response.follow(href, callback=self.parse_item)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A page or JSON arrives as a `TextResponse`. A file (PDF, image, archive) arrives as a plain
|
|
55
|
+
`scrapy.http.Response` with its original bytes in `response.body`.
|
|
56
|
+
|
|
57
|
+
## Response headers
|
|
58
|
+
|
|
59
|
+
Every response through Crawio carries these, alongside the site's own headers:
|
|
60
|
+
|
|
61
|
+
| Header | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `X-Crawio-Request-Id` | The request's reference (`req_...`). Quote it when you contact support. |
|
|
64
|
+
| `X-Crawio-Cost` | Credits this request used: `1`, or `0` when it was not billed (blocks, errors, timeouts). |
|
|
65
|
+
| `X-Crawio-Ms` | Crawio's own time for the fetch, in milliseconds. |
|
|
66
|
+
|
|
67
|
+
Error responses (429, 502, 503, 504) carry `X-Crawio-Request-Id` and `X-Crawio-Cost` too, and a 429
|
|
68
|
+
carries `Retry-After`.
|
|
69
|
+
|
|
70
|
+
## Per-request overrides
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
scrapy.Request(url, meta={"crawio_skip": True}) # download this one directly, not through us
|
|
74
|
+
scrapy.Request(url, meta={"crawio_session": "job-2"}) # pin to a sticky session
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A sticky session keeps the same exit and the same cookies across requests: sign in once, reuse the
|
|
78
|
+
id, and the following requests stay signed in.
|
|
79
|
+
|
|
80
|
+
## Retries and billing
|
|
81
|
+
|
|
82
|
+
Every request carries an `Idempotency-Key`, so a retry reattaches to the same job instead of
|
|
83
|
+
starting — and billing — a second one. The key is stamped on the `Request`, so Scrapy's own
|
|
84
|
+
`RetryMiddleware` reuses it.
|
|
85
|
+
|
|
86
|
+
## What Crawio answers
|
|
87
|
+
|
|
88
|
+
| code | meaning |
|
|
89
|
+
|------|---------|
|
|
90
|
+
| 200 | success — the site's response is in the body |
|
|
91
|
+
| 401 | invalid API key; the request is dropped, not retried |
|
|
92
|
+
| 403 | account suspended; fix the account, do not retry |
|
|
93
|
+
| 410 | a strict sticky session lost its exit — sign in again on a new session |
|
|
94
|
+
| 429 | a plan limit: the body names which (`rate_limited`, `concurrency_limit`, `daily_quota_exceeded`, `monthly_quota_exceeded`, `credits_used_up`) and `Retry-After` says when, where the wait is known |
|
|
95
|
+
| 502 | the site blocked the request behind its anti-bot |
|
|
96
|
+
| 503 | no capacity right now; retry |
|
|
97
|
+
| 504 | the fetch timed out |
|
|
98
|
+
|
|
99
|
+
Everything except 401 comes back as a real `Response`, so `RetryMiddleware` decides what to do with
|
|
100
|
+
it and the body tells you why.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scrapy-crawio"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Scrapy downloader middleware for Crawio: route requests through the Crawio API, with proxies and anti-bot handling included."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Crawio", email = "support@crawio.com" }]
|
|
14
|
+
keywords = ["scrapy", "scraping", "proxy", "crawler", "anti-bot", "crawio"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Framework :: Scrapy",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"scrapy>=2.6",
|
|
24
|
+
"requests>=2.25",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://crawio.com"
|
|
29
|
+
Documentation = "https://crawio.com/docs"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.dynamic]
|
|
32
|
+
version = { attr = "scrapy_crawio.__version__" }
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scrapy downloader middleware for Crawio (https://crawio.com).
|
|
3
|
+
|
|
4
|
+
A matched request is POSTed to Crawio's /scrape endpoint with your API key. Crawio fetches it
|
|
5
|
+
through a healthy exit, handles the site's anti-bot, and returns the site's own response, which is
|
|
6
|
+
handed back to Scrapy as if it had been downloaded directly. Spiders need no changes.
|
|
7
|
+
|
|
8
|
+
settings.py:
|
|
9
|
+
DOWNLOADER_MIDDLEWARES = {"scrapy_crawio.CrawioMiddleware": 1000}
|
|
10
|
+
CRAWIO_API_KEY = "rk-..." # required
|
|
11
|
+
CRAWIO_DOMAINS = ["example.com"] # hosts to route; omit to route every request
|
|
12
|
+
CRAWIO_API_URL = "https://api.crawio.com" # optional
|
|
13
|
+
CRAWIO_TIMEOUT = 120 # seconds, optional
|
|
14
|
+
CRAWIO_SESSION = "job-1" # optional default sticky session id
|
|
15
|
+
|
|
16
|
+
Per-request, through Request.meta:
|
|
17
|
+
meta={"crawio_skip": True} # download this one directly, not through Crawio
|
|
18
|
+
meta={"crawio_session": "job-2"} # pin to a sticky session: sign in once, reuse the id and
|
|
19
|
+
# the cookies are kept for you across requests
|
|
20
|
+
|
|
21
|
+
Every proxied response carries three headers of Crawio's own:
|
|
22
|
+
X-Crawio-Request-Id the request's reference (req_...): quote it to support
|
|
23
|
+
X-Crawio-Cost credits this request used, 1 or 0 (blocks, errors and timeouts are 0)
|
|
24
|
+
X-Crawio-Ms Crawio's own time for the fetch, in milliseconds
|
|
25
|
+
Error responses (429, 502, 503, 504...) carry the request id and cost too.
|
|
26
|
+
|
|
27
|
+
Only successful requests are billed. Blocks, errors and timeouts are free.
|
|
28
|
+
"""
|
|
29
|
+
import base64
|
|
30
|
+
import logging
|
|
31
|
+
import uuid
|
|
32
|
+
|
|
33
|
+
import requests
|
|
34
|
+
from scrapy.exceptions import IgnoreRequest, NotConfigured
|
|
35
|
+
from scrapy.http import Response, TextResponse
|
|
36
|
+
from twisted.internet.threads import deferToThread
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
DEFAULT_API_URL = "https://api.crawio.com"
|
|
41
|
+
|
|
42
|
+
# Response headers that would misdescribe the already-decoded body.
|
|
43
|
+
_DROP = {"content-encoding", "content-length", "transfer-encoding", "connection"}
|
|
44
|
+
# Crawio's own response headers, passed through on every response (the API sends them since 2026-09-18)
|
|
45
|
+
_CRAWIO_HEADERS = ("X-Crawio-Request-Id", "X-Crawio-Cost")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CrawioMiddleware:
|
|
49
|
+
def __init__(self, api_key, url=DEFAULT_API_URL, domains=(), timeout=120, session=None):
|
|
50
|
+
self.url = (url or DEFAULT_API_URL).rstrip("/")
|
|
51
|
+
self.api_key = api_key
|
|
52
|
+
self.domains = tuple(d.lower().lstrip(".") for d in domains if d)
|
|
53
|
+
self.timeout = float(timeout)
|
|
54
|
+
self.session = session # default sticky session id (per-request meta overrides)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_crawler(cls, crawler):
|
|
58
|
+
s = crawler.settings
|
|
59
|
+
key = s.get("CRAWIO_API_KEY")
|
|
60
|
+
if not key:
|
|
61
|
+
raise NotConfigured("CRAWIO_API_KEY is required (dashboard -> API Key)")
|
|
62
|
+
return cls(
|
|
63
|
+
api_key=key,
|
|
64
|
+
url=s.get("CRAWIO_API_URL", DEFAULT_API_URL),
|
|
65
|
+
# No default host list: an empty CRAWIO_DOMAINS routes every request through Crawio,
|
|
66
|
+
# which is what installing the middleware asks for. Narrow it when only some hosts
|
|
67
|
+
# need us.
|
|
68
|
+
domains=s.getlist("CRAWIO_DOMAINS", []),
|
|
69
|
+
timeout=s.getfloat("CRAWIO_TIMEOUT", 120),
|
|
70
|
+
session=s.get("CRAWIO_SESSION") or None,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _host(self, url: str) -> str:
|
|
74
|
+
return (url.split("/")[2].split("@")[-1].split(":")[0] if "://" in url else "").lower()
|
|
75
|
+
|
|
76
|
+
def _should_handle(self, request) -> bool:
|
|
77
|
+
if request.meta.get("crawio_skip"):
|
|
78
|
+
return False
|
|
79
|
+
if not self.domains:
|
|
80
|
+
return True
|
|
81
|
+
host = self._host(request.url)
|
|
82
|
+
return any(host == d or host.endswith("." + d) for d in self.domains)
|
|
83
|
+
|
|
84
|
+
def process_request(self, request, spider):
|
|
85
|
+
if not self._should_handle(request):
|
|
86
|
+
return None # download normally
|
|
87
|
+
return deferToThread(self._fetch, request, spider) # blocking POST off the reactor
|
|
88
|
+
|
|
89
|
+
def _fetch(self, request, spider):
|
|
90
|
+
payload = {
|
|
91
|
+
"method": request.method,
|
|
92
|
+
"url": request.url,
|
|
93
|
+
"headers": dict(request.headers.to_unicode_dict()),
|
|
94
|
+
"body": request.body.decode("utf-8", "replace") if request.body else None,
|
|
95
|
+
# Crawio's own budget sits just under our socket timeout, so a slow fetch comes back
|
|
96
|
+
# as a documented 504 rather than a dead connection.
|
|
97
|
+
"timeout": max(10, self.timeout - 10),
|
|
98
|
+
}
|
|
99
|
+
headers = {"X-API-Key": self.api_key}
|
|
100
|
+
session_id = request.meta.get("crawio_session") or self.session
|
|
101
|
+
if session_id:
|
|
102
|
+
headers["X-Session-Id"] = str(session_id) # sticky session, cookies kept for you
|
|
103
|
+
# A retry (Scrapy's RetryMiddleware, or a severed connection) reattaches to the SAME job
|
|
104
|
+
# instead of starting — and billing — a second one. The key is stamped on the Request so
|
|
105
|
+
# every retry of it carries the same value.
|
|
106
|
+
idem = request.meta.setdefault("crawio_idempotency_key", uuid.uuid4().hex)
|
|
107
|
+
headers["Idempotency-Key"] = idem
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
resp = requests.post(f"{self.url}/scrape", json=payload,
|
|
111
|
+
headers=headers, timeout=self.timeout)
|
|
112
|
+
except requests.RequestException as e:
|
|
113
|
+
raise IgnoreRequest(f"crawio unreachable: {e}")
|
|
114
|
+
|
|
115
|
+
if resp.status_code == 401:
|
|
116
|
+
raise IgnoreRequest("crawio rejected the API key (401): check CRAWIO_API_KEY")
|
|
117
|
+
|
|
118
|
+
if resp.status_code != 200:
|
|
119
|
+
# 403 suspended, 410 session expired, 429 a plan limit, 502 the site blocked us,
|
|
120
|
+
# 503 no capacity, 504 timeout. Surfaced as a real Response so Scrapy's own
|
|
121
|
+
# RetryMiddleware decides, and the body says which. A 429 carries Retry-After.
|
|
122
|
+
out = {"Content-Type": "application/json"}
|
|
123
|
+
for name in ("Retry-After",) + _CRAWIO_HEADERS:
|
|
124
|
+
value = resp.headers.get(name)
|
|
125
|
+
if value:
|
|
126
|
+
out[name] = value
|
|
127
|
+
spider.logger.warning("crawio %s for %s (request %s): %s", resp.status_code, request.url,
|
|
128
|
+
out.get("X-Crawio-Request-Id", "-"), resp.text[:200])
|
|
129
|
+
return TextResponse(url=request.url, status=resp.status_code,
|
|
130
|
+
request=request, body=resp.content, headers=out)
|
|
131
|
+
|
|
132
|
+
data = resp.json()
|
|
133
|
+
headers = {k: v for k, v in (data.get("headers") or {}).items()
|
|
134
|
+
if k.lower() not in _DROP}
|
|
135
|
+
if data.get("ms") is not None:
|
|
136
|
+
headers["X-Crawio-Ms"] = str(data["ms"])
|
|
137
|
+
# the request reference and its cost: from the JSON body, else from Crawio's own headers
|
|
138
|
+
rid = data.get("request_id") or resp.headers.get("X-Crawio-Request-Id")
|
|
139
|
+
if rid:
|
|
140
|
+
headers["X-Crawio-Request-Id"] = str(rid)
|
|
141
|
+
cost = data.get("cost") if data.get("cost") is not None else resp.headers.get("X-Crawio-Cost")
|
|
142
|
+
if cost is not None:
|
|
143
|
+
headers["X-Crawio-Cost"] = str(cost)
|
|
144
|
+
url = data.get("finalUrl") or data.get("final_url") or request.url
|
|
145
|
+
if data.get("body_encoding") == "base64":
|
|
146
|
+
# a file (PDF, image, archive): Crawio sends its bytes base64 encoded
|
|
147
|
+
return Response(
|
|
148
|
+
url=url,
|
|
149
|
+
status=data.get("status", 200),
|
|
150
|
+
headers=headers,
|
|
151
|
+
body=base64.b64decode(data.get("body") or ""),
|
|
152
|
+
request=request,
|
|
153
|
+
)
|
|
154
|
+
return TextResponse(
|
|
155
|
+
url=url,
|
|
156
|
+
status=data.get("status", 200),
|
|
157
|
+
headers=headers,
|
|
158
|
+
body=(data.get("body") or "").encode("utf-8"),
|
|
159
|
+
request=request,
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scrapy-crawio
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Scrapy downloader middleware for Crawio: route requests through the Crawio API, with proxies and anti-bot handling included.
|
|
5
|
+
Author-email: Crawio <support@crawio.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://crawio.com
|
|
8
|
+
Project-URL: Documentation, https://crawio.com/docs
|
|
9
|
+
Keywords: scrapy,scraping,proxy,crawler,anti-bot,crawio
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: Scrapy
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: scrapy>=2.6
|
|
19
|
+
Requires-Dist: requests>=2.25
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# scrapy-crawio
|
|
23
|
+
|
|
24
|
+
Scrapy downloader middleware for [Crawio](https://crawio.com).
|
|
25
|
+
|
|
26
|
+
Your spiders make normal requests; this middleware routes them through the Crawio API, which
|
|
27
|
+
fetches each one from a healthy exit — using the cheapest route that works, up to a full browser
|
|
28
|
+
when the site's anti-bot needs one — and returns the site's own response. No spider changes, no
|
|
29
|
+
per-request proxy juggling. Only successful requests are billed.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install scrapy-crawio
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configure (`settings.py`)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
DOWNLOADER_MIDDLEWARES = {
|
|
41
|
+
"scrapy_crawio.CrawioMiddleware": 1000,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
CRAWIO_API_KEY = "rk-..." # required — dashboard -> API Key
|
|
45
|
+
CRAWIO_DOMAINS = ["example.com"] # hosts to route; omit to route every request
|
|
46
|
+
CRAWIO_API_URL = "https://api.crawio.com" # optional
|
|
47
|
+
CRAWIO_TIMEOUT = 120 # seconds, optional
|
|
48
|
+
CRAWIO_SESSION = "job-1" # optional: default sticky session id
|
|
49
|
+
|
|
50
|
+
# Recommended: let Scrapy retry the transient answers.
|
|
51
|
+
RETRY_ENABLED = True
|
|
52
|
+
RETRY_HTTP_CODES = [429, 502, 503, 504]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use
|
|
56
|
+
|
|
57
|
+
Write spiders exactly as normal — the matched hosts go through Crawio:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import scrapy
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BooksSpider(scrapy.Spider):
|
|
64
|
+
name = "books"
|
|
65
|
+
|
|
66
|
+
def start_requests(self):
|
|
67
|
+
yield scrapy.Request("https://example.com/catalogue/page-1.html", callback=self.parse)
|
|
68
|
+
|
|
69
|
+
def parse(self, response):
|
|
70
|
+
self.logger.info("fetched in %s ms", response.headers.get("X-Crawio-Ms", b"").decode())
|
|
71
|
+
for href in response.css("h3 a::attr(href)").getall():
|
|
72
|
+
yield response.follow(href, callback=self.parse_item)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
A page or JSON arrives as a `TextResponse`. A file (PDF, image, archive) arrives as a plain
|
|
76
|
+
`scrapy.http.Response` with its original bytes in `response.body`.
|
|
77
|
+
|
|
78
|
+
## Response headers
|
|
79
|
+
|
|
80
|
+
Every response through Crawio carries these, alongside the site's own headers:
|
|
81
|
+
|
|
82
|
+
| Header | Meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `X-Crawio-Request-Id` | The request's reference (`req_...`). Quote it when you contact support. |
|
|
85
|
+
| `X-Crawio-Cost` | Credits this request used: `1`, or `0` when it was not billed (blocks, errors, timeouts). |
|
|
86
|
+
| `X-Crawio-Ms` | Crawio's own time for the fetch, in milliseconds. |
|
|
87
|
+
|
|
88
|
+
Error responses (429, 502, 503, 504) carry `X-Crawio-Request-Id` and `X-Crawio-Cost` too, and a 429
|
|
89
|
+
carries `Retry-After`.
|
|
90
|
+
|
|
91
|
+
## Per-request overrides
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
scrapy.Request(url, meta={"crawio_skip": True}) # download this one directly, not through us
|
|
95
|
+
scrapy.Request(url, meta={"crawio_session": "job-2"}) # pin to a sticky session
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A sticky session keeps the same exit and the same cookies across requests: sign in once, reuse the
|
|
99
|
+
id, and the following requests stay signed in.
|
|
100
|
+
|
|
101
|
+
## Retries and billing
|
|
102
|
+
|
|
103
|
+
Every request carries an `Idempotency-Key`, so a retry reattaches to the same job instead of
|
|
104
|
+
starting — and billing — a second one. The key is stamped on the `Request`, so Scrapy's own
|
|
105
|
+
`RetryMiddleware` reuses it.
|
|
106
|
+
|
|
107
|
+
## What Crawio answers
|
|
108
|
+
|
|
109
|
+
| code | meaning |
|
|
110
|
+
|------|---------|
|
|
111
|
+
| 200 | success — the site's response is in the body |
|
|
112
|
+
| 401 | invalid API key; the request is dropped, not retried |
|
|
113
|
+
| 403 | account suspended; fix the account, do not retry |
|
|
114
|
+
| 410 | a strict sticky session lost its exit — sign in again on a new session |
|
|
115
|
+
| 429 | a plan limit: the body names which (`rate_limited`, `concurrency_limit`, `daily_quota_exceeded`, `monthly_quota_exceeded`, `credits_used_up`) and `Retry-After` says when, where the wait is known |
|
|
116
|
+
| 502 | the site blocked the request behind its anti-bot |
|
|
117
|
+
| 503 | no capacity right now; retry |
|
|
118
|
+
| 504 | the fetch timed out |
|
|
119
|
+
|
|
120
|
+
Everything except 401 comes back as a real `Response`, so `RetryMiddleware` decides what to do with
|
|
121
|
+
it and the body tells you why.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/scrapy_crawio/__init__.py
|
|
5
|
+
src/scrapy_crawio/middleware.py
|
|
6
|
+
src/scrapy_crawio.egg-info/PKG-INFO
|
|
7
|
+
src/scrapy_crawio.egg-info/SOURCES.txt
|
|
8
|
+
src/scrapy_crawio.egg-info/dependency_links.txt
|
|
9
|
+
src/scrapy_crawio.egg-info/requires.txt
|
|
10
|
+
src/scrapy_crawio.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scrapy_crawio
|