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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Scrapy downloader middleware for Crawio — https://crawio.com"""
2
+ from .middleware import CrawioMiddleware
3
+
4
+ __all__ = ["CrawioMiddleware"]
5
+ __version__ = "1.1.0"
@@ -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,2 @@
1
+ scrapy>=2.6
2
+ requests>=2.25
@@ -0,0 +1 @@
1
+ scrapy_crawio