flru-parser 0.3.0__tar.gz → 0.3.2__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 (72) hide show
  1. {flru_parser-0.3.0 → flru_parser-0.3.2}/CHANGELOG.md +27 -0
  2. {flru_parser-0.3.0 → flru_parser-0.3.2}/PKG-INFO +11 -15
  3. {flru_parser-0.3.0 → flru_parser-0.3.2}/README.md +9 -14
  4. {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/PUBLISHING.md +3 -3
  5. {flru_parser-0.3.0 → flru_parser-0.3.2}/pyproject.toml +2 -1
  6. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/__init__.py +1 -1
  7. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/client.py +66 -10
  8. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/config.py +3 -1
  9. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/easy.py +7 -1
  10. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/exceptions.py +13 -0
  11. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/models.py +1 -1
  12. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/projects.py +11 -4
  13. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/security.py +1 -3
  14. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/transport.py +64 -12
  15. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/PKG-INFO +11 -15
  16. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/SOURCES.txt +1 -0
  17. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/requires.txt +1 -0
  18. flru_parser-0.3.2/tests/test_client_easy_edges.py +147 -0
  19. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_easy_api.py +4 -4
  20. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_freelancers.py +1 -1
  21. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_parsers.py +32 -0
  22. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_resilience.py +1 -1
  23. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_security_transport.py +71 -4
  24. {flru_parser-0.3.0 → flru_parser-0.3.2}/CODE_OF_CONDUCT.md +0 -0
  25. {flru_parser-0.3.0 → flru_parser-0.3.2}/CONTRIBUTING.md +0 -0
  26. {flru_parser-0.3.0 → flru_parser-0.3.2}/LICENSE +0 -0
  27. {flru_parser-0.3.0 → flru_parser-0.3.2}/MANIFEST.in +0 -0
  28. {flru_parser-0.3.0 → flru_parser-0.3.2}/SECURITY.md +0 -0
  29. {flru_parser-0.3.0 → flru_parser-0.3.2}/SUPPORT.md +0 -0
  30. {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/ARCHITECTURE.md +0 -0
  31. {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/SELECTOR_MAINTENANCE.md +0 -0
  32. {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/SIMPLE_API.md +0 -0
  33. {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/ru/README.md +0 -0
  34. {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/README.md +0 -0
  35. {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/basic.py +0 -0
  36. {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/incremental_sqlite.py +0 -0
  37. {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/production_config.py +0 -0
  38. {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/simple_one_shot.py +0 -0
  39. {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/sync_usage.py +0 -0
  40. {flru_parser-0.3.0 → flru_parser-0.3.2}/setup.cfg +0 -0
  41. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/batch.py +0 -0
  42. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/canary.py +0 -0
  43. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/cookies.py +0 -0
  44. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/filters.py +0 -0
  45. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/integrations/__init__.py +0 -0
  46. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/integrations/opentelemetry.py +0 -0
  47. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/integrations/prometheus.py +0 -0
  48. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/observability.py +0 -0
  49. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/__init__.py +0 -0
  50. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/common.py +0 -0
  51. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/freelancers.py +0 -0
  52. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/users.py +0 -0
  53. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/proxy.py +0 -0
  54. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/py.typed +0 -0
  55. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/resilience.py +0 -0
  56. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/robots.py +0 -0
  57. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/state.py +0 -0
  58. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/sync.py +0 -0
  59. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/dependency_links.txt +0 -0
  60. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/entry_points.txt +0 -0
  61. {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/top_level.txt +0 -0
  62. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/freelancers.html +0 -0
  63. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/project.html +0 -0
  64. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/projects.html +0 -0
  65. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/user.html +0 -0
  66. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_api_extensions.py +0 -0
  67. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_client.py +0 -0
  68. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_diagnostics_and_common.py +0 -0
  69. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_generic_page.py +0 -0
  70. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_properties.py +0 -0
  71. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_robots_cookies.py +0 -0
  72. {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_transport_retries.py +0 -0
@@ -4,6 +4,33 @@ All notable changes are documented here. The project follows [Semantic Versionin
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.2] - 2026-07-26
8
+
9
+ ### Fixed
10
+
11
+ - Parse current FL.ru catalog cards as complete cards instead of mistaking title elements
12
+ for card containers, restoring descriptions, budgets, kinds, and other card fields.
13
+ - Follow FL.ru's `/projects/page-N/` pagination so `pages="all"` traverses the catalog.
14
+ - Default the high-level projects APIs to orders (`kind=1`), preventing vacancies from
15
+ appearing among orders unless explicitly requested through `types`.
16
+ - Keep package and parser metadata versions synchronized.
17
+
18
+ ## [0.3.1] - 2026-07-26
19
+
20
+ ### Added
21
+
22
+ - Block-response diagnostics: sanitized response headers and body are saved under
23
+ `.flru-debug/blocked-.../` when CAPTCHA or anti-bot protection is detected.
24
+ - `BlockedError.status_code` and `BlockedError.debug_path` for programmatic diagnostics.
25
+ - Persistent server-set cookies shared by the transport's existing clients.
26
+ - A conservative 0.5-second minimum interval between request starts by default.
27
+
28
+ ### Changed
29
+
30
+ - CAPTCHA responses are never retried; batches and iterators stop immediately on a block.
31
+ - Retry handling honors `Retry-After` for both HTTP 429 and HTTP 503 responses.
32
+ - Block detection now requires strong evidence and ignores isolated `captcha` text in normal scripts.
33
+
7
34
  ## [0.3.0] - 2026-07-25
8
35
 
9
36
  ### 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.2
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
@@ -27,6 +27,7 @@ Requires-Dist: beautifulsoup4<5,>=4.12
27
27
  Requires-Dist: httpx[http2]<1,>=0.28
28
28
  Requires-Dist: lxml<7,>=5
29
29
  Requires-Dist: pydantic<3,>=2.10
30
+ Requires-Dist: tzdata>=2025.2
30
31
  Provides-Extra: postgres
31
32
  Requires-Dist: asyncpg<1,>=0.30; extra == "postgres"
32
33
  Provides-Extra: redis
@@ -43,9 +44,8 @@ Dynamic: license-file
43
44
  [![Python](https://img.shields.io/pypi/pyversions/flru-parser.svg)](https://pypi.org/project/flru-parser/)
44
45
  [![License](https://img.shields.io/pypi/l/flru-parser.svg)](LICENSE)
45
46
  [![Typing](https://img.shields.io/badge/typing-PEP%20561-blue)](src/flru/py.typed)
46
- [![Coverage](https://img.shields.io/badge/branch%20coverage-85.76%25-brightgreen)](#quality)
47
- [![Tests](https://img.shields.io/badge/tests-47%20passed-brightgreen)](#quality)
48
- [![CI](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml)
47
+ [![Tests](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml?query=branch%3Amain)
48
+ [![Codecov](https://codecov.io/gh/N3ffus/flru-parser/branch/main/graph/badge.svg)](https://codecov.io/gh/N3ffus/flru-parser/branch/main)
49
49
 
50
50
  A typed, resilient, read-only Python client for public pages on [FL.ru](https://www.fl.ru/).
51
51
  The default API is intentionally small; production controls remain available when needed.
@@ -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 |
@@ -368,13 +370,7 @@ Install `flru-parser[observability]` before using these adapters.
368
370
 
369
371
  ## Quality
370
372
 
371
- Current verified result for **0.3.0**:
372
-
373
- ```text
374
- 47 passed, 1 optional Hypothesis suite skipped locally
375
- Branch coverage: 85.76%
376
- Coverage gate: 85%
377
- ```
373
+ The CI and Codecov badges above always show the current `main` result.
378
374
 
379
375
  Run the complete local checks:
380
376
 
@@ -402,8 +398,8 @@ The release workflow uses PyPI Trusted Publishing and checks that the Git tag ma
402
398
 
403
399
  ```bash
404
400
  uv run python scripts/configure_project.py YOUR_GITHUB_USERNAME
405
- git tag -a v0.3.0 -m "flru-parser 0.3.0"
406
- git push origin main v0.3.0
401
+ git tag -a v0.3.2 -m "flru-parser 0.3.2"
402
+ git push origin main v0.3.2
407
403
  ```
408
404
 
409
405
  See [docs/PUBLISHING.md](docs/PUBLISHING.md) for the one-time PyPI and GitHub setup.
@@ -4,9 +4,8 @@
4
4
  [![Python](https://img.shields.io/pypi/pyversions/flru-parser.svg)](https://pypi.org/project/flru-parser/)
5
5
  [![License](https://img.shields.io/pypi/l/flru-parser.svg)](LICENSE)
6
6
  [![Typing](https://img.shields.io/badge/typing-PEP%20561-blue)](src/flru/py.typed)
7
- [![Coverage](https://img.shields.io/badge/branch%20coverage-85.76%25-brightgreen)](#quality)
8
- [![Tests](https://img.shields.io/badge/tests-47%20passed-brightgreen)](#quality)
9
- [![CI](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml)
7
+ [![Tests](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml?query=branch%3Amain)
8
+ [![Codecov](https://codecov.io/gh/N3ffus/flru-parser/branch/main/graph/badge.svg)](https://codecov.io/gh/N3ffus/flru-parser/branch/main)
10
9
 
11
10
  A typed, resilient, read-only Python client for public pages on [FL.ru](https://www.fl.ru/).
12
11
  The default API is intentionally small; production controls remain available when needed.
@@ -285,8 +284,10 @@ See [docs/SIMPLE_API.md](docs/SIMPLE_API.md) for the complete high-level API and
285
284
  - Unclassified empty pages raise `EmptyPageError`.
286
285
  - A recognized end page contains the `catalog_end` diagnostic warning.
287
286
  - HTTP 429 can pause all concurrent workers through a shared cooldown.
287
+ - HTTP 429 and 503 honor `Retry-After` when the server supplies it.
288
288
  - Circuit breakers are scoped by endpoint and proxy by default.
289
- - CAPTCHA and block pages are detected, not bypassed.
289
+ - CAPTCHA and block pages are detected, not bypassed or retried. Their HTML and sanitized response
290
+ metadata are saved to `.flru-debug/blocked-.../`; this directory is ignored by Git.
290
291
  - Requests are restricted to allowed FL.ru hosts and safe redirects.
291
292
 
292
293
  ## Observability
@@ -317,7 +318,7 @@ Install `flru-parser[observability]` before using these adapters.
317
318
 
318
319
  | Exception | Meaning |
319
320
  |---|---|
320
- | `BlockedError` | CAPTCHA, anti-bot or access-block page detected |
321
+ | `BlockedError` | CAPTCHA, anti-bot or access-block page detected; exposes `status_code` and `debug_path` |
321
322
  | `AuthenticationRequired` | Authenticated cookies are required |
322
323
  | `RateLimitedError` | HTTP 429 after retry policy exhaustion |
323
324
  | `CircuitOpenError` | A scoped circuit breaker is open |
@@ -329,13 +330,7 @@ Install `flru-parser[observability]` before using these adapters.
329
330
 
330
331
  ## Quality
331
332
 
332
- Current verified result for **0.3.0**:
333
-
334
- ```text
335
- 47 passed, 1 optional Hypothesis suite skipped locally
336
- Branch coverage: 85.76%
337
- Coverage gate: 85%
338
- ```
333
+ The CI and Codecov badges above always show the current `main` result.
339
334
 
340
335
  Run the complete local checks:
341
336
 
@@ -363,8 +358,8 @@ The release workflow uses PyPI Trusted Publishing and checks that the Git tag ma
363
358
 
364
359
  ```bash
365
360
  uv run python scripts/configure_project.py YOUR_GITHUB_USERNAME
366
- git tag -a v0.3.0 -m "flru-parser 0.3.0"
367
- git push origin main v0.3.0
361
+ git tag -a v0.3.2 -m "flru-parser 0.3.2"
362
+ git push origin main v0.3.2
368
363
  ```
369
364
 
370
365
  See [docs/PUBLISHING.md](docs/PUBLISHING.md) for the one-time PyPI and GitHub setup.
@@ -34,7 +34,7 @@ uv pip install \
34
34
  --python /tmp/flru-test/bin/python \
35
35
  --index-url https://test.pypi.org/simple/ \
36
36
  --extra-index-url https://pypi.org/simple/ \
37
- flru-parser==0.3.0
37
+ flru-parser==0.3.2
38
38
  ```
39
39
 
40
40
  On Windows, use `/tmp/flru-test/Scripts/python.exe` instead.
@@ -47,8 +47,8 @@ On Windows, use `/tmp/flru-test/Scripts/python.exe` instead.
47
47
  4. Commit, create an annotated tag, and push it:
48
48
 
49
49
  ```bash
50
- git tag -a v0.3.0 -m "flru-parser 0.3.0"
51
- git push origin main v0.3.0
50
+ git tag -a v0.3.2 -m "flru-parser 0.3.2"
51
+ git push origin main v0.3.2
52
52
  ```
53
53
 
54
54
  The workflow builds distributions once, validates them, and publishes the exact same artifacts through PyPI Trusted Publishing.
@@ -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.2"
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"
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "httpx[http2]>=0.28,<1",
18
18
  "lxml>=5,<7",
19
19
  "pydantic>=2.10,<3",
20
+ "tzdata>=2025.2",
20
21
  ]
21
22
  classifiers = [
22
23
  "Development Status :: 4 - Beta",
@@ -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.2"
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,8 +15,14 @@ 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
19
- from .filters import ProjectFilters
18
+ from .exceptions import (
19
+ BlockedError,
20
+ EmptyPageError,
21
+ ParseError,
22
+ RobotsDeniedError,
23
+ SelectorDriftError,
24
+ )
25
+ from .filters import ProjectFilters, ProjectType
20
26
  from .models import (
21
27
  Category,
22
28
  CrawlCheckpoint,
@@ -25,6 +31,7 @@ from .models import (
25
31
  PageData,
26
32
  PortfolioItem,
27
33
  ProjectDetail,
34
+ ProjectKind,
28
35
  ProjectPage,
29
36
  ProjectSummary,
30
37
  RequestMetrics,
@@ -44,6 +51,26 @@ from .robots import RobotsPolicy
44
51
  from .state import CrawlStateStore, record_for
45
52
  from .transport import ResilientTransport
46
53
 
54
+ T = TypeVar("T")
55
+
56
+
57
+ async def _gather_batch_or_stop_on_block(
58
+ awaitables: Iterable[Awaitable[T]],
59
+ ) -> list[T | BaseException]:
60
+ """Cancel outstanding work when a block makes further scraping inappropriate."""
61
+ tasks = [asyncio.ensure_future(awaitable) for awaitable in awaitables]
62
+ if not tasks:
63
+ return []
64
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
65
+ for task in done:
66
+ error = task.exception()
67
+ if isinstance(error, BlockedError):
68
+ for pending_task in pending:
69
+ pending_task.cancel()
70
+ await asyncio.gather(*pending, return_exceptions=True)
71
+ raise error
72
+ return list(await asyncio.gather(*tasks, return_exceptions=True))
73
+
47
74
 
48
75
  class FLClient:
49
76
  """Asynchronous, read-only client/parser for public FL.ru pages."""
@@ -173,6 +200,14 @@ class FLClient:
173
200
  timezone_name=self.config.parser_timezone,
174
201
  )
175
202
  self._validate_project_page(result)
203
+ if filters and filters.project_types:
204
+ kind_map = {
205
+ ProjectType.ORDER: ProjectKind.ORDER,
206
+ ProjectType.VACANCY: ProjectKind.VACANCY,
207
+ ProjectType.CONTEST: ProjectKind.CONTEST,
208
+ }
209
+ requested = {kind_map[item] for item in filters.project_types}
210
+ result.items = [item for item in result.items if item.kind in requested]
176
211
  return result
177
212
  except ParseError as exc:
178
213
  await self._record_parse_failure(effective_url, html, exc)
@@ -216,15 +251,17 @@ class FLClient:
216
251
  params=params,
217
252
  )
218
253
 
219
- values = await asyncio.gather(*(load(page) for page in page_numbers), return_exceptions=True)
254
+ values = await _gather_batch_or_stop_on_block(load(page) for page in page_numbers)
220
255
  result: BatchResult[int, ProjectPage] = BatchResult()
221
256
  for page, value in zip(page_numbers, values, strict=True):
222
257
  if isinstance(value, asyncio.CancelledError):
223
258
  raise value
259
+ if isinstance(value, BlockedError):
260
+ raise value
224
261
  if isinstance(value, Exception):
225
262
  result.failed.append(BatchError(page, value))
226
263
  else:
227
- result.successful.append(value)
264
+ result.successful.append(cast(ProjectPage, value))
228
265
  return result
229
266
 
230
267
  async def get_projects_batch(
@@ -307,6 +344,16 @@ class FLClient:
307
344
  fail_fast: bool = True,
308
345
  ) -> AsyncIterator[ProjectSummary]:
309
346
  seen: set[int] = set()
347
+ allowed_kinds = {
348
+ ProjectType.ORDER: ProjectKind.ORDER,
349
+ ProjectType.VACANCY: ProjectKind.VACANCY,
350
+ ProjectType.CONTEST: ProjectKind.CONTEST,
351
+ }
352
+ requested_kinds = (
353
+ {allowed_kinds[item] for item in filters.project_types}
354
+ if filters and filters.project_types
355
+ else set()
356
+ )
310
357
  async for page in self.iter_project_pages(
311
358
  start_page=start_page,
312
359
  max_pages=max_pages,
@@ -317,6 +364,11 @@ class FLClient:
317
364
  fail_fast=fail_fast,
318
365
  ):
319
366
  for item in page.items:
367
+ # FL.ru can include pinned cards of another kind even when a
368
+ # `kind` filter is present, so enforce the requested kinds
369
+ # locally as well.
370
+ if requested_kinds and item.kind not in requested_kinds:
371
+ continue
320
372
  if deduplicate and item.id in seen:
321
373
  continue
322
374
  seen.add(item.id)
@@ -461,15 +513,17 @@ class FLClient:
461
513
  async with semaphore:
462
514
  return await self.get_freelancers_page(page, category=category)
463
515
 
464
- values = await asyncio.gather(*(load(page) for page in page_numbers), return_exceptions=True)
516
+ values = await _gather_batch_or_stop_on_block(load(page) for page in page_numbers)
465
517
  result: BatchResult[int, FreelancerPage] = BatchResult()
466
518
  for page, value in zip(page_numbers, values, strict=True):
467
519
  if isinstance(value, asyncio.CancelledError):
468
520
  raise value
521
+ if isinstance(value, BlockedError):
522
+ raise value
469
523
  if isinstance(value, Exception):
470
524
  result.failed.append(BatchError(page, value))
471
525
  else:
472
- result.successful.append(value)
526
+ result.successful.append(cast(FreelancerPage, value))
473
527
  return result
474
528
 
475
529
  async def get_freelancers_batch(
@@ -555,16 +609,18 @@ class FLClient:
555
609
  async with semaphore:
556
610
  return await self.get_project(value)
557
611
 
558
- values = await asyncio.gather(*(load(project) for project in projects), return_exceptions=True)
612
+ values = await _gather_batch_or_stop_on_block(load(project) for project in projects)
559
613
  result: BatchResult[int | str, ProjectDetail] = BatchResult()
560
614
  for project, value in zip(projects, values, strict=True):
561
615
  key: int | str = project.id if isinstance(project, ProjectSummary) else project
562
616
  if isinstance(value, asyncio.CancelledError):
563
617
  raise value
618
+ if isinstance(value, BlockedError):
619
+ raise value
564
620
  if isinstance(value, Exception):
565
621
  result.failed.append(BatchError(key, value))
566
622
  else:
567
- result.successful.append(value)
623
+ result.successful.append(cast(ProjectDetail, value))
568
624
  return result
569
625
 
570
626
  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:
@@ -444,12 +444,18 @@ def _project_filters(
444
444
  types: ProjectTypeInput,
445
445
  with_budget: bool | None,
446
446
  ) -> ProjectFilters:
447
+ # The public "projects" API represents FL.ru orders. FL.ru's unfiltered
448
+ # catalog also contains vacancies and contests, so request orders unless
449
+ # callers explicitly select one or more kinds.
450
+ project_types = _normalize_project_types(types)
451
+ if types is None:
452
+ project_types = frozenset({ProjectType.ORDER})
447
453
  return ProjectFilters(
448
454
  query=query,
449
455
  category=category,
450
456
  budget_from=min_budget,
451
457
  budget_to=max_budget,
452
- project_types=_normalize_project_types(types),
458
+ project_types=project_types,
453
459
  only_with_budget=with_budget,
454
460
  )
455
461
 
@@ -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."""
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  from pydantic import BaseModel, ConfigDict, Field
9
9
 
10
- PARSER_VERSION = "0.3.0"
10
+ PARSER_VERSION = "0.3.2"
11
11
  SCHEMA_VERSION = "1"
12
12
 
13
13
 
@@ -39,7 +39,7 @@ from .common import (
39
39
 
40
40
  TITLE_SELECTORS = ("h1", '[itemprop="name"]', ".b-page__title", ".project-title")
41
41
  DESCRIPTION_SELECTORS = (
42
- '[itemprop="description"]', ".b-layout__txt_padbot_20", ".b-post__txt",
42
+ '[itemprop="description"]', ".b-layout__txt_padbot_20", ".b-post__txt.text-5",
43
43
  ".project-description", '[class*="description"]',
44
44
  )
45
45
 
@@ -48,8 +48,12 @@ def _find_card(anchor: Tag) -> Tag:
48
48
  for parent in anchor.parents:
49
49
  if not isinstance(parent, Tag):
50
50
  continue
51
- classes = " ".join(parent.get("class", []))
52
- if parent.name == "article" or re.search(r"(?:^|[-_ ])(?:project|b-post)(?:[-_ ]|$)", classes):
51
+ classes = set(parent.get("class", []))
52
+ if (
53
+ parent.name == "article"
54
+ or classes.intersection({"b-post", "project-card", "project", "catalog-item"})
55
+ or str(parent.get("data-id", "")).startswith("qa-lenta-")
56
+ ):
53
57
  return parent
54
58
  if parent.name in {"main", "body"}:
55
59
  break
@@ -124,7 +128,10 @@ def _next_page_url(soup: BeautifulSoup, url: str, page: int) -> str | None:
124
128
  return absolute_url(url, str(explicit.get("href")))
125
129
  for anchor in soup.select("a[href]"):
126
130
  href = absolute_url(url, str(anchor.get("href")))
127
- if href and parse_qs(urlsplit(href).query).get("page") == [str(page + 1)]:
131
+ if href and (
132
+ parse_qs(urlsplit(href).query).get("page") == [str(page + 1)]
133
+ or re.search(rf"/page-{page + 1}/?$", urlsplit(href).path)
134
+ ):
128
135
  return href
129
136
  return None
130
137
 
@@ -33,9 +33,7 @@ def redact_url(url: str | None) -> str | None:
33
33
  parts = urlsplit(url)
34
34
  hostname = parts.hostname or ""
35
35
  port = f":{parts.port}" if parts.port else ""
36
- userinfo = ""
37
- if parts.username:
38
- userinfo = f"{parts.username}:***@"
36
+ userinfo = "***@" if parts.username else ""
39
37
  netloc = f"{userinfo}{hostname}{port}"
40
38
  query = _SECRET_QUERY_RE.sub(lambda match: f"{match.group(1)}=***", parts.query)
41
39
  return urlunsplit(SplitResult(parts.scheme, netloc, parts.path, query, parts.fragment))
@@ -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():