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.
- {flru_parser-0.3.0 → flru_parser-0.3.2}/CHANGELOG.md +27 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/PKG-INFO +11 -15
- {flru_parser-0.3.0 → flru_parser-0.3.2}/README.md +9 -14
- {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/PUBLISHING.md +3 -3
- {flru_parser-0.3.0 → flru_parser-0.3.2}/pyproject.toml +2 -1
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/__init__.py +1 -1
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/client.py +66 -10
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/config.py +3 -1
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/easy.py +7 -1
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/exceptions.py +13 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/models.py +1 -1
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/projects.py +11 -4
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/security.py +1 -3
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/transport.py +64 -12
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/PKG-INFO +11 -15
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/SOURCES.txt +1 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/requires.txt +1 -0
- flru_parser-0.3.2/tests/test_client_easy_edges.py +147 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_easy_api.py +4 -4
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_freelancers.py +1 -1
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_parsers.py +32 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_resilience.py +1 -1
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_security_transport.py +71 -4
- {flru_parser-0.3.0 → flru_parser-0.3.2}/CODE_OF_CONDUCT.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/CONTRIBUTING.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/LICENSE +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/MANIFEST.in +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/SECURITY.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/SUPPORT.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/ARCHITECTURE.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/SELECTOR_MAINTENANCE.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/SIMPLE_API.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/docs/ru/README.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/README.md +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/basic.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/incremental_sqlite.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/production_config.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/simple_one_shot.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/examples/sync_usage.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/setup.cfg +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/batch.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/canary.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/cookies.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/filters.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/integrations/__init__.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/integrations/opentelemetry.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/integrations/prometheus.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/observability.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/__init__.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/common.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/freelancers.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/parsers/users.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/proxy.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/py.typed +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/resilience.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/robots.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/state.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru/sync.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/dependency_links.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/entry_points.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/src/flru_parser.egg-info/top_level.txt +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/freelancers.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/project.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/projects.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/fixtures/user.html +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_api_extensions.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_client.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_diagnostics_and_common.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_generic_page.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_properties.py +0 -0
- {flru_parser-0.3.0 → flru_parser-0.3.2}/tests/test_robots_cookies.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/flru-parser/)
|
|
44
45
|
[](LICENSE)
|
|
45
46
|
[](src/flru/py.typed)
|
|
46
|
-
[](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml)
|
|
47
|
+
[](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml?query=branch%3Amain)
|
|
48
|
+
[](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
|
-
|
|
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.
|
|
406
|
-
git push origin main v0.3.
|
|
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
|
[](https://pypi.org/project/flru-parser/)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](src/flru/py.typed)
|
|
7
|
-
[](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml)
|
|
7
|
+
[](https://github.com/N3ffus/flru-parser/actions/workflows/ci.yml?query=branch%3Amain)
|
|
8
|
+
[](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
|
-
|
|
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.
|
|
367
|
-
git push origin main v0.3.
|
|
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.
|
|
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.
|
|
51
|
-
git push origin main v0.3.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
19
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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=
|
|
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."""
|
|
@@ -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 =
|
|
52
|
-
if
|
|
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
|
|
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
|
|
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():
|