flru-parser 0.3.0__tar.gz → 0.3.1__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.
- {flru_parser-0.3.0 → flru_parser-0.3.1}/CHANGELOG.md +16 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/PKG-INFO +5 -3
- {flru_parser-0.3.0 → flru_parser-0.3.1}/README.md +4 -2
- {flru_parser-0.3.0 → flru_parser-0.3.1}/pyproject.toml +1 -1
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/__init__.py +1 -1
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/client.py +41 -9
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/config.py +3 -1
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/exceptions.py +13 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/transport.py +64 -12
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/PKG-INFO +5 -3
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_security_transport.py +70 -3
- {flru_parser-0.3.0 → flru_parser-0.3.1}/CODE_OF_CONDUCT.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/CONTRIBUTING.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/LICENSE +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/MANIFEST.in +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/SECURITY.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/SUPPORT.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/ARCHITECTURE.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/PUBLISHING.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/SELECTOR_MAINTENANCE.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/SIMPLE_API.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/ru/README.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/README.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/basic.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/incremental_sqlite.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/production_config.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/simple_one_shot.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/sync_usage.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/setup.cfg +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/batch.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/canary.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/cookies.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/easy.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/filters.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/integrations/__init__.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/integrations/opentelemetry.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/integrations/prometheus.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/models.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/observability.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/__init__.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/common.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/freelancers.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/projects.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/users.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/proxy.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/py.typed +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/resilience.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/robots.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/security.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/state.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/sync.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/SOURCES.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/dependency_links.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/entry_points.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/requires.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/top_level.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/freelancers.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/project.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/projects.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/user.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_api_extensions.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_client.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_diagnostics_and_common.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_easy_api.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_freelancers.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_generic_page.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_parsers.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_properties.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_resilience.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_robots_cookies.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_transport_retries.py +0 -0
|
@@ -4,6 +4,22 @@ All notable changes are documented here. The project follows [Semantic Versionin
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.1] - 2026-07-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Block-response diagnostics: sanitized response headers and body are saved under
|
|
12
|
+
`.flru-debug/blocked-.../` when CAPTCHA or anti-bot protection is detected.
|
|
13
|
+
- `BlockedError.status_code` and `BlockedError.debug_path` for programmatic diagnostics.
|
|
14
|
+
- Persistent server-set cookies shared by the transport's existing clients.
|
|
15
|
+
- A conservative 0.5-second minimum interval between request starts by default.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- CAPTCHA responses are never retried; batches and iterators stop immediately on a block.
|
|
20
|
+
- Retry handling honors `Retry-After` for both HTTP 429 and HTTP 503 responses.
|
|
21
|
+
- Block detection now requires strong evidence and ignores isolated `captcha` text in normal scripts.
|
|
22
|
+
|
|
7
23
|
## [0.3.0] - 2026-07-25
|
|
8
24
|
|
|
9
25
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flru-parser
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Simple, typed and resilient async/sync parser client for public FL.ru pages
|
|
5
5
|
Author: Andrey
|
|
6
6
|
License-Expression: MIT
|
|
@@ -324,8 +324,10 @@ See [docs/SIMPLE_API.md](docs/SIMPLE_API.md) for the complete high-level API and
|
|
|
324
324
|
- Unclassified empty pages raise `EmptyPageError`.
|
|
325
325
|
- A recognized end page contains the `catalog_end` diagnostic warning.
|
|
326
326
|
- HTTP 429 can pause all concurrent workers through a shared cooldown.
|
|
327
|
+
- HTTP 429 and 503 honor `Retry-After` when the server supplies it.
|
|
327
328
|
- Circuit breakers are scoped by endpoint and proxy by default.
|
|
328
|
-
- CAPTCHA and block pages are detected, not bypassed.
|
|
329
|
+
- CAPTCHA and block pages are detected, not bypassed or retried. Their HTML and sanitized response
|
|
330
|
+
metadata are saved to `.flru-debug/blocked-.../`; this directory is ignored by Git.
|
|
329
331
|
- Requests are restricted to allowed FL.ru hosts and safe redirects.
|
|
330
332
|
|
|
331
333
|
## Observability
|
|
@@ -356,7 +358,7 @@ Install `flru-parser[observability]` before using these adapters.
|
|
|
356
358
|
|
|
357
359
|
| Exception | Meaning |
|
|
358
360
|
|---|---|
|
|
359
|
-
| `BlockedError` | CAPTCHA, anti-bot or access-block page detected |
|
|
361
|
+
| `BlockedError` | CAPTCHA, anti-bot or access-block page detected; exposes `status_code` and `debug_path` |
|
|
360
362
|
| `AuthenticationRequired` | Authenticated cookies are required |
|
|
361
363
|
| `RateLimitedError` | HTTP 429 after retry policy exhaustion |
|
|
362
364
|
| `CircuitOpenError` | A scoped circuit breaker is open |
|
|
@@ -285,8 +285,10 @@ See [docs/SIMPLE_API.md](docs/SIMPLE_API.md) for the complete high-level API and
|
|
|
285
285
|
- Unclassified empty pages raise `EmptyPageError`.
|
|
286
286
|
- A recognized end page contains the `catalog_end` diagnostic warning.
|
|
287
287
|
- HTTP 429 can pause all concurrent workers through a shared cooldown.
|
|
288
|
+
- HTTP 429 and 503 honor `Retry-After` when the server supplies it.
|
|
288
289
|
- Circuit breakers are scoped by endpoint and proxy by default.
|
|
289
|
-
- CAPTCHA and block pages are detected, not bypassed.
|
|
290
|
+
- CAPTCHA and block pages are detected, not bypassed or retried. Their HTML and sanitized response
|
|
291
|
+
metadata are saved to `.flru-debug/blocked-.../`; this directory is ignored by Git.
|
|
290
292
|
- Requests are restricted to allowed FL.ru hosts and safe redirects.
|
|
291
293
|
|
|
292
294
|
## Observability
|
|
@@ -317,7 +319,7 @@ Install `flru-parser[observability]` before using these adapters.
|
|
|
317
319
|
|
|
318
320
|
| Exception | Meaning |
|
|
319
321
|
|---|---|
|
|
320
|
-
| `BlockedError` | CAPTCHA, anti-bot or access-block page detected |
|
|
322
|
+
| `BlockedError` | CAPTCHA, anti-bot or access-block page detected; exposes `status_code` and `debug_path` |
|
|
321
323
|
| `AuthenticationRequired` | Authenticated cookies are required |
|
|
322
324
|
| `RateLimitedError` | HTTP 429 after retry policy exhaustion |
|
|
323
325
|
| `CircuitOpenError` | A scoped circuit breaker is open |
|
|
@@ -3,10 +3,10 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import email.utils
|
|
5
5
|
import re
|
|
6
|
-
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Mapping, Sequence
|
|
6
|
+
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Iterable, Mapping, Sequence
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Self, cast
|
|
9
|
+
from typing import Any, Self, TypeVar, cast
|
|
10
10
|
from urllib.parse import parse_qs, urlencode, urljoin, urlsplit
|
|
11
11
|
|
|
12
12
|
import httpx
|
|
@@ -15,7 +15,13 @@ from bs4 import BeautifulSoup
|
|
|
15
15
|
from .batch import BatchError, BatchResult
|
|
16
16
|
from .config import ClientConfig
|
|
17
17
|
from .cookies import load_netscape_cookies
|
|
18
|
-
from .exceptions import
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
BlockedError,
|
|
20
|
+
EmptyPageError,
|
|
21
|
+
ParseError,
|
|
22
|
+
RobotsDeniedError,
|
|
23
|
+
SelectorDriftError,
|
|
24
|
+
)
|
|
19
25
|
from .filters import ProjectFilters
|
|
20
26
|
from .models import (
|
|
21
27
|
Category,
|
|
@@ -44,6 +50,26 @@ from .robots import RobotsPolicy
|
|
|
44
50
|
from .state import CrawlStateStore, record_for
|
|
45
51
|
from .transport import ResilientTransport
|
|
46
52
|
|
|
53
|
+
T = TypeVar("T")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _gather_batch_or_stop_on_block(
|
|
57
|
+
awaitables: Iterable[Awaitable[T]],
|
|
58
|
+
) -> list[T | BaseException]:
|
|
59
|
+
"""Cancel outstanding work when a block makes further scraping inappropriate."""
|
|
60
|
+
tasks = [asyncio.ensure_future(awaitable) for awaitable in awaitables]
|
|
61
|
+
if not tasks:
|
|
62
|
+
return []
|
|
63
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
|
|
64
|
+
for task in done:
|
|
65
|
+
error = task.exception()
|
|
66
|
+
if isinstance(error, BlockedError):
|
|
67
|
+
for pending_task in pending:
|
|
68
|
+
pending_task.cancel()
|
|
69
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
70
|
+
raise error
|
|
71
|
+
return list(await asyncio.gather(*tasks, return_exceptions=True))
|
|
72
|
+
|
|
47
73
|
|
|
48
74
|
class FLClient:
|
|
49
75
|
"""Asynchronous, read-only client/parser for public FL.ru pages."""
|
|
@@ -216,15 +242,17 @@ class FLClient:
|
|
|
216
242
|
params=params,
|
|
217
243
|
)
|
|
218
244
|
|
|
219
|
-
values = await
|
|
245
|
+
values = await _gather_batch_or_stop_on_block(load(page) for page in page_numbers)
|
|
220
246
|
result: BatchResult[int, ProjectPage] = BatchResult()
|
|
221
247
|
for page, value in zip(page_numbers, values, strict=True):
|
|
222
248
|
if isinstance(value, asyncio.CancelledError):
|
|
223
249
|
raise value
|
|
250
|
+
if isinstance(value, BlockedError):
|
|
251
|
+
raise value
|
|
224
252
|
if isinstance(value, Exception):
|
|
225
253
|
result.failed.append(BatchError(page, value))
|
|
226
254
|
else:
|
|
227
|
-
result.successful.append(value)
|
|
255
|
+
result.successful.append(cast(ProjectPage, value))
|
|
228
256
|
return result
|
|
229
257
|
|
|
230
258
|
async def get_projects_batch(
|
|
@@ -461,15 +489,17 @@ class FLClient:
|
|
|
461
489
|
async with semaphore:
|
|
462
490
|
return await self.get_freelancers_page(page, category=category)
|
|
463
491
|
|
|
464
|
-
values = await
|
|
492
|
+
values = await _gather_batch_or_stop_on_block(load(page) for page in page_numbers)
|
|
465
493
|
result: BatchResult[int, FreelancerPage] = BatchResult()
|
|
466
494
|
for page, value in zip(page_numbers, values, strict=True):
|
|
467
495
|
if isinstance(value, asyncio.CancelledError):
|
|
468
496
|
raise value
|
|
497
|
+
if isinstance(value, BlockedError):
|
|
498
|
+
raise value
|
|
469
499
|
if isinstance(value, Exception):
|
|
470
500
|
result.failed.append(BatchError(page, value))
|
|
471
501
|
else:
|
|
472
|
-
result.successful.append(value)
|
|
502
|
+
result.successful.append(cast(FreelancerPage, value))
|
|
473
503
|
return result
|
|
474
504
|
|
|
475
505
|
async def get_freelancers_batch(
|
|
@@ -555,16 +585,18 @@ class FLClient:
|
|
|
555
585
|
async with semaphore:
|
|
556
586
|
return await self.get_project(value)
|
|
557
587
|
|
|
558
|
-
values = await
|
|
588
|
+
values = await _gather_batch_or_stop_on_block(load(project) for project in projects)
|
|
559
589
|
result: BatchResult[int | str, ProjectDetail] = BatchResult()
|
|
560
590
|
for project, value in zip(projects, values, strict=True):
|
|
561
591
|
key: int | str = project.id if isinstance(project, ProjectSummary) else project
|
|
562
592
|
if isinstance(value, asyncio.CancelledError):
|
|
563
593
|
raise value
|
|
594
|
+
if isinstance(value, BlockedError):
|
|
595
|
+
raise value
|
|
564
596
|
if isinstance(value, Exception):
|
|
565
597
|
result.failed.append(BatchError(key, value))
|
|
566
598
|
else:
|
|
567
|
-
result.successful.append(value)
|
|
599
|
+
result.successful.append(cast(ProjectDetail, value))
|
|
568
600
|
return result
|
|
569
601
|
|
|
570
602
|
async def get_project_details(
|
|
@@ -33,7 +33,7 @@ class RateLimitConfig:
|
|
|
33
33
|
requests_per_second: float = 1.0
|
|
34
34
|
burst: int = 2
|
|
35
35
|
max_concurrency: int = 5
|
|
36
|
-
min_interval: float = 0.
|
|
36
|
+
min_interval: float = 0.5
|
|
37
37
|
interval_jitter: float = 0.15
|
|
38
38
|
|
|
39
39
|
|
|
@@ -101,6 +101,8 @@ class ClientConfig:
|
|
|
101
101
|
raise ConfigurationError("requests_per_second must be positive")
|
|
102
102
|
if self.rate_limit.max_concurrency <= 0:
|
|
103
103
|
raise ConfigurationError("max_concurrency must be positive")
|
|
104
|
+
if self.rate_limit.min_interval < 0:
|
|
105
|
+
raise ConfigurationError("min_interval must not be negative")
|
|
104
106
|
if self.retry.max_attempts <= 0:
|
|
105
107
|
raise ConfigurationError("max_attempts must be positive")
|
|
106
108
|
if self.retry.total_timeout <= 0:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
class FLRUError(Exception):
|
|
5
7
|
"""Base package exception."""
|
|
@@ -27,6 +29,17 @@ class RateLimitedError(HTTPStatusError):
|
|
|
27
29
|
class BlockedError(TransportError):
|
|
28
30
|
"""A CAPTCHA, anti-bot page, or access block was detected."""
|
|
29
31
|
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
message: str,
|
|
35
|
+
*,
|
|
36
|
+
status_code: int | None = None,
|
|
37
|
+
debug_path: Path | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
self.debug_path = debug_path
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
|
|
30
43
|
|
|
31
44
|
class AuthenticationRequired(TransportError):
|
|
32
45
|
"""The requested page requires a valid authenticated session."""
|
|
@@ -2,8 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import email.utils
|
|
5
|
+
import json
|
|
5
6
|
import random
|
|
6
|
-
from datetime import
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
7
9
|
from typing import Any
|
|
8
10
|
from urllib.parse import urljoin, urlsplit
|
|
9
11
|
|
|
@@ -22,11 +24,14 @@ from .proxy import ProxyPool, ProxyState
|
|
|
22
24
|
from .resilience import AsyncRateLimiter, CircuitBreakerRegistry, RetryBudget
|
|
23
25
|
from .security import validate_url
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
27
|
+
_STRONG_BLOCK_MARKERS = (
|
|
28
|
+
"g-recaptcha",
|
|
29
|
+
"hcaptcha",
|
|
30
|
+
"captcha-container",
|
|
31
|
+
"cf-chl-",
|
|
32
|
+
"challenge-platform",
|
|
33
|
+
"<title>access denied",
|
|
34
|
+
"<title>доступ ограничен",
|
|
30
35
|
)
|
|
31
36
|
_AUTH_MARKERS = (
|
|
32
37
|
"необходимо авторизоваться",
|
|
@@ -35,6 +40,28 @@ _AUTH_MARKERS = (
|
|
|
35
40
|
_REDIRECT_CODES = {301, 302, 303, 307, 308}
|
|
36
41
|
|
|
37
42
|
|
|
43
|
+
def _dump_blocked_response(response: httpx.Response) -> Path:
|
|
44
|
+
"""Persist a blocked response without serializing request credentials."""
|
|
45
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
46
|
+
directory = Path(".flru-debug") / f"blocked-{timestamp}"
|
|
47
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
(directory / "response.html").write_bytes(response.content)
|
|
50
|
+
sensitive_headers = {"authorization", "cookie", "set-cookie", "proxy-authorization"}
|
|
51
|
+
safe_headers = {
|
|
52
|
+
key: value for key, value in response.headers.items() if key.lower() not in sensitive_headers
|
|
53
|
+
}
|
|
54
|
+
metadata = {
|
|
55
|
+
"status_code": response.status_code,
|
|
56
|
+
"url": str(response.url),
|
|
57
|
+
"headers": safe_headers,
|
|
58
|
+
}
|
|
59
|
+
(directory / "metadata.json").write_text(
|
|
60
|
+
json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
61
|
+
)
|
|
62
|
+
return directory
|
|
63
|
+
|
|
64
|
+
|
|
38
65
|
class ResilientTransport:
|
|
39
66
|
def __init__(
|
|
40
67
|
self,
|
|
@@ -145,7 +172,17 @@ class ResilientTransport:
|
|
|
145
172
|
bytes_received=len(response.content),
|
|
146
173
|
latency_seconds_total=elapsed,
|
|
147
174
|
)
|
|
148
|
-
|
|
175
|
+
try:
|
|
176
|
+
self._detect_block(response)
|
|
177
|
+
except BlockedError as exc:
|
|
178
|
+
debug_directory = _dump_blocked_response(response)
|
|
179
|
+
raise BlockedError(
|
|
180
|
+
f"{exc}. Response saved to {debug_directory}",
|
|
181
|
+
status_code=response.status_code,
|
|
182
|
+
debug_path=debug_directory,
|
|
183
|
+
) from exc
|
|
184
|
+
|
|
185
|
+
self._persist_response_cookies(response)
|
|
149
186
|
|
|
150
187
|
if response.status_code in self.config.retry.retry_statuses:
|
|
151
188
|
if response.status_code == 429:
|
|
@@ -191,7 +228,6 @@ class ResilientTransport:
|
|
|
191
228
|
raise
|
|
192
229
|
await self._proxy_pool.failure(proxy_state, exc)
|
|
193
230
|
except BlockedError as exc:
|
|
194
|
-
last_error = exc
|
|
195
231
|
await self.metrics.mutate(endpoint=endpoint, blocked_total=1, failures_total=1)
|
|
196
232
|
await self._proxy_pool.failure(proxy_state, exc)
|
|
197
233
|
await self._emit(
|
|
@@ -206,6 +242,8 @@ class ResilientTransport:
|
|
|
206
242
|
error=type(exc).__name__,
|
|
207
243
|
)
|
|
208
244
|
)
|
|
245
|
+
await circuit.record_failure()
|
|
246
|
+
raise
|
|
209
247
|
except httpx.TransportError as exc:
|
|
210
248
|
last_error = exc
|
|
211
249
|
await self.metrics.mutate(endpoint=endpoint, failures_total=1)
|
|
@@ -320,7 +358,7 @@ class ResilientTransport:
|
|
|
320
358
|
self.config.retry.max_delay,
|
|
321
359
|
self.config.retry.base_delay * (2 ** (attempt - 1)),
|
|
322
360
|
)
|
|
323
|
-
return exponential + random.uniform(0, self.config.retry.jitter)
|
|
361
|
+
return float(exponential + random.uniform(0, self.config.retry.jitter))
|
|
324
362
|
|
|
325
363
|
def _retry_after(self, response: httpx.Response) -> float | None:
|
|
326
364
|
return self._parse_retry_after(response.headers.get("Retry-After"))
|
|
@@ -335,8 +373,8 @@ class ResilientTransport:
|
|
|
335
373
|
try:
|
|
336
374
|
parsed = email.utils.parsedate_to_datetime(value)
|
|
337
375
|
if parsed.tzinfo is None:
|
|
338
|
-
parsed = parsed.replace(tzinfo=
|
|
339
|
-
return max(0.0, (parsed - datetime.now(
|
|
376
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
377
|
+
return max(0.0, (parsed - datetime.now(UTC)).total_seconds())
|
|
340
378
|
except (TypeError, ValueError):
|
|
341
379
|
return None
|
|
342
380
|
|
|
@@ -344,11 +382,25 @@ class ResilientTransport:
|
|
|
344
382
|
if not self.config.detect_blocks:
|
|
345
383
|
return
|
|
346
384
|
sample = response.text[:200_000].casefold()
|
|
347
|
-
|
|
385
|
+
marker_count = sum(marker in sample for marker in _STRONG_BLOCK_MARKERS)
|
|
386
|
+
suspicious_status = response.status_code in {403, 429, 503}
|
|
387
|
+
redirected_to_challenge = any(
|
|
388
|
+
part in str(response.url).casefold() for part in ("captcha", "challenge", "blocked")
|
|
389
|
+
)
|
|
390
|
+
if redirected_to_challenge or marker_count >= 2:
|
|
348
391
|
raise BlockedError(f"Anti-bot or CAPTCHA page detected at {response.url}")
|
|
392
|
+
if suspicious_status and marker_count >= 1:
|
|
393
|
+
raise BlockedError(
|
|
394
|
+
f"Possible anti-bot response ({response.status_code}) at {response.url}"
|
|
395
|
+
)
|
|
349
396
|
if any(marker in sample for marker in _AUTH_MARKERS):
|
|
350
397
|
raise AuthenticationRequired(f"Authentication is required for {response.url}")
|
|
351
398
|
|
|
399
|
+
def _persist_response_cookies(self, response: httpx.Response) -> None:
|
|
400
|
+
cookies = dict(response.cookies)
|
|
401
|
+
if cookies:
|
|
402
|
+
self.update_cookies(cookies)
|
|
403
|
+
|
|
352
404
|
def update_cookies(self, cookies: dict[str, str]) -> None:
|
|
353
405
|
self._cookies.update(cookies)
|
|
354
406
|
for client in self._clients.values():
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flru-parser
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Simple, typed and resilient async/sync parser client for public FL.ru pages
|
|
5
5
|
Author: Andrey
|
|
6
6
|
License-Expression: MIT
|
|
@@ -324,8 +324,10 @@ See [docs/SIMPLE_API.md](docs/SIMPLE_API.md) for the complete high-level API and
|
|
|
324
324
|
- Unclassified empty pages raise `EmptyPageError`.
|
|
325
325
|
- A recognized end page contains the `catalog_end` diagnostic warning.
|
|
326
326
|
- HTTP 429 can pause all concurrent workers through a shared cooldown.
|
|
327
|
+
- HTTP 429 and 503 honor `Retry-After` when the server supplies it.
|
|
327
328
|
- Circuit breakers are scoped by endpoint and proxy by default.
|
|
328
|
-
- CAPTCHA and block pages are detected, not bypassed.
|
|
329
|
+
- CAPTCHA and block pages are detected, not bypassed or retried. Their HTML and sanitized response
|
|
330
|
+
metadata are saved to `.flru-debug/blocked-.../`; this directory is ignored by Git.
|
|
329
331
|
- Requests are restricted to allowed FL.ru hosts and safe redirects.
|
|
330
332
|
|
|
331
333
|
## Observability
|
|
@@ -356,7 +358,7 @@ Install `flru-parser[observability]` before using these adapters.
|
|
|
356
358
|
|
|
357
359
|
| Exception | Meaning |
|
|
358
360
|
|---|---|
|
|
359
|
-
| `BlockedError` | CAPTCHA, anti-bot or access-block page detected |
|
|
361
|
+
| `BlockedError` | CAPTCHA, anti-bot or access-block page detected; exposes `status_code` and `debug_path` |
|
|
360
362
|
| `AuthenticationRequired` | Authenticated cookies are required |
|
|
361
363
|
| `RateLimitedError` | HTTP 429 after retry policy exhaustion |
|
|
362
364
|
| `CircuitOpenError` | A scoped circuit breaker is open |
|
|
@@ -6,6 +6,7 @@ import httpx
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
8
|
from flru import ClientConfig, FLClient, RateLimitConfig, RetryConfig
|
|
9
|
+
from flru.client import _gather_batch_or_stop_on_block
|
|
9
10
|
from flru.exceptions import AuthenticationRequired, BlockedError, HTTPStatusError, SecurityError
|
|
10
11
|
from flru.security import redact_url, validate_url
|
|
11
12
|
|
|
@@ -44,13 +45,19 @@ async def test_safe_redirect_and_external_redirect_rejected() -> None:
|
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
@pytest.mark.asyncio
|
|
47
|
-
async def test_block_auth_status_and_event_handler_failure() -> None:
|
|
48
|
+
async def test_block_auth_status_and_event_handler_failure(tmp_path, monkeypatch) -> None:
|
|
48
49
|
paths: list[str] = []
|
|
50
|
+
monkeypatch.chdir(tmp_path)
|
|
49
51
|
|
|
50
52
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
51
53
|
paths.append(request.url.path)
|
|
52
54
|
if request.url.path == "/blocked":
|
|
53
|
-
return httpx.Response(
|
|
55
|
+
return httpx.Response(
|
|
56
|
+
403,
|
|
57
|
+
text="<html><title>Access denied</title><div class='g-recaptcha'></div></html>",
|
|
58
|
+
headers={"Set-Cookie": "session=secret", "X-Debug": "safe"},
|
|
59
|
+
request=request,
|
|
60
|
+
)
|
|
54
61
|
if request.url.path == "/auth":
|
|
55
62
|
return httpx.Response(401, request=request)
|
|
56
63
|
return httpx.Response(404, request=request)
|
|
@@ -59,7 +66,7 @@ async def test_block_auth_status_and_event_handler_failure() -> None:
|
|
|
59
66
|
raise RuntimeError("event failed")
|
|
60
67
|
|
|
61
68
|
async with FLClient(config(), transport=httpx.MockTransport(handler), event_handler=broken_event) as client:
|
|
62
|
-
with pytest.raises(BlockedError):
|
|
69
|
+
with pytest.raises(BlockedError) as blocked:
|
|
63
70
|
await client.get_html("/blocked")
|
|
64
71
|
with pytest.raises(AuthenticationRequired):
|
|
65
72
|
await client.get_html("/auth")
|
|
@@ -67,6 +74,30 @@ async def test_block_auth_status_and_event_handler_failure() -> None:
|
|
|
67
74
|
await client.get_html("/not-found")
|
|
68
75
|
assert (await client.metrics()).event_handler_failures_total > 0
|
|
69
76
|
|
|
77
|
+
assert blocked.value.status_code == 403
|
|
78
|
+
assert blocked.value.debug_path is not None
|
|
79
|
+
assert (blocked.value.debug_path / "response.html").read_text() == (
|
|
80
|
+
"<html><title>Access denied</title><div class='g-recaptcha'></div></html>"
|
|
81
|
+
)
|
|
82
|
+
metadata = (blocked.value.debug_path / "metadata.json").read_text(encoding="utf-8")
|
|
83
|
+
assert '"x-debug": "safe"' in metadata
|
|
84
|
+
assert "Set-Cookie" not in metadata
|
|
85
|
+
assert "secret" not in metadata
|
|
86
|
+
assert paths.count("/blocked") == 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_captcha_word_in_normal_script_is_not_a_block() -> None:
|
|
91
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
92
|
+
return httpx.Response(
|
|
93
|
+
200,
|
|
94
|
+
text="<html><script>analytics.track('captcha_seen')</script><main>ok</main></html>",
|
|
95
|
+
request=request,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
async with FLClient(config(), transport=httpx.MockTransport(handler)) as client:
|
|
99
|
+
assert "captcha_seen" in await client.get_html("/normal")
|
|
100
|
+
|
|
70
101
|
|
|
71
102
|
@pytest.mark.asyncio
|
|
72
103
|
async def test_retry_after_and_stable_user_agent(monkeypatch) -> None:
|
|
@@ -87,6 +118,42 @@ async def test_retry_after_and_stable_user_agent(monkeypatch) -> None:
|
|
|
87
118
|
assert (await client.metrics()).rate_limited_total == 1
|
|
88
119
|
|
|
89
120
|
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_server_cookies_persist_for_following_requests() -> None:
|
|
123
|
+
received_cookies: list[str] = []
|
|
124
|
+
|
|
125
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
126
|
+
received_cookies.append(request.headers.get("Cookie", ""))
|
|
127
|
+
if len(received_cookies) == 1:
|
|
128
|
+
return httpx.Response(200, headers={"Set-Cookie": "session=token; Path=/"}, request=request)
|
|
129
|
+
return httpx.Response(200, text="ok", request=request)
|
|
130
|
+
|
|
131
|
+
async with FLClient(config(), transport=httpx.MockTransport(handler)) as client:
|
|
132
|
+
await client.get_html("/first")
|
|
133
|
+
await client.get_html("/second")
|
|
134
|
+
|
|
135
|
+
assert "session=token" in received_cookies[1]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
async def test_batch_work_is_cancelled_when_a_block_is_detected() -> None:
|
|
140
|
+
cancellation_observed = asyncio.Event()
|
|
141
|
+
|
|
142
|
+
async def blocked() -> None:
|
|
143
|
+
raise BlockedError("blocked")
|
|
144
|
+
|
|
145
|
+
async def pending() -> None:
|
|
146
|
+
try:
|
|
147
|
+
await asyncio.Event().wait()
|
|
148
|
+
finally:
|
|
149
|
+
cancellation_observed.set()
|
|
150
|
+
|
|
151
|
+
with pytest.raises(BlockedError, match="blocked"):
|
|
152
|
+
await _gather_batch_or_stop_on_block([blocked(), pending()])
|
|
153
|
+
|
|
154
|
+
assert cancellation_observed.is_set()
|
|
155
|
+
|
|
156
|
+
|
|
90
157
|
@pytest.mark.asyncio
|
|
91
158
|
async def test_shared_limiter_pause() -> None:
|
|
92
159
|
from flru.config import RateLimitConfig
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|