Skip to content

Commit 1bb0443

Browse files
committed
Docs: align with code; document json=, per-request callbacks, and the new settings
Reconcile the docs with the reviewed code. Fixes: non-existent settings (RETRY_TIMES, RANDOMIZE_DOWNLOAD_DELAY, DOWNLOAD_DELAY, the old query-param settings); the MiddlewareResult factory API; signal handler kwargs and request_failed/headers_received semantics; ALLOWED_DOMAINS -> allowed_domains; Scheduler.get(); the RequestQueue ABC surface; response.text() as a method; the inverted crawl-priority direction; the XML exporter's stringified list output; the camoufox_page_event_handlers meta key; env-var vs Settings nesting; and the StatsCollector verb API. Documents the now-real capabilities: json= bodies, per-request callbacks (callback/cb_kwargs), RANDOMIZE_DELAY, the RETRY_* settings, COOKIES_ENABLED/ RETRY_ENABLED, IGNORE/KEEP_QUERY_PARAMS, and follow(headers=). Multi-step auth examples now use callbacks + json=.
1 parent 418edf2 commit 1bb0443

20 files changed

Lines changed: 299 additions & 241 deletions

docs/advanced-topics/anti_bot_evasion.md

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,8 @@ custom_settings = {
3535
# Delay between requests to same domain (seconds)
3636
"DELAY_PER_DOMAIN": 2.0,
3737

38-
# Random delay range (adds randomness to delays)
39-
"RANDOMIZE_DOWNLOAD_DELAY": True,
40-
41-
# Download delay (global, across all domains)
42-
"DOWNLOAD_DELAY": 1.0,
38+
# Jitter the per-domain delay to 50-150% per request
39+
"RANDOMIZE_DELAY": True,
4340
}
4441
```
4542

@@ -48,8 +45,7 @@ custom_settings = {
4845
- `CONCURRENCY`: Maximum concurrent requests across all domains
4946
- `CONCURRENCY_PER_DOMAIN`: Maximum concurrent requests per domain (most important for anti-bot)
5047
- `DELAY_PER_DOMAIN`: Minimum delay between requests to same domain
51-
- `RANDOMIZE_DOWNLOAD_DELAY`: Adds random variance to delays (0.5x to 1.5x)
52-
- `DOWNLOAD_DELAY`: Global minimum delay between any requests
48+
- `RANDOMIZE_DELAY`: Jitters `DELAY_PER_DOMAIN` to 50-150% per request
5349

5450
**Example Spider:**
5551

@@ -62,7 +58,7 @@ class PoliteSpider(Spider):
6258
custom_settings = {
6359
"CONCURRENCY_PER_DOMAIN": 1, # One request at a time per domain
6460
"DELAY_PER_DOMAIN": 3.0, # 3 seconds between requests
65-
"RANDOMIZE_DOWNLOAD_DELAY": True, # Vary timing naturally
61+
"RANDOMIZE_DELAY": True, # Vary timing naturally
6662
}
6763
```
6864

@@ -217,7 +213,7 @@ custom_settings = {
217213
},
218214

219215
# Retry configuration
220-
"RETRY_TIMES": 3, # Maximum retry attempts
216+
"MAX_RETRIES": 3, # Maximum retry attempts
221217

222218
# HTTP status codes that trigger retry
223219
"RETRY_HTTP_CODES": [500, 502, 503, 504, 408, 429],
@@ -232,7 +228,7 @@ custom_settings = {
232228
1. Request fails with retryable status code (e.g., 503 Service Unavailable)
233229
2. Request is re-queued with lower priority
234230
3. Natural delay occurs before retry (due to queue processing)
235-
4. Process repeats up to `RETRY_TIMES` attempts
231+
4. Process repeats up to `MAX_RETRIES` attempts
236232

237233
**Custom Retry Logic:**
238234

@@ -401,7 +397,7 @@ class StealthSpider(Spider):
401397
"CONCURRENCY": 2,
402398
"CONCURRENCY_PER_DOMAIN": 1,
403399
"DELAY_PER_DOMAIN": 3.0,
404-
"RANDOMIZE_DOWNLOAD_DELAY": True,
400+
"RANDOMIZE_DELAY": True,
405401

406402
# Headers (qCrawl framework-level)
407403
"DEFAULT_REQUEST_HEADERS": {
@@ -412,7 +408,7 @@ class StealthSpider(Spider):
412408
},
413409

414410
# Retry with backoff (qCrawl framework-level)
415-
"RETRY_TIMES": 3,
411+
"MAX_RETRIES": 3,
416412
"RETRY_HTTP_CODES": [500, 502, 503, 504, 408, 429],
417413

418414
# Middlewares (qCrawl framework-level)
@@ -568,7 +564,7 @@ if should_scrape_now():
568564

569565
```python
570566
async def parse(self, response):
571-
if "captcha" in response.text.lower():
567+
if "captcha" in response.text().lower()():
572568
self.logger.warning(f"CAPTCHA detected at {response.url}")
573569
# Option 1: Stop scraping
574570
return
@@ -601,7 +597,7 @@ class AdaptiveSpider(Spider):
601597
self.detection_count -= 1
602598

603599
def is_detected(self, response):
604-
return response.status_code in [429, 403] or "captcha" in response.text.lower()
600+
return response.status_code in [429, 403] or "captcha" in response.text().lower()()
605601
```
606602

607603
## Common Anti-Bot Patterns and Solutions
@@ -616,7 +612,7 @@ custom_settings = {
616612
"DELAY_PER_DOMAIN": 5.0,
617613
"CONCURRENCY_PER_DOMAIN": 1,
618614
"RETRY_HTTP_CODES": [429],
619-
"RETRY_TIMES": 5,
615+
"MAX_RETRIES": 5,
620616
}
621617
```
622618

@@ -689,8 +685,8 @@ async def start_requests(self):
689685
yield Request(
690686
url="https://example.com/login",
691687
method="POST",
692-
body={"username": "...", "password": "..."},
693-
callback=self.after_login
688+
json={"username": "...", "password": "..."},
689+
callback=self.after_login,
694690
)
695691

696692
async def after_login(self, response):
@@ -744,7 +740,7 @@ qCrawl provides comprehensive anti-bot evasion capabilities:
744740
| User Agent Rotation | Avoid fingerprinting | Per-request headers |
745741
| Cookie Management | Session handling | `CookiesMiddleware` (automatic) |
746742
| Proxy Support | IP rotation | `HttpProxyMiddleware`, per-request meta |
747-
| Retry Strategy | Handle transient errors | `RETRY_TIMES`, `RETRY_HTTP_CODES` |
743+
| Retry Strategy | Handle transient errors | `MAX_RETRIES`, `RETRY_HTTP_CODES` |
748744
| Custom Headers | Appear legitimate | `DEFAULT_REQUEST_HEADERS`, per-request |
749745
| Navigation Patterns | Human-like behavior | PageMethod with Camoufox |
750746

docs/advanced-topics/authentication.md

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,30 @@ class AuthSpider(Spider):
1818
}
1919

2020
async def parse(self, response):
21+
# The login page: extract the CSRF token and submit the form.
2122
rv = self.response_view(response)
22-
23-
# Extract CSRF token
2423
csrf_token = rv.doc.cssselect("input[name=csrf_token]")[0].get("value")
2524

26-
# Submit login form
25+
# Form login uses x-www-form-urlencoded, so encode the body (bytes).
26+
from urllib.parse import urlencode
27+
2728
yield Request(
2829
url="https://example.com/login",
2930
method="POST",
30-
body={
31-
"username": "user@example.com",
32-
"password": "password123",
33-
"csrf_token": csrf_token
34-
},
35-
meta={"next_action": "start_crawl"}
31+
body=urlencode(
32+
{
33+
"username": "user@example.com",
34+
"password": "password123",
35+
"csrf_token": csrf_token,
36+
}
37+
).encode(),
38+
headers={"Content-Type": "application/x-www-form-urlencoded"},
39+
callback=self.after_login,
3640
)
3741

38-
async def parse_logged_in(self, response):
39-
# Check if login succeeded
40-
if response.request.meta.get("next_action") == "start_crawl":
41-
# Start crawling protected pages
42-
yield Request(url="https://example.com/dashboard")
42+
async def after_login(self, response):
43+
# Cookies from the login are stored automatically; crawl protected pages.
44+
yield Request(url="https://example.com/dashboard")
4345
```
4446

4547
**Verify login success:**
@@ -221,40 +223,32 @@ class CustomAuthSpider(Spider):
221223
}
222224

223225
async def parse(self, response):
224-
"""Step 1: Get initial token."""
226+
"""Step 1: get the initial token, then submit it (routed to step 2)."""
225227
rv = self.response_view(response)
226-
227228
initial_token = rv.doc.cssselect("input[name=token]")[0].get("value")
228229

229-
# Step 2: Submit token
230230
yield Request(
231231
url="https://example.com/step2",
232232
method="POST",
233-
body={"token": initial_token},
234-
meta={"step": 2}
233+
json={"token": initial_token},
234+
callback=self.submit_credentials,
235235
)
236236

237-
async def parse_step2(self, response):
238-
"""Step 2: Submit credentials."""
239-
step = response.request.meta.get("step")
237+
async def submit_credentials(self, response):
238+
"""Step 2: submit credentials (routed to step 3)."""
239+
rv = self.response_view(response)
240+
csrf = rv.doc.cssselect("input[name=csrf]")[0].get("value")
240241

241-
if step == 2:
242-
rv = self.response_view(response)
243-
csrf = rv.doc.cssselect("input[name=csrf]")[0].get("value")
242+
yield Request(
243+
url="https://example.com/login",
244+
method="POST",
245+
json={"username": "user", "password": "pass", "csrf": csrf},
246+
callback=self.start_crawl,
247+
)
244248

245-
yield Request(
246-
url="https://example.com/login",
247-
method="POST",
248-
body={
249-
"username": "user",
250-
"password": "pass",
251-
"csrf": csrf
252-
},
253-
meta={"step": 3}
254-
)
255-
elif step == 3:
256-
# Authentication complete, start crawling
257-
yield Request(url="https://example.com/protected")
249+
async def start_crawl(self, response):
250+
"""Step 3: authentication complete, start crawling."""
251+
yield Request(url="https://example.com/protected")
258252
```
259253

260254

docs/advanced-topics/browser_automation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ yield Request(
417417
url="https://example.com",
418418
meta={
419419
"use_handler": "camoufox",
420-
"camoufox_event_handlers": {
420+
"camoufox_page_event_handlers": {
421421
"console": handle_console,
422422
"dialog": handle_dialog
423423
}

docs/advanced-topics/crawl_ordering.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
The scheduler processes requests based on priority values. Higher priority requests are processed first. By adjusting priorities, you can control whether your crawler explores pages breadth-first (level by level), depth-first (following paths deeply), or with custom focus on specific content.
2+
The scheduler processes requests based on priority values. Requests with a lower priority value are processed first (priority `0` is the default). By adjusting priorities, you can control whether your crawler explores pages breadth-first (level by level), depth-first (following paths deeply), or with custom focus on specific content.
33

44
## Breadth-first crawl (default)
55

@@ -39,15 +39,17 @@ async def parse(self, response):
3939
rv = self.response_view(response)
4040

4141
current_depth = response.request.meta.get("depth", 0)
42-
next_priority = current_depth # Higher depth = higher priority
42+
next_depth = current_depth + 1
4343

4444
for link in rv.doc.cssselect("a"):
4545
href = link.get("href")
4646
if href:
47+
# Lower priority value = processed sooner, so each request gets
48+
# priority -depth: the deepest queued link is always followed first.
4749
yield rv.follow(
4850
href,
49-
priority=next_priority,
50-
meta={"depth": current_depth + 1}
51+
priority=-next_depth,
52+
meta={"depth": next_depth},
5153
)
5254
```
5355

@@ -66,15 +68,15 @@ Prioritize specific content types or URL patterns:
6668
async def parse(self, response):
6769
rv = self.response_view(response)
6870

69-
# High priority for target content
71+
# High priority for target content (lower value = processed first)
7072
if "product" in response.url:
7173
for link in rv.doc.cssselect("a.product"):
72-
yield rv.follow(link.get("href"), priority=100)
74+
yield rv.follow(link.get("href"), priority=-100)
7375

74-
# Low priority for other pages
76+
# Lower priority for other pages
7577
else:
7678
for link in rv.doc.cssselect("a"):
77-
yield rv.follow(link.get("href"), priority=1)
79+
yield rv.follow(link.get("href"), priority=10)
7880
```
7981

8082
**Use cases:**

docs/advanced-topics/data_extraction.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,7 @@ async def start_requests(self):
219219
yield Request(
220220
url="https://api.example.com/graphql",
221221
method="POST",
222-
body={
223-
"query": query,
224-
"variables": {"page": 1}
225-
},
226-
headers={"Content-Type": "application/json"}
222+
json={"query": query, "variables": {"page": 1}},
227223
)
228224

229225
async def parse(self, response):
@@ -254,7 +250,7 @@ class SitemapSpider(Spider):
254250

255251
async def parse(self, response):
256252
# Parse sitemap XML
257-
root = ET.fromstring(response.text)
253+
root = ET.fromstring(response.text())
258254
namespace = {"ns": "http://www.sitemaps.org/schemas/sitemap/0.9"}
259255

260256
# Extract URLs
@@ -273,7 +269,7 @@ class SitemapSpider(Spider):
273269
### Extract sitemap metadata
274270
```python
275271
async def parse(self, response):
276-
root = ET.fromstring(response.text)
272+
root = ET.fromstring(response.text())
277273
namespace = {"ns": "http://www.sitemaps.org/schemas/sitemap/0.9"}
278274

279275
for url_elem in root.findall(".//ns:url", namespace):

docs/advanced-topics/error_recovery.md

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,20 @@ No configuration needed - it works out of the box!
1919

2020
### Configure retry settings
2121

22-
Customize retry behavior in spider settings:
22+
All retry behaviour is configurable via settings:
2323

2424
```python
2525
class MySpider(Spider):
2626
name = "my_spider"
2727
start_urls = ["https://example.com"]
2828

2929
custom_settings = {
30-
"RETRY_ENABLED": True, # Default: True
31-
"RETRY_TIMES": 5, # Max retries (default: 3)
32-
"RETRY_HTTP_CODES": [429, 500, 502, 503, 504, 408], # Status codes to retry
33-
"RETRY_PRIORITY_ADJUST": -1, # Lower priority for retries (default: -1)
34-
35-
# Exponential backoff parameters
36-
"RETRY_BACKOFF_BASE": 2.0, # Base delay in seconds (default: 1.0)
37-
"RETRY_BACKOFF_MAX": 120.0, # Max delay in seconds (default: 60.0)
38-
"RETRY_BACKOFF_JITTER": 0.3, # Jitter fraction (default: 0.3)
30+
"MAX_RETRIES": 5, # default: 3
31+
"RETRY_HTTP_CODES": [429, 500, 502, 503, 504, 408], # default: [429, 500, 502, 503, 504]
32+
"RETRY_PRIORITY_ADJUST": -1, # default: -1 (lower priority for retries)
33+
"RETRY_BACKOFF_BASE": 2.0, # default: 1.0
34+
"RETRY_BACKOFF_MAX": 120.0, # default: 60.0
35+
"RETRY_BACKOFF_JITTER": 0.3, # default: 0.3
3936
}
4037
```
4138

@@ -61,22 +58,28 @@ With jitter applied: `delay ± (delay * jitter)`
6158
Check retry statistics:
6259

6360
```python
64-
stats = crawler.stats.get_stats()
61+
stats = crawler.stats.snapshot()
6562

6663
print(f"Total retries: {stats.get('downloader/retry/total', 0)}")
6764
print(f"Network errors: {stats.get('downloader/retry/network_error', 0)}")
6865
print(f"HTTP errors: {stats.get('downloader/retry/http_error', 0)}")
6966
print(f"Max retries reached: {stats.get('downloader/retry/max_reached', 0)}")
7067
```
7168

72-
### Disable retry for specific requests
69+
### Skip retries for specific requests
70+
71+
Control retries per request through `meta`: `dont_retry` skips retrying a
72+
request entirely, and `max_retry_times` caps its retries independently of the
73+
`MAX_RETRIES` setting.
7374

7475
```python
75-
# Disable retry for a specific request
76-
yield Request(
77-
url="https://example.com/optional",
78-
meta={"dont_retry": True}
79-
)
76+
from qcrawl.core.request import Request
77+
78+
# Never retry this request
79+
yield Request(url="https://example.com/optional", meta={"dont_retry": True})
80+
81+
# Allow at most one retry for this request
82+
yield Request(url="https://example.com/flaky", meta={"max_retry_times": 1})
8083
```
8184

8285

@@ -232,7 +235,7 @@ async def parse(self, response):
232235
self.logger.warning(
233236
f"No items found on {response.url} "
234237
f"(status: {response.status_code}, "
235-
f"content-length: {len(response.text)})"
238+
f"content-length: {len(response.text())})"
236239
)
237240

238241
# Log page structure for debugging
@@ -252,7 +255,7 @@ async def parse(self, response):
252255
## Best practices
253256

254257
- **Use built-in RetryMiddleware**: It's enabled by default and handles network errors and HTTP errors automatically
255-
- **Configure retry settings**: Adjust `RETRY_TIMES`, `RETRY_HTTP_CODES`, and backoff parameters as needed
258+
- **Configure retry settings**: Adjust `MAX_RETRIES`, `RETRY_HTTP_CODES`, `RETRY_PRIORITY_ADJUST`, and `RETRY_BACKOFF_*` as needed
256259
- **Monitor retry stats**: Track retry metrics to detect problematic sources
257260
- **Handle missing data gracefully**: Use default values and defensive checks for optional fields
258261
- **Log errors appropriately**: Use different log levels (warning, error, debug)

0 commit comments

Comments
 (0)