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.
Files changed (71) hide show
  1. {flru_parser-0.3.0 → flru_parser-0.3.1}/CHANGELOG.md +16 -0
  2. {flru_parser-0.3.0 → flru_parser-0.3.1}/PKG-INFO +5 -3
  3. {flru_parser-0.3.0 → flru_parser-0.3.1}/README.md +4 -2
  4. {flru_parser-0.3.0 → flru_parser-0.3.1}/pyproject.toml +1 -1
  5. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/__init__.py +1 -1
  6. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/client.py +41 -9
  7. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/config.py +3 -1
  8. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/exceptions.py +13 -0
  9. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/transport.py +64 -12
  10. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/PKG-INFO +5 -3
  11. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_security_transport.py +70 -3
  12. {flru_parser-0.3.0 → flru_parser-0.3.1}/CODE_OF_CONDUCT.md +0 -0
  13. {flru_parser-0.3.0 → flru_parser-0.3.1}/CONTRIBUTING.md +0 -0
  14. {flru_parser-0.3.0 → flru_parser-0.3.1}/LICENSE +0 -0
  15. {flru_parser-0.3.0 → flru_parser-0.3.1}/MANIFEST.in +0 -0
  16. {flru_parser-0.3.0 → flru_parser-0.3.1}/SECURITY.md +0 -0
  17. {flru_parser-0.3.0 → flru_parser-0.3.1}/SUPPORT.md +0 -0
  18. {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/ARCHITECTURE.md +0 -0
  19. {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/PUBLISHING.md +0 -0
  20. {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/SELECTOR_MAINTENANCE.md +0 -0
  21. {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/SIMPLE_API.md +0 -0
  22. {flru_parser-0.3.0 → flru_parser-0.3.1}/docs/ru/README.md +0 -0
  23. {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/README.md +0 -0
  24. {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/basic.py +0 -0
  25. {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/incremental_sqlite.py +0 -0
  26. {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/production_config.py +0 -0
  27. {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/simple_one_shot.py +0 -0
  28. {flru_parser-0.3.0 → flru_parser-0.3.1}/examples/sync_usage.py +0 -0
  29. {flru_parser-0.3.0 → flru_parser-0.3.1}/setup.cfg +0 -0
  30. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/batch.py +0 -0
  31. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/canary.py +0 -0
  32. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/cookies.py +0 -0
  33. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/easy.py +0 -0
  34. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/filters.py +0 -0
  35. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/integrations/__init__.py +0 -0
  36. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/integrations/opentelemetry.py +0 -0
  37. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/integrations/prometheus.py +0 -0
  38. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/models.py +0 -0
  39. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/observability.py +0 -0
  40. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/__init__.py +0 -0
  41. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/common.py +0 -0
  42. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/freelancers.py +0 -0
  43. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/projects.py +0 -0
  44. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/parsers/users.py +0 -0
  45. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/proxy.py +0 -0
  46. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/py.typed +0 -0
  47. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/resilience.py +0 -0
  48. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/robots.py +0 -0
  49. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/security.py +0 -0
  50. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/state.py +0 -0
  51. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru/sync.py +0 -0
  52. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/SOURCES.txt +0 -0
  53. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/dependency_links.txt +0 -0
  54. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/entry_points.txt +0 -0
  55. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/requires.txt +0 -0
  56. {flru_parser-0.3.0 → flru_parser-0.3.1}/src/flru_parser.egg-info/top_level.txt +0 -0
  57. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/freelancers.html +0 -0
  58. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/project.html +0 -0
  59. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/projects.html +0 -0
  60. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/fixtures/user.html +0 -0
  61. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_api_extensions.py +0 -0
  62. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_client.py +0 -0
  63. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_diagnostics_and_common.py +0 -0
  64. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_easy_api.py +0 -0
  65. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_freelancers.py +0 -0
  66. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_generic_page.py +0 -0
  67. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_parsers.py +0 -0
  68. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_properties.py +0 -0
  69. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_resilience.py +0 -0
  70. {flru_parser-0.3.0 → flru_parser-0.3.1}/tests/test_robots_cookies.py +0 -0
  71. {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.0
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flru-parser"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Simple, typed and resilient async/sync parser client for public FL.ru pages"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -67,7 +67,7 @@ from .state import (
67
67
  project_content_hash,
68
68
  )
69
69
 
70
- __version__ = "0.3.0"
70
+ __version__ = "0.3.1"
71
71
 
72
72
  AsyncClient = Client
73
73
 
@@ -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 EmptyPageError, ParseError, RobotsDeniedError, SelectorDriftError
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 asyncio.gather(*(load(page) for page in page_numbers), return_exceptions=True)
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 asyncio.gather(*(load(page) for page in page_numbers), return_exceptions=True)
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 asyncio.gather(*(load(project) for project in projects), return_exceptions=True)
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.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 datetime, timezone
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
- _BLOCK_MARKERS = (
26
- "captcha",
27
- "подтвердите, что вы не робот",
28
- "доступ временно ограничен",
29
- "cloudflare",
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
- self._detect_block(response)
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=timezone.utc)
339
- return max(0.0, (parsed - datetime.now(timezone.utc)).total_seconds())
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
- if any(marker in sample for marker in _BLOCK_MARKERS):
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.0
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(200, text="Подтвердите, что вы не робот", request=request)
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