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/transport.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import email.utils
|
|
5
|
+
import random
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .config import ClientConfig
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
AuthenticationRequired,
|
|
15
|
+
BlockedError,
|
|
16
|
+
HTTPStatusError,
|
|
17
|
+
RateLimitedError,
|
|
18
|
+
TransportError,
|
|
19
|
+
)
|
|
20
|
+
from .observability import EventHandler, Metrics, RequestEvent, Timer, emit
|
|
21
|
+
from .proxy import ProxyPool, ProxyState
|
|
22
|
+
from .resilience import AsyncRateLimiter, CircuitBreakerRegistry, RetryBudget
|
|
23
|
+
from .security import validate_url
|
|
24
|
+
|
|
25
|
+
_BLOCK_MARKERS = (
|
|
26
|
+
"captcha",
|
|
27
|
+
"подтвердите, что вы не робот",
|
|
28
|
+
"доступ временно ограничен",
|
|
29
|
+
"cloudflare",
|
|
30
|
+
)
|
|
31
|
+
_AUTH_MARKERS = (
|
|
32
|
+
"необходимо авторизоваться",
|
|
33
|
+
"войдите в аккаунт",
|
|
34
|
+
)
|
|
35
|
+
_REDIRECT_CODES = {301, 302, 303, 307, 308}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ResilientTransport:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
config: ClientConfig,
|
|
42
|
+
*,
|
|
43
|
+
event_handler: EventHandler | None = None,
|
|
44
|
+
client_transport: httpx.AsyncBaseTransport | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.config = config
|
|
47
|
+
self.metrics = Metrics()
|
|
48
|
+
self._event_handler = event_handler
|
|
49
|
+
self._rate_limiter = AsyncRateLimiter(config.rate_limit)
|
|
50
|
+
self._circuits = CircuitBreakerRegistry(config.circuit_breaker)
|
|
51
|
+
self._proxy_pool = ProxyPool(config.proxies)
|
|
52
|
+
self._clients: dict[str | None, httpx.AsyncClient] = {}
|
|
53
|
+
self._client_transport = client_transport
|
|
54
|
+
self._cookies = dict(config.cookies)
|
|
55
|
+
self._closed = False
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def proxy_pool(self) -> ProxyPool:
|
|
59
|
+
return self._proxy_pool
|
|
60
|
+
|
|
61
|
+
def _make_client(self, proxy: str | None) -> httpx.AsyncClient:
|
|
62
|
+
timeout = httpx.Timeout(
|
|
63
|
+
connect=self.config.timeout.connect,
|
|
64
|
+
read=self.config.timeout.read,
|
|
65
|
+
write=self.config.timeout.write,
|
|
66
|
+
pool=self.config.timeout.pool,
|
|
67
|
+
)
|
|
68
|
+
limits = httpx.Limits(
|
|
69
|
+
max_connections=self.config.max_connections,
|
|
70
|
+
max_keepalive_connections=self.config.max_keepalive_connections,
|
|
71
|
+
keepalive_expiry=self.config.keepalive_expiry,
|
|
72
|
+
)
|
|
73
|
+
user_agent = (
|
|
74
|
+
random.choice(self.config.user_agents)
|
|
75
|
+
if self.config.rotate_user_agents
|
|
76
|
+
else self.config.user_agents[0]
|
|
77
|
+
)
|
|
78
|
+
headers = {
|
|
79
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
80
|
+
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.7",
|
|
81
|
+
"Cache-Control": "no-cache",
|
|
82
|
+
"User-Agent": user_agent,
|
|
83
|
+
**self.config.headers,
|
|
84
|
+
}
|
|
85
|
+
return httpx.AsyncClient(
|
|
86
|
+
timeout=timeout,
|
|
87
|
+
limits=limits,
|
|
88
|
+
headers=headers,
|
|
89
|
+
cookies=self._cookies,
|
|
90
|
+
proxy=proxy,
|
|
91
|
+
verify=self.config.verify_ssl,
|
|
92
|
+
follow_redirects=False,
|
|
93
|
+
http2=self.config.http2,
|
|
94
|
+
transport=self._client_transport,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _client(self, proxy: str | None) -> httpx.AsyncClient:
|
|
98
|
+
if proxy not in self._clients:
|
|
99
|
+
self._clients[proxy] = self._make_client(proxy)
|
|
100
|
+
return self._clients[proxy]
|
|
101
|
+
|
|
102
|
+
def _endpoint(self, url: str) -> str:
|
|
103
|
+
path = urlsplit(url).path.strip("/")
|
|
104
|
+
return path.split("/", 1)[0] or "root"
|
|
105
|
+
|
|
106
|
+
def _circuit_key(self, endpoint: str, proxy: str | None) -> str:
|
|
107
|
+
scope = self.config.circuit_breaker.scope
|
|
108
|
+
if scope == "global":
|
|
109
|
+
return "global"
|
|
110
|
+
if scope == "endpoint":
|
|
111
|
+
return endpoint
|
|
112
|
+
return f"{endpoint}|{proxy or 'direct'}"
|
|
113
|
+
|
|
114
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
115
|
+
if self._closed:
|
|
116
|
+
raise RuntimeError("Transport is closed")
|
|
117
|
+
validate_url(url, self.config.allowed_hosts, self.config.allow_subdomains)
|
|
118
|
+
|
|
119
|
+
endpoint = self._endpoint(url)
|
|
120
|
+
last_error: Exception | None = None
|
|
121
|
+
request_headers = dict(kwargs.pop("headers", {}) or {})
|
|
122
|
+
budget = RetryBudget(
|
|
123
|
+
total_timeout=self.config.retry.total_timeout,
|
|
124
|
+
max_total_delay=self.config.retry.max_total_delay,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
for attempt in range(1, self.config.retry.max_attempts + 1):
|
|
128
|
+
response: httpx.Response | None = None
|
|
129
|
+
proxy_state = await self._proxy_pool.acquire()
|
|
130
|
+
circuit = await self._circuits.get(self._circuit_key(endpoint, proxy_state.url))
|
|
131
|
+
await circuit.before_call()
|
|
132
|
+
timer = Timer()
|
|
133
|
+
await self.metrics.mutate(endpoint=endpoint, requests_total=1)
|
|
134
|
+
await self._emit(RequestEvent("start", method, url, attempt, endpoint, proxy_state.safe_url))
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
async with self._rate_limiter:
|
|
138
|
+
response = await self._request_following_safe_redirects(
|
|
139
|
+
self._client(proxy_state.url), method, url, headers=request_headers, **kwargs
|
|
140
|
+
)
|
|
141
|
+
elapsed = timer.elapsed
|
|
142
|
+
await self.metrics.mutate(
|
|
143
|
+
endpoint=endpoint,
|
|
144
|
+
responses_total=1,
|
|
145
|
+
bytes_received=len(response.content),
|
|
146
|
+
latency_seconds_total=elapsed,
|
|
147
|
+
)
|
|
148
|
+
self._detect_block(response)
|
|
149
|
+
|
|
150
|
+
if response.status_code in self.config.retry.retry_statuses:
|
|
151
|
+
if response.status_code == 429:
|
|
152
|
+
await self.metrics.mutate(rate_limited_total=1)
|
|
153
|
+
retry_after = self._retry_after(response)
|
|
154
|
+
if retry_after is not None:
|
|
155
|
+
await self._rate_limiter.pause_for(retry_after)
|
|
156
|
+
raise RateLimitedError(429, str(response.url))
|
|
157
|
+
raise HTTPStatusError(response.status_code, str(response.url))
|
|
158
|
+
|
|
159
|
+
if response.status_code in {401, 403}:
|
|
160
|
+
raise AuthenticationRequired(
|
|
161
|
+
f"FL.ru returned HTTP {response.status_code} for {response.url}"
|
|
162
|
+
)
|
|
163
|
+
if response.is_error:
|
|
164
|
+
raise HTTPStatusError(response.status_code, str(response.url))
|
|
165
|
+
|
|
166
|
+
await self._proxy_pool.success(proxy_state)
|
|
167
|
+
await circuit.record_success()
|
|
168
|
+
await self._emit(
|
|
169
|
+
RequestEvent(
|
|
170
|
+
"success",
|
|
171
|
+
method,
|
|
172
|
+
str(response.url),
|
|
173
|
+
attempt,
|
|
174
|
+
endpoint,
|
|
175
|
+
proxy_state.safe_url,
|
|
176
|
+
response.status_code,
|
|
177
|
+
elapsed,
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return response
|
|
181
|
+
|
|
182
|
+
except AuthenticationRequired as exc:
|
|
183
|
+
await self.metrics.mutate(endpoint=endpoint, failures_total=1)
|
|
184
|
+
await self._emit_failure(method, url, attempt, endpoint, proxy_state, timer, exc)
|
|
185
|
+
raise
|
|
186
|
+
except HTTPStatusError as exc:
|
|
187
|
+
last_error = exc
|
|
188
|
+
await self.metrics.mutate(endpoint=endpoint, failures_total=1)
|
|
189
|
+
if exc.status_code not in self.config.retry.retry_statuses:
|
|
190
|
+
await self._emit_failure(method, url, attempt, endpoint, proxy_state, timer, exc)
|
|
191
|
+
raise
|
|
192
|
+
await self._proxy_pool.failure(proxy_state, exc)
|
|
193
|
+
except BlockedError as exc:
|
|
194
|
+
last_error = exc
|
|
195
|
+
await self.metrics.mutate(endpoint=endpoint, blocked_total=1, failures_total=1)
|
|
196
|
+
await self._proxy_pool.failure(proxy_state, exc)
|
|
197
|
+
await self._emit(
|
|
198
|
+
RequestEvent(
|
|
199
|
+
"blocked",
|
|
200
|
+
method,
|
|
201
|
+
url,
|
|
202
|
+
attempt,
|
|
203
|
+
endpoint,
|
|
204
|
+
proxy_state.safe_url,
|
|
205
|
+
elapsed=timer.elapsed,
|
|
206
|
+
error=type(exc).__name__,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
except httpx.TransportError as exc:
|
|
210
|
+
last_error = exc
|
|
211
|
+
await self.metrics.mutate(endpoint=endpoint, failures_total=1)
|
|
212
|
+
await self._proxy_pool.failure(proxy_state, exc)
|
|
213
|
+
|
|
214
|
+
if attempt >= self.config.retry.max_attempts:
|
|
215
|
+
await circuit.record_failure()
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
delay = self._retry_delay(attempt, response)
|
|
219
|
+
if not budget.allows(delay):
|
|
220
|
+
await circuit.record_failure()
|
|
221
|
+
break
|
|
222
|
+
budget.consume_delay(delay)
|
|
223
|
+
await self.metrics.mutate(endpoint=endpoint, retries_total=1)
|
|
224
|
+
await self._emit(
|
|
225
|
+
RequestEvent(
|
|
226
|
+
"retry",
|
|
227
|
+
method,
|
|
228
|
+
url,
|
|
229
|
+
attempt,
|
|
230
|
+
endpoint,
|
|
231
|
+
proxy_state.safe_url,
|
|
232
|
+
elapsed=timer.elapsed,
|
|
233
|
+
error=type(last_error).__name__ if last_error else None,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
await asyncio.sleep(delay)
|
|
237
|
+
|
|
238
|
+
await self._emit(
|
|
239
|
+
RequestEvent(
|
|
240
|
+
"failure",
|
|
241
|
+
method,
|
|
242
|
+
url,
|
|
243
|
+
min(self.config.retry.max_attempts, attempt),
|
|
244
|
+
endpoint,
|
|
245
|
+
error=type(last_error).__name__ if last_error else None,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
if isinstance(last_error, TransportError):
|
|
249
|
+
raise last_error
|
|
250
|
+
raise TransportError(f"Request failed: {method} {url}: {last_error}") from last_error
|
|
251
|
+
|
|
252
|
+
async def _request_following_safe_redirects(
|
|
253
|
+
self,
|
|
254
|
+
client: httpx.AsyncClient,
|
|
255
|
+
method: str,
|
|
256
|
+
url: str,
|
|
257
|
+
*,
|
|
258
|
+
headers: dict[str, str],
|
|
259
|
+
**kwargs: Any,
|
|
260
|
+
) -> httpx.Response:
|
|
261
|
+
current_method = method
|
|
262
|
+
current_url = url
|
|
263
|
+
body_kwargs = dict(kwargs)
|
|
264
|
+
for _ in range(10):
|
|
265
|
+
response = await client.request(current_method, current_url, headers=headers, **body_kwargs)
|
|
266
|
+
if not self.config.follow_redirects or response.status_code not in _REDIRECT_CODES:
|
|
267
|
+
return response
|
|
268
|
+
location = response.headers.get("Location")
|
|
269
|
+
if not location:
|
|
270
|
+
return response
|
|
271
|
+
current_url = urljoin(str(response.url), location)
|
|
272
|
+
validate_url(current_url, self.config.allowed_hosts, self.config.allow_subdomains)
|
|
273
|
+
if response.status_code == 303 or (
|
|
274
|
+
response.status_code in {301, 302} and current_method.upper() == "POST"
|
|
275
|
+
):
|
|
276
|
+
current_method = "GET"
|
|
277
|
+
body_kwargs.pop("content", None)
|
|
278
|
+
body_kwargs.pop("data", None)
|
|
279
|
+
body_kwargs.pop("json", None)
|
|
280
|
+
raise TransportError(f"Too many redirects for {url}")
|
|
281
|
+
|
|
282
|
+
async def emit_event(self, event: RequestEvent) -> None:
|
|
283
|
+
"""Emit a sanitized event without allowing handler failures to break requests."""
|
|
284
|
+
if not await emit(self._event_handler, event):
|
|
285
|
+
await self.metrics.mutate(event_handler_failures_total=1)
|
|
286
|
+
|
|
287
|
+
async def _emit(self, event: RequestEvent) -> None:
|
|
288
|
+
await self.emit_event(event)
|
|
289
|
+
|
|
290
|
+
async def _emit_failure(
|
|
291
|
+
self,
|
|
292
|
+
method: str,
|
|
293
|
+
url: str,
|
|
294
|
+
attempt: int,
|
|
295
|
+
endpoint: str,
|
|
296
|
+
proxy_state: ProxyState,
|
|
297
|
+
timer: Timer,
|
|
298
|
+
error: Exception,
|
|
299
|
+
) -> None:
|
|
300
|
+
await self._emit(
|
|
301
|
+
RequestEvent(
|
|
302
|
+
"failure",
|
|
303
|
+
method,
|
|
304
|
+
url,
|
|
305
|
+
attempt,
|
|
306
|
+
endpoint,
|
|
307
|
+
proxy_state.safe_url,
|
|
308
|
+
getattr(error, "status_code", None),
|
|
309
|
+
timer.elapsed,
|
|
310
|
+
type(error).__name__,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _retry_delay(self, attempt: int, response: httpx.Response | None) -> float:
|
|
315
|
+
if response is not None and self.config.retry.respect_retry_after:
|
|
316
|
+
parsed = self._retry_after(response)
|
|
317
|
+
if parsed is not None:
|
|
318
|
+
return min(parsed, self.config.retry.max_delay)
|
|
319
|
+
exponential = min(
|
|
320
|
+
self.config.retry.max_delay,
|
|
321
|
+
self.config.retry.base_delay * (2 ** (attempt - 1)),
|
|
322
|
+
)
|
|
323
|
+
return exponential + random.uniform(0, self.config.retry.jitter)
|
|
324
|
+
|
|
325
|
+
def _retry_after(self, response: httpx.Response) -> float | None:
|
|
326
|
+
return self._parse_retry_after(response.headers.get("Retry-After"))
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
330
|
+
if not value:
|
|
331
|
+
return None
|
|
332
|
+
try:
|
|
333
|
+
return max(0.0, float(value))
|
|
334
|
+
except ValueError:
|
|
335
|
+
try:
|
|
336
|
+
parsed = email.utils.parsedate_to_datetime(value)
|
|
337
|
+
if parsed.tzinfo is None:
|
|
338
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
339
|
+
return max(0.0, (parsed - datetime.now(timezone.utc)).total_seconds())
|
|
340
|
+
except (TypeError, ValueError):
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def _detect_block(self, response: httpx.Response) -> None:
|
|
344
|
+
if not self.config.detect_blocks:
|
|
345
|
+
return
|
|
346
|
+
sample = response.text[:200_000].casefold()
|
|
347
|
+
if any(marker in sample for marker in _BLOCK_MARKERS):
|
|
348
|
+
raise BlockedError(f"Anti-bot or CAPTCHA page detected at {response.url}")
|
|
349
|
+
if any(marker in sample for marker in _AUTH_MARKERS):
|
|
350
|
+
raise AuthenticationRequired(f"Authentication is required for {response.url}")
|
|
351
|
+
|
|
352
|
+
def update_cookies(self, cookies: dict[str, str]) -> None:
|
|
353
|
+
self._cookies.update(cookies)
|
|
354
|
+
for client in self._clients.values():
|
|
355
|
+
client.cookies.update(cookies)
|
|
356
|
+
|
|
357
|
+
async def close(self) -> None:
|
|
358
|
+
if self._closed:
|
|
359
|
+
return
|
|
360
|
+
self._closed = True
|
|
361
|
+
await asyncio.gather(*(client.aclose() for client in self._clients.values()))
|
|
362
|
+
self._clients.clear()
|