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/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."""