flru-parser 0.3.0__py3-none-any.whl
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/__init__.py +138 -0
- flru/batch.py +28 -0
- flru/canary.py +33 -0
- flru/client.py +771 -0
- flru/config.py +114 -0
- flru/cookies.py +15 -0
- flru/easy.py +514 -0
- flru/exceptions.py +60 -0
- flru/filters.py +38 -0
- flru/integrations/__init__.py +1 -0
- flru/integrations/opentelemetry.py +29 -0
- flru/integrations/prometheus.py +35 -0
- flru/models.py +297 -0
- flru/observability.py +118 -0
- flru/parsers/__init__.py +13 -0
- flru/parsers/common.py +227 -0
- flru/parsers/freelancers.py +97 -0
- flru/parsers/projects.py +281 -0
- flru/parsers/users.py +193 -0
- flru/proxy.py +87 -0
- flru/py.typed +0 -0
- flru/resilience.py +157 -0
- flru/robots.py +45 -0
- flru/security.py +41 -0
- flru/state.py +324 -0
- flru/sync.py +218 -0
- flru/transport.py +362 -0
- flru_parser-0.3.0.dist-info/METADATA +421 -0
- flru_parser-0.3.0.dist-info/RECORD +33 -0
- flru_parser-0.3.0.dist-info/WHEEL +5 -0
- flru_parser-0.3.0.dist-info/entry_points.txt +2 -0
- flru_parser-0.3.0.dist-info/licenses/LICENSE +21 -0
- flru_parser-0.3.0.dist-info/top_level.txt +1 -0
flru/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Literal
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from .exceptions import ConfigurationError
|
|
10
|
+
|
|
11
|
+
DEFAULT_USER_AGENTS = (
|
|
12
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
13
|
+
"(KHTML, like Gecko) Chrome/131.0 Safari/537.36",
|
|
14
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
15
|
+
"(KHTML, like Gecko) Chrome/130.0 Safari/537.36",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True, frozen=True)
|
|
20
|
+
class RetryConfig:
|
|
21
|
+
max_attempts: int = 5
|
|
22
|
+
base_delay: float = 0.75
|
|
23
|
+
max_delay: float = 30.0
|
|
24
|
+
jitter: float = 0.35
|
|
25
|
+
retry_statuses: frozenset[int] = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
26
|
+
respect_retry_after: bool = True
|
|
27
|
+
total_timeout: float = 90.0
|
|
28
|
+
max_total_delay: float = 45.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True, frozen=True)
|
|
32
|
+
class RateLimitConfig:
|
|
33
|
+
requests_per_second: float = 1.0
|
|
34
|
+
burst: int = 2
|
|
35
|
+
max_concurrency: int = 5
|
|
36
|
+
min_interval: float = 0.0
|
|
37
|
+
interval_jitter: float = 0.15
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True, frozen=True)
|
|
41
|
+
class CircuitBreakerConfig:
|
|
42
|
+
enabled: bool = True
|
|
43
|
+
failure_threshold: int = 8
|
|
44
|
+
recovery_timeout: float = 60.0
|
|
45
|
+
half_open_max_calls: int = 1
|
|
46
|
+
scope: Literal["global", "endpoint", "endpoint_proxy"] = "endpoint_proxy"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True, frozen=True)
|
|
50
|
+
class ProxyConfig:
|
|
51
|
+
urls: tuple[str, ...] = ()
|
|
52
|
+
strategy: Literal["round_robin", "random"] = "round_robin"
|
|
53
|
+
max_failures: int = 3
|
|
54
|
+
cooldown: float = 120.0
|
|
55
|
+
direct_fallback: bool = True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True, frozen=True)
|
|
59
|
+
class TimeoutConfig:
|
|
60
|
+
connect: float = 10.0
|
|
61
|
+
read: float = 30.0
|
|
62
|
+
write: float = 15.0
|
|
63
|
+
pool: float = 10.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True, frozen=True)
|
|
67
|
+
class ClientConfig:
|
|
68
|
+
base_url: str = "https://www.fl.ru"
|
|
69
|
+
retry: RetryConfig = field(default_factory=RetryConfig)
|
|
70
|
+
rate_limit: RateLimitConfig = field(default_factory=RateLimitConfig)
|
|
71
|
+
circuit_breaker: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig)
|
|
72
|
+
proxies: ProxyConfig = field(default_factory=ProxyConfig)
|
|
73
|
+
timeout: TimeoutConfig = field(default_factory=TimeoutConfig)
|
|
74
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
75
|
+
cookies: Mapping[str, str] = field(default_factory=dict)
|
|
76
|
+
user_agents: tuple[str, ...] = DEFAULT_USER_AGENTS
|
|
77
|
+
rotate_user_agents: bool = True
|
|
78
|
+
follow_redirects: bool = True
|
|
79
|
+
verify_ssl: bool = True
|
|
80
|
+
http2: bool = True
|
|
81
|
+
max_connections: int = 100
|
|
82
|
+
max_keepalive_connections: int = 20
|
|
83
|
+
keepalive_expiry: float = 30.0
|
|
84
|
+
respect_robots_txt: bool = True
|
|
85
|
+
robots_fail_closed: bool = False
|
|
86
|
+
robots_cache_ttl: float = 3600.0
|
|
87
|
+
store_raw_html: bool = False
|
|
88
|
+
store_failed_html: bool = False
|
|
89
|
+
failed_html_directory: str | None = None
|
|
90
|
+
detect_blocks: bool = True
|
|
91
|
+
allowed_hosts: frozenset[str] = frozenset({"fl.ru", "www.fl.ru"})
|
|
92
|
+
allow_subdomains: bool = True
|
|
93
|
+
parser_timezone: str = "Europe/Moscow"
|
|
94
|
+
strict_parsing: bool = True
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
host = (urlsplit(self.base_url).hostname or "").casefold()
|
|
98
|
+
object.__setattr__(self, "headers", MappingProxyType(dict(self.headers)))
|
|
99
|
+
object.__setattr__(self, "cookies", MappingProxyType(dict(self.cookies)))
|
|
100
|
+
if self.rate_limit.requests_per_second <= 0:
|
|
101
|
+
raise ConfigurationError("requests_per_second must be positive")
|
|
102
|
+
if self.rate_limit.max_concurrency <= 0:
|
|
103
|
+
raise ConfigurationError("max_concurrency must be positive")
|
|
104
|
+
if self.retry.max_attempts <= 0:
|
|
105
|
+
raise ConfigurationError("max_attempts must be positive")
|
|
106
|
+
if self.retry.total_timeout <= 0:
|
|
107
|
+
raise ConfigurationError("total_timeout must be positive")
|
|
108
|
+
if not self.user_agents:
|
|
109
|
+
raise ConfigurationError("at least one user agent is required")
|
|
110
|
+
if not host or not any(host == allowed or host.endswith(f".{allowed}") for allowed in self.allowed_hosts):
|
|
111
|
+
raise ConfigurationError("base_url host must be present in allowed_hosts")
|
|
112
|
+
|
|
113
|
+
def with_cookies(self, cookies: Mapping[str, str]) -> ClientConfig:
|
|
114
|
+
return replace(self, cookies={**self.cookies, **cookies})
|
flru/cookies.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http.cookiejar import MozillaCookieJar
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_netscape_cookies(path: str | Path, *, domain_suffix: str = "fl.ru") -> dict[str, str]:
|
|
8
|
+
"""Load cookies exported in Netscape/cookies.txt format."""
|
|
9
|
+
jar = MozillaCookieJar(str(path))
|
|
10
|
+
jar.load(ignore_discard=True, ignore_expires=True)
|
|
11
|
+
return {
|
|
12
|
+
cookie.name: cookie.value
|
|
13
|
+
for cookie in jar
|
|
14
|
+
if cookie.domain.lstrip(".").endswith(domain_suffix)
|
|
15
|
+
}
|
flru/easy.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal, overload
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .client import FLClient
|
|
12
|
+
from .config import ClientConfig
|
|
13
|
+
from .cookies import load_netscape_cookies
|
|
14
|
+
from .filters import ProjectFilters, ProjectType
|
|
15
|
+
from .models import (
|
|
16
|
+
FreelancerSummary,
|
|
17
|
+
PageData,
|
|
18
|
+
ProjectDetail,
|
|
19
|
+
ProjectSummary,
|
|
20
|
+
RequestMetrics,
|
|
21
|
+
UserProfile,
|
|
22
|
+
)
|
|
23
|
+
from .observability import EventHandler
|
|
24
|
+
from .state import CrawlStateStore, SQLiteStateStore
|
|
25
|
+
|
|
26
|
+
Pages = int | Literal["all"]
|
|
27
|
+
ProjectTypeInput = ProjectType | str | Sequence[ProjectType | str] | None
|
|
28
|
+
CookiesInput = Mapping[str, str] | str | Path | None
|
|
29
|
+
ProxyInput = str | Sequence[str] | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Client(FLClient):
|
|
33
|
+
"""Simple high-level API.
|
|
34
|
+
|
|
35
|
+
``Client`` covers common use cases with flat arguments. The lower-level
|
|
36
|
+
:class:`flru.FLClient` and configuration dataclasses remain available for
|
|
37
|
+
applications that need complete control.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
concurrency: int | None = None,
|
|
44
|
+
rps: float | None = None,
|
|
45
|
+
retries: int | None = None,
|
|
46
|
+
timeout: float | None = None,
|
|
47
|
+
proxy: ProxyInput = None,
|
|
48
|
+
cookies: CookiesInput = None,
|
|
49
|
+
strict: bool | None = None,
|
|
50
|
+
respect_robots_txt: bool | None = None,
|
|
51
|
+
http2: bool | None = None,
|
|
52
|
+
verify_ssl: bool | None = None,
|
|
53
|
+
config: ClientConfig | None = None,
|
|
54
|
+
event_handler: EventHandler | None = None,
|
|
55
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Create a client using sensible production defaults.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
concurrency: Maximum simultaneous requests.
|
|
61
|
+
rps: Maximum average requests per second.
|
|
62
|
+
retries: Attempts per logical request, including the first attempt.
|
|
63
|
+
timeout: Read timeout in seconds.
|
|
64
|
+
proxy: One proxy URL or a sequence of proxy URLs.
|
|
65
|
+
cookies: Cookie mapping or a Netscape ``cookies.txt`` path.
|
|
66
|
+
strict: Raise when page structure appears to have changed.
|
|
67
|
+
respect_robots_txt: Respect FL.ru robots.txt directives.
|
|
68
|
+
http2: Enable HTTP/2 support.
|
|
69
|
+
verify_ssl: Verify TLS certificates.
|
|
70
|
+
config: Optional advanced configuration used as the base.
|
|
71
|
+
"""
|
|
72
|
+
base = config or ClientConfig()
|
|
73
|
+
concurrency_value = (
|
|
74
|
+
(3 if config is None else base.rate_limit.max_concurrency)
|
|
75
|
+
if concurrency is None
|
|
76
|
+
else concurrency
|
|
77
|
+
)
|
|
78
|
+
rps_value = (
|
|
79
|
+
(1.0 if config is None else base.rate_limit.requests_per_second)
|
|
80
|
+
if rps is None
|
|
81
|
+
else rps
|
|
82
|
+
)
|
|
83
|
+
retries_value = (
|
|
84
|
+
(5 if config is None else base.retry.max_attempts)
|
|
85
|
+
if retries is None
|
|
86
|
+
else retries
|
|
87
|
+
)
|
|
88
|
+
timeout_value = (
|
|
89
|
+
(30.0 if config is None else base.timeout.read)
|
|
90
|
+
if timeout is None
|
|
91
|
+
else timeout
|
|
92
|
+
)
|
|
93
|
+
strict_value = base.strict_parsing if strict is None else strict
|
|
94
|
+
robots_value = (
|
|
95
|
+
base.respect_robots_txt
|
|
96
|
+
if respect_robots_txt is None
|
|
97
|
+
else respect_robots_txt
|
|
98
|
+
)
|
|
99
|
+
http2_value = base.http2 if http2 is None else http2
|
|
100
|
+
verify_ssl_value = base.verify_ssl if verify_ssl is None else verify_ssl
|
|
101
|
+
|
|
102
|
+
if concurrency_value <= 0:
|
|
103
|
+
raise ValueError("concurrency must be positive")
|
|
104
|
+
if rps_value <= 0:
|
|
105
|
+
raise ValueError("rps must be positive")
|
|
106
|
+
if retries_value <= 0:
|
|
107
|
+
raise ValueError("retries must be positive")
|
|
108
|
+
if timeout_value <= 0:
|
|
109
|
+
raise ValueError("timeout must be positive")
|
|
110
|
+
|
|
111
|
+
proxy_urls = _normalize_proxies(proxy)
|
|
112
|
+
cookie_values = _normalize_cookies(cookies)
|
|
113
|
+
|
|
114
|
+
simple_config = replace(
|
|
115
|
+
base,
|
|
116
|
+
retry=replace(base.retry, max_attempts=retries_value),
|
|
117
|
+
rate_limit=replace(
|
|
118
|
+
base.rate_limit,
|
|
119
|
+
requests_per_second=rps_value,
|
|
120
|
+
max_concurrency=concurrency_value,
|
|
121
|
+
),
|
|
122
|
+
proxies=replace(base.proxies, urls=proxy_urls) if proxy is not None else base.proxies,
|
|
123
|
+
timeout=replace(
|
|
124
|
+
base.timeout,
|
|
125
|
+
connect=min(timeout_value, base.timeout.connect),
|
|
126
|
+
read=timeout_value,
|
|
127
|
+
write=min(timeout_value, base.timeout.write),
|
|
128
|
+
pool=min(timeout_value, base.timeout.pool),
|
|
129
|
+
),
|
|
130
|
+
cookies={**base.cookies, **cookie_values},
|
|
131
|
+
strict_parsing=strict_value,
|
|
132
|
+
respect_robots_txt=robots_value,
|
|
133
|
+
http2=http2_value,
|
|
134
|
+
verify_ssl=verify_ssl_value,
|
|
135
|
+
)
|
|
136
|
+
super().__init__(
|
|
137
|
+
simple_config,
|
|
138
|
+
event_handler=event_handler,
|
|
139
|
+
transport=transport,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@overload
|
|
143
|
+
async def projects(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
details: Literal[False] = False,
|
|
147
|
+
pages: Pages = 1,
|
|
148
|
+
start_page: int = 1,
|
|
149
|
+
query: str | None = None,
|
|
150
|
+
category: str | None = None,
|
|
151
|
+
min_budget: Decimal | int | None = None,
|
|
152
|
+
max_budget: Decimal | int | None = None,
|
|
153
|
+
types: ProjectTypeInput = None,
|
|
154
|
+
with_budget: bool | None = None,
|
|
155
|
+
concurrency: int | None = None,
|
|
156
|
+
fail_fast: bool = True,
|
|
157
|
+
) -> list[ProjectSummary]: ...
|
|
158
|
+
|
|
159
|
+
@overload
|
|
160
|
+
async def projects(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
details: Literal[True],
|
|
164
|
+
pages: Pages = 1,
|
|
165
|
+
start_page: int = 1,
|
|
166
|
+
query: str | None = None,
|
|
167
|
+
category: str | None = None,
|
|
168
|
+
min_budget: Decimal | int | None = None,
|
|
169
|
+
max_budget: Decimal | int | None = None,
|
|
170
|
+
types: ProjectTypeInput = None,
|
|
171
|
+
with_budget: bool | None = None,
|
|
172
|
+
concurrency: int | None = None,
|
|
173
|
+
fail_fast: bool = True,
|
|
174
|
+
) -> list[ProjectDetail]: ...
|
|
175
|
+
|
|
176
|
+
async def projects(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
pages: Pages = 1,
|
|
180
|
+
start_page: int = 1,
|
|
181
|
+
query: str | None = None,
|
|
182
|
+
category: str | None = None,
|
|
183
|
+
min_budget: Decimal | int | None = None,
|
|
184
|
+
max_budget: Decimal | int | None = None,
|
|
185
|
+
types: ProjectTypeInput = None,
|
|
186
|
+
with_budget: bool | None = None,
|
|
187
|
+
details: bool = False,
|
|
188
|
+
concurrency: int | None = None,
|
|
189
|
+
fail_fast: bool = True,
|
|
190
|
+
) -> list[ProjectSummary] | list[ProjectDetail]:
|
|
191
|
+
"""Return projects from one or more catalog pages.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
``await client.projects()`` — first page.
|
|
195
|
+
``await client.projects(pages=5)`` — first five pages.
|
|
196
|
+
``await client.projects(pages="all")`` — the complete catalog.
|
|
197
|
+
``await client.projects(details=True)`` — full project cards.
|
|
198
|
+
"""
|
|
199
|
+
_validate_start_page(start_page)
|
|
200
|
+
max_pages = _normalize_page_count(pages)
|
|
201
|
+
filters = _project_filters(
|
|
202
|
+
query=query,
|
|
203
|
+
category=category,
|
|
204
|
+
min_budget=min_budget,
|
|
205
|
+
max_budget=max_budget,
|
|
206
|
+
types=types,
|
|
207
|
+
with_budget=with_budget,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if max_pages == 1:
|
|
211
|
+
summaries = await self.get_projects(start_page, filters=filters)
|
|
212
|
+
else:
|
|
213
|
+
summaries = await self.crawl_projects(
|
|
214
|
+
start_page=start_page,
|
|
215
|
+
max_pages=max_pages,
|
|
216
|
+
batch_size=concurrency or self.config.rate_limit.max_concurrency,
|
|
217
|
+
filters=filters,
|
|
218
|
+
fail_fast=fail_fast,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if not details:
|
|
222
|
+
return summaries
|
|
223
|
+
|
|
224
|
+
result = await self.get_project_details_result(summaries, concurrency=concurrency)
|
|
225
|
+
if fail_fast:
|
|
226
|
+
result.raise_for_errors()
|
|
227
|
+
return result.successful
|
|
228
|
+
|
|
229
|
+
async def stream_projects(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
pages: Pages = "all",
|
|
233
|
+
start_page: int = 1,
|
|
234
|
+
query: str | None = None,
|
|
235
|
+
category: str | None = None,
|
|
236
|
+
min_budget: Decimal | int | None = None,
|
|
237
|
+
max_budget: Decimal | int | None = None,
|
|
238
|
+
types: ProjectTypeInput = None,
|
|
239
|
+
with_budget: bool | None = None,
|
|
240
|
+
concurrency: int | None = None,
|
|
241
|
+
fail_fast: bool = True,
|
|
242
|
+
) -> AsyncIterator[ProjectSummary]:
|
|
243
|
+
"""Stream projects without retaining the complete catalog in memory."""
|
|
244
|
+
_validate_start_page(start_page)
|
|
245
|
+
filters = _project_filters(
|
|
246
|
+
query=query,
|
|
247
|
+
category=category,
|
|
248
|
+
min_budget=min_budget,
|
|
249
|
+
max_budget=max_budget,
|
|
250
|
+
types=types,
|
|
251
|
+
with_budget=with_budget,
|
|
252
|
+
)
|
|
253
|
+
async for project in self.iter_projects(
|
|
254
|
+
start_page=start_page,
|
|
255
|
+
max_pages=_normalize_page_count(pages),
|
|
256
|
+
batch_size=concurrency or self.config.rate_limit.max_concurrency,
|
|
257
|
+
filters=filters,
|
|
258
|
+
fail_fast=fail_fast,
|
|
259
|
+
):
|
|
260
|
+
yield project
|
|
261
|
+
|
|
262
|
+
async def project(self, project: int | str) -> ProjectDetail:
|
|
263
|
+
"""Return one complete project by ID or URL."""
|
|
264
|
+
return await self.get_project(project)
|
|
265
|
+
|
|
266
|
+
async def freelancers(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
pages: Pages = 1,
|
|
270
|
+
start_page: int = 1,
|
|
271
|
+
category: str | None = None,
|
|
272
|
+
concurrency: int | None = None,
|
|
273
|
+
fail_fast: bool = True,
|
|
274
|
+
) -> list[FreelancerSummary]:
|
|
275
|
+
"""Return freelancers from one or more catalog pages."""
|
|
276
|
+
_validate_start_page(start_page)
|
|
277
|
+
max_pages = _normalize_page_count(pages)
|
|
278
|
+
if max_pages == 1:
|
|
279
|
+
return await self.get_freelancers(start_page, category=category)
|
|
280
|
+
|
|
281
|
+
return [
|
|
282
|
+
item
|
|
283
|
+
async for item in self.iter_freelancers(
|
|
284
|
+
start_page=start_page,
|
|
285
|
+
max_pages=max_pages,
|
|
286
|
+
batch_size=concurrency or self.config.rate_limit.max_concurrency,
|
|
287
|
+
category=category,
|
|
288
|
+
fail_fast=fail_fast,
|
|
289
|
+
)
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
async def user(
|
|
293
|
+
self,
|
|
294
|
+
username: str,
|
|
295
|
+
*,
|
|
296
|
+
full: bool = False,
|
|
297
|
+
pages: int = 1,
|
|
298
|
+
) -> UserProfile:
|
|
299
|
+
"""Return a user profile; ``full=True`` also loads related sections."""
|
|
300
|
+
if pages <= 0:
|
|
301
|
+
raise ValueError("pages must be positive")
|
|
302
|
+
return await self.get_user(
|
|
303
|
+
username,
|
|
304
|
+
include_projects=full,
|
|
305
|
+
include_reviews=full,
|
|
306
|
+
include_portfolio=full,
|
|
307
|
+
pages=pages,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
async def new_projects(
|
|
311
|
+
self,
|
|
312
|
+
state: CrawlStateStore | str | Path = ".flru-state.db",
|
|
313
|
+
*,
|
|
314
|
+
pages: Pages = 30,
|
|
315
|
+
start_page: int | None = None,
|
|
316
|
+
stop_after_known: int = 20,
|
|
317
|
+
query: str | None = None,
|
|
318
|
+
category: str | None = None,
|
|
319
|
+
min_budget: Decimal | int | None = None,
|
|
320
|
+
max_budget: Decimal | int | None = None,
|
|
321
|
+
types: ProjectTypeInput = None,
|
|
322
|
+
with_budget: bool | None = None,
|
|
323
|
+
concurrency: int | None = None,
|
|
324
|
+
) -> list[ProjectSummary]:
|
|
325
|
+
"""Return only new or changed projects using durable crawl state.
|
|
326
|
+
|
|
327
|
+
Passing a filesystem path automatically creates and closes a SQLite
|
|
328
|
+
state store. Advanced applications can pass any ``CrawlStateStore``.
|
|
329
|
+
"""
|
|
330
|
+
if start_page is not None:
|
|
331
|
+
_validate_start_page(start_page)
|
|
332
|
+
if stop_after_known <= 0:
|
|
333
|
+
raise ValueError("stop_after_known must be positive")
|
|
334
|
+
store, owned = _state_store(state)
|
|
335
|
+
filters = _project_filters(
|
|
336
|
+
query=query,
|
|
337
|
+
category=category,
|
|
338
|
+
min_budget=min_budget,
|
|
339
|
+
max_budget=max_budget,
|
|
340
|
+
types=types,
|
|
341
|
+
with_budget=with_budget,
|
|
342
|
+
)
|
|
343
|
+
try:
|
|
344
|
+
crawl_options: dict[str, Any] = {
|
|
345
|
+
"max_pages": _normalize_page_count(pages),
|
|
346
|
+
"batch_size": concurrency or self.config.rate_limit.max_concurrency,
|
|
347
|
+
"filters": filters,
|
|
348
|
+
"stop_after_known": stop_after_known,
|
|
349
|
+
}
|
|
350
|
+
if start_page is not None:
|
|
351
|
+
crawl_options["start_page"] = start_page
|
|
352
|
+
return [
|
|
353
|
+
project
|
|
354
|
+
async for project in self.iter_new_projects(
|
|
355
|
+
store,
|
|
356
|
+
**crawl_options,
|
|
357
|
+
)
|
|
358
|
+
]
|
|
359
|
+
finally:
|
|
360
|
+
if owned:
|
|
361
|
+
await store.close()
|
|
362
|
+
|
|
363
|
+
async def page(self, url: str) -> PageData:
|
|
364
|
+
"""Parse an arbitrary allowed FL.ru page."""
|
|
365
|
+
return await self.get_page(url)
|
|
366
|
+
|
|
367
|
+
async def stats(self) -> RequestMetrics:
|
|
368
|
+
"""Return request and parser metrics."""
|
|
369
|
+
return await self.metrics()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _normalize_proxies(proxy: ProxyInput) -> tuple[str, ...]:
|
|
373
|
+
if proxy is None:
|
|
374
|
+
return ()
|
|
375
|
+
if isinstance(proxy, str):
|
|
376
|
+
return (proxy,)
|
|
377
|
+
return tuple(proxy)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _normalize_cookies(cookies: CookiesInput) -> dict[str, str]:
|
|
381
|
+
if cookies is None:
|
|
382
|
+
return {}
|
|
383
|
+
if isinstance(cookies, Mapping):
|
|
384
|
+
return dict(cookies)
|
|
385
|
+
return load_netscape_cookies(cookies)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _normalize_page_count(pages: Pages) -> int | None:
|
|
389
|
+
if pages == "all":
|
|
390
|
+
return None
|
|
391
|
+
if not isinstance(pages, int) or isinstance(pages, bool) or pages <= 0:
|
|
392
|
+
raise ValueError('pages must be a positive integer or "all"')
|
|
393
|
+
return pages
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _validate_start_page(page: int) -> None:
|
|
397
|
+
if isinstance(page, bool) or page <= 0:
|
|
398
|
+
raise ValueError("start_page must be positive")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _normalize_project_types(types: ProjectTypeInput) -> frozenset[ProjectType]:
|
|
402
|
+
if types is None:
|
|
403
|
+
return frozenset()
|
|
404
|
+
values: Sequence[ProjectType | str]
|
|
405
|
+
if isinstance(types, (ProjectType, str)):
|
|
406
|
+
values = (types,)
|
|
407
|
+
else:
|
|
408
|
+
values = types
|
|
409
|
+
|
|
410
|
+
aliases = {
|
|
411
|
+
"order": ProjectType.ORDER,
|
|
412
|
+
"orders": ProjectType.ORDER,
|
|
413
|
+
"vacancy": ProjectType.VACANCY,
|
|
414
|
+
"vacancies": ProjectType.VACANCY,
|
|
415
|
+
"contest": ProjectType.CONTEST,
|
|
416
|
+
"contests": ProjectType.CONTEST,
|
|
417
|
+
"заказ": ProjectType.ORDER,
|
|
418
|
+
"заказы": ProjectType.ORDER,
|
|
419
|
+
"вакансия": ProjectType.VACANCY,
|
|
420
|
+
"вакансии": ProjectType.VACANCY,
|
|
421
|
+
"конкурс": ProjectType.CONTEST,
|
|
422
|
+
"конкурсы": ProjectType.CONTEST,
|
|
423
|
+
}
|
|
424
|
+
result: set[ProjectType] = set()
|
|
425
|
+
for value in values:
|
|
426
|
+
if isinstance(value, ProjectType):
|
|
427
|
+
result.add(value)
|
|
428
|
+
continue
|
|
429
|
+
normalized = value.strip().casefold()
|
|
430
|
+
try:
|
|
431
|
+
result.add(aliases[normalized] if normalized in aliases else ProjectType(normalized))
|
|
432
|
+
except ValueError as exc:
|
|
433
|
+
choices = ", ".join(item.name.lower() for item in ProjectType)
|
|
434
|
+
raise ValueError(f"unknown project type {value!r}; expected one of: {choices}") from exc
|
|
435
|
+
return frozenset(result)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _project_filters(
|
|
439
|
+
*,
|
|
440
|
+
query: str | None,
|
|
441
|
+
category: str | None,
|
|
442
|
+
min_budget: Decimal | int | None,
|
|
443
|
+
max_budget: Decimal | int | None,
|
|
444
|
+
types: ProjectTypeInput,
|
|
445
|
+
with_budget: bool | None,
|
|
446
|
+
) -> ProjectFilters:
|
|
447
|
+
return ProjectFilters(
|
|
448
|
+
query=query,
|
|
449
|
+
category=category,
|
|
450
|
+
budget_from=min_budget,
|
|
451
|
+
budget_to=max_budget,
|
|
452
|
+
project_types=_normalize_project_types(types),
|
|
453
|
+
only_with_budget=with_budget,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _state_store(state: CrawlStateStore | str | Path) -> tuple[CrawlStateStore, bool]:
|
|
458
|
+
if isinstance(state, (str, Path)):
|
|
459
|
+
return SQLiteStateStore(state), True
|
|
460
|
+
return state, False
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def fetch_projects(
|
|
464
|
+
*,
|
|
465
|
+
client_options: Mapping[str, Any] | None = None,
|
|
466
|
+
**kwargs: Any,
|
|
467
|
+
) -> list[ProjectSummary] | list[ProjectDetail]:
|
|
468
|
+
"""Fetch projects in one call and close the temporary client."""
|
|
469
|
+
async with Client(**dict(client_options or {})) as client:
|
|
470
|
+
return await client.projects(**kwargs)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def fetch_project(
|
|
474
|
+
project_id_or_url: int | str,
|
|
475
|
+
*,
|
|
476
|
+
client_options: Mapping[str, Any] | None = None,
|
|
477
|
+
) -> ProjectDetail:
|
|
478
|
+
"""Fetch one project in one call."""
|
|
479
|
+
async with Client(**dict(client_options or {})) as client:
|
|
480
|
+
return await client.project(project_id_or_url)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
async def fetch_freelancers(
|
|
484
|
+
*,
|
|
485
|
+
client_options: Mapping[str, Any] | None = None,
|
|
486
|
+
**kwargs: Any,
|
|
487
|
+
) -> list[FreelancerSummary]:
|
|
488
|
+
"""Fetch freelancers in one call."""
|
|
489
|
+
async with Client(**dict(client_options or {})) as client:
|
|
490
|
+
return await client.freelancers(**kwargs)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
async def fetch_user(
|
|
494
|
+
username: str,
|
|
495
|
+
*,
|
|
496
|
+
client_options: Mapping[str, Any] | None = None,
|
|
497
|
+
**kwargs: Any,
|
|
498
|
+
) -> UserProfile:
|
|
499
|
+
"""Fetch one user profile in one call."""
|
|
500
|
+
async with Client(**dict(client_options or {})) as client:
|
|
501
|
+
return await client.user(username, **kwargs)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
__all__ = [
|
|
505
|
+
"Client",
|
|
506
|
+
"CookiesInput",
|
|
507
|
+
"Pages",
|
|
508
|
+
"ProjectTypeInput",
|
|
509
|
+
"ProxyInput",
|
|
510
|
+
"fetch_freelancers",
|
|
511
|
+
"fetch_project",
|
|
512
|
+
"fetch_projects",
|
|
513
|
+
"fetch_user",
|
|
514
|
+
]
|
flru/exceptions.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FLRUError(Exception):
|
|
5
|
+
"""Base package exception."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(FLRUError):
|
|
9
|
+
"""Invalid client configuration."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(FLRUError):
|
|
13
|
+
"""Request failed after all retry attempts."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HTTPStatusError(TransportError):
|
|
17
|
+
def __init__(self, status_code: int, url: str, message: str | None = None) -> None:
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.url = url
|
|
20
|
+
super().__init__(message or f"FL.ru returned HTTP {status_code} for {url}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RateLimitedError(HTTPStatusError):
|
|
24
|
+
"""Remote server returned HTTP 429."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BlockedError(TransportError):
|
|
28
|
+
"""A CAPTCHA, anti-bot page, or access block was detected."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationRequired(TransportError):
|
|
32
|
+
"""The requested page requires a valid authenticated session."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CircuitOpenError(TransportError):
|
|
36
|
+
"""Circuit breaker is open and temporarily rejects requests."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RobotsDeniedError(FLRUError):
|
|
40
|
+
"""robots.txt denies access for the configured user agent."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SecurityError(FLRUError):
|
|
44
|
+
"""A URL violates the configured host or redirect policy."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ParseError(FLRUError):
|
|
48
|
+
"""Page could not be parsed into the requested model."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EmptyPageError(ParseError):
|
|
52
|
+
"""A page had no parsed records and could not be identified as catalog end."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SelectorDriftError(ParseError):
|
|
56
|
+
"""Candidate records were present, but selectors could not parse them."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class UnexpectedPageError(ParseError):
|
|
60
|
+
"""The response is valid HTML but not the expected page type."""
|