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/filters.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProjectType(StrEnum):
|
|
11
|
+
ORDER = "1"
|
|
12
|
+
VACANCY = "2"
|
|
13
|
+
CONTEST = "3"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True, frozen=True)
|
|
17
|
+
class ProjectFilters:
|
|
18
|
+
query: str | None = None
|
|
19
|
+
category: str | None = None
|
|
20
|
+
budget_from: Decimal | int | None = None
|
|
21
|
+
budget_to: Decimal | int | None = None
|
|
22
|
+
project_types: frozenset[ProjectType] = field(default_factory=frozenset)
|
|
23
|
+
only_with_budget: bool | None = None
|
|
24
|
+
extra: Mapping[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def to_params(self) -> dict[str, Any]:
|
|
27
|
+
params: dict[str, Any] = dict(self.extra)
|
|
28
|
+
if self.query:
|
|
29
|
+
params["search"] = self.query
|
|
30
|
+
if self.budget_from is not None:
|
|
31
|
+
params["budget_from"] = str(self.budget_from)
|
|
32
|
+
if self.budget_to is not None:
|
|
33
|
+
params["budget_to"] = str(self.budget_to)
|
|
34
|
+
if self.project_types:
|
|
35
|
+
params["kind"] = [item.value for item in sorted(self.project_types)]
|
|
36
|
+
if self.only_with_budget is not None:
|
|
37
|
+
params["with_budget"] = int(self.only_with_budget)
|
|
38
|
+
return params
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional observability integrations."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..observability import RequestEvent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenTelemetryEventHandler:
|
|
7
|
+
"""Emit lightweight events to the current OpenTelemetry span."""
|
|
8
|
+
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
try:
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
except ImportError as exc: # pragma: no cover
|
|
13
|
+
raise RuntimeError("Install flru-parser[observability]") from exc
|
|
14
|
+
self._trace = trace
|
|
15
|
+
|
|
16
|
+
def __call__(self, event: RequestEvent) -> None:
|
|
17
|
+
span = self._trace.get_current_span()
|
|
18
|
+
span.add_event(
|
|
19
|
+
f"flru.{event.phase}",
|
|
20
|
+
{
|
|
21
|
+
"http.request.method": event.method,
|
|
22
|
+
"url.full": event.url,
|
|
23
|
+
"flru.endpoint": event.endpoint,
|
|
24
|
+
"flru.attempt": event.attempt,
|
|
25
|
+
"http.response.status_code": event.status_code or 0,
|
|
26
|
+
"flru.elapsed": event.elapsed or 0.0,
|
|
27
|
+
"flru.error": event.error or "",
|
|
28
|
+
},
|
|
29
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..observability import RequestEvent
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PrometheusEventHandler:
|
|
7
|
+
"""Export request events through prometheus-client.
|
|
8
|
+
|
|
9
|
+
Install with ``flru-parser[observability]``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, namespace: str = "flru") -> None:
|
|
13
|
+
try:
|
|
14
|
+
from prometheus_client import Counter, Histogram
|
|
15
|
+
except ImportError as exc: # pragma: no cover
|
|
16
|
+
raise RuntimeError("Install flru-parser[observability]") from exc
|
|
17
|
+
self.requests = Counter(
|
|
18
|
+
f"{namespace}_requests_total",
|
|
19
|
+
"FL.ru parser request events",
|
|
20
|
+
("phase", "endpoint", "status"),
|
|
21
|
+
)
|
|
22
|
+
self.latency = Histogram(
|
|
23
|
+
f"{namespace}_request_duration_seconds",
|
|
24
|
+
"FL.ru parser request latency",
|
|
25
|
+
("endpoint",),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def __call__(self, event: RequestEvent) -> None:
|
|
29
|
+
self.requests.labels(
|
|
30
|
+
phase=event.phase,
|
|
31
|
+
endpoint=event.endpoint,
|
|
32
|
+
status=str(event.status_code or "none"),
|
|
33
|
+
).inc()
|
|
34
|
+
if event.elapsed is not None and event.phase == "success":
|
|
35
|
+
self.latency.labels(endpoint=event.endpoint).observe(event.elapsed)
|
flru/models.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
PARSER_VERSION = "0.3.0"
|
|
11
|
+
SCHEMA_VERSION = "1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FLModel(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="allow", validate_assignment=True)
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> dict[str, Any]:
|
|
18
|
+
"""Return a JSON-compatible dictionary."""
|
|
19
|
+
return self.model_dump(mode="json")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProjectKind(StrEnum):
|
|
23
|
+
ORDER = "order"
|
|
24
|
+
VACANCY = "vacancy"
|
|
25
|
+
CONTEST = "contest"
|
|
26
|
+
UNKNOWN = "unknown"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProjectStatus(StrEnum):
|
|
30
|
+
OPEN = "open"
|
|
31
|
+
EXECUTOR_SELECTED = "executor_selected"
|
|
32
|
+
CLOSED = "closed"
|
|
33
|
+
UNKNOWN = "unknown"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ParseDiagnostics(FLModel):
|
|
37
|
+
cards_found: int = 0
|
|
38
|
+
candidate_links_found: int = 0
|
|
39
|
+
selectors_matched: list[str] = Field(default_factory=list)
|
|
40
|
+
missing_required: list[str] = Field(default_factory=list)
|
|
41
|
+
warnings: list[str] = Field(default_factory=list)
|
|
42
|
+
field_sources: dict[str, str] = Field(default_factory=dict)
|
|
43
|
+
confidence: float = 1.0
|
|
44
|
+
page_fingerprint: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SourceInfo(FLModel):
|
|
48
|
+
source_url: str
|
|
49
|
+
fetched_at: datetime
|
|
50
|
+
parser_version: str = PARSER_VERSION
|
|
51
|
+
schema_version: str = SCHEMA_VERSION
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Money(FLModel):
|
|
55
|
+
amount_min: Decimal | None = None
|
|
56
|
+
amount_max: Decimal | None = None
|
|
57
|
+
currency: str | None = None
|
|
58
|
+
negotiable: bool = False
|
|
59
|
+
interview_based: bool = False
|
|
60
|
+
raw: str | None = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def amount(self) -> Decimal | None:
|
|
64
|
+
"""Return the most representative single amount when available."""
|
|
65
|
+
if self.amount_min == self.amount_max:
|
|
66
|
+
return self.amount_min
|
|
67
|
+
return self.amount_min or self.amount_max
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Link(FLModel):
|
|
71
|
+
text: str | None = None
|
|
72
|
+
url: str
|
|
73
|
+
rel: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Image(FLModel):
|
|
77
|
+
url: str
|
|
78
|
+
alt: str | None = None
|
|
79
|
+
title: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Attachment(FLModel):
|
|
83
|
+
name: str | None = None
|
|
84
|
+
url: str
|
|
85
|
+
media_type: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class UserSummary(FLModel):
|
|
89
|
+
username: str | None = None
|
|
90
|
+
user_id: int | None = None
|
|
91
|
+
name: str | None = None
|
|
92
|
+
url: str | None = None
|
|
93
|
+
avatar_url: str | None = None
|
|
94
|
+
role: str | None = None
|
|
95
|
+
location: str | None = None
|
|
96
|
+
rating: Decimal | None = None
|
|
97
|
+
reviews_positive: int | None = None
|
|
98
|
+
reviews_negative: int | None = None
|
|
99
|
+
safe_deals: int | None = None
|
|
100
|
+
verified: bool | None = None
|
|
101
|
+
online: bool | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class FreelancerSummary(UserSummary):
|
|
105
|
+
specialization: str | None = None
|
|
106
|
+
experience_raw: str | None = None
|
|
107
|
+
portfolio_count: int | None = None
|
|
108
|
+
reviews_count: int | None = None
|
|
109
|
+
description: str | None = None
|
|
110
|
+
portfolio_links: list[Link] = Field(default_factory=list)
|
|
111
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ProjectSummary(FLModel):
|
|
115
|
+
id: int
|
|
116
|
+
title: str
|
|
117
|
+
url: str
|
|
118
|
+
description: str | None = None
|
|
119
|
+
budget: Money | None = None
|
|
120
|
+
kind: ProjectKind = ProjectKind.UNKNOWN
|
|
121
|
+
status: ProjectStatus = ProjectStatus.UNKNOWN
|
|
122
|
+
category: str | None = None
|
|
123
|
+
subcategory: str | None = None
|
|
124
|
+
location: str | None = None
|
|
125
|
+
published_at: datetime | None = None
|
|
126
|
+
published_raw: str | None = None
|
|
127
|
+
responses_count: int | None = None
|
|
128
|
+
views_raw: str | None = None
|
|
129
|
+
customer: UserSummary | None = None
|
|
130
|
+
image_url: str | None = None
|
|
131
|
+
source: SourceInfo | None = None
|
|
132
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def budget_min(self) -> Decimal | None:
|
|
136
|
+
return self.budget.amount_min if self.budget else None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def budget_max(self) -> Decimal | None:
|
|
140
|
+
return self.budget.amount_max if self.budget else None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def currency(self) -> str | None:
|
|
144
|
+
return self.budget.currency if self.budget else None
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def customer_username(self) -> str | None:
|
|
148
|
+
return self.customer.username if self.customer else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ProjectDetail(ProjectSummary):
|
|
152
|
+
full_description: str | None = None
|
|
153
|
+
updated_at: datetime | None = None
|
|
154
|
+
updated_raw: str | None = None
|
|
155
|
+
executor: UserSummary | None = None
|
|
156
|
+
breadcrumbs: list[str] = Field(default_factory=list)
|
|
157
|
+
attachments: list[Attachment] = Field(default_factory=list)
|
|
158
|
+
links: list[Link] = Field(default_factory=list)
|
|
159
|
+
images: list[Image] = Field(default_factory=list)
|
|
160
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
161
|
+
raw_text: str | None = None
|
|
162
|
+
raw_html: str | None = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ProjectRecord(FLModel):
|
|
166
|
+
project: ProjectSummary
|
|
167
|
+
first_seen_at: datetime
|
|
168
|
+
last_seen_at: datetime
|
|
169
|
+
content_hash: str
|
|
170
|
+
source_updated_at: datetime | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class CrawlCheckpoint(FLModel):
|
|
174
|
+
namespace: str = "projects"
|
|
175
|
+
next_url: str | None = None
|
|
176
|
+
next_page: int | None = None
|
|
177
|
+
updated_at: datetime
|
|
178
|
+
consecutive_known: int = 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Review(FLModel):
|
|
182
|
+
author: UserSummary | None = None
|
|
183
|
+
text: str | None = None
|
|
184
|
+
rating: int | None = None
|
|
185
|
+
sentiment: str | None = None
|
|
186
|
+
created_at: datetime | None = None
|
|
187
|
+
created_raw: str | None = None
|
|
188
|
+
project_url: str | None = None
|
|
189
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class PortfolioItem(FLModel):
|
|
193
|
+
title: str | None = None
|
|
194
|
+
url: str | None = None
|
|
195
|
+
description: str | None = None
|
|
196
|
+
image_url: str | None = None
|
|
197
|
+
category: str | None = None
|
|
198
|
+
price: Money | None = None
|
|
199
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class UserProfile(UserSummary):
|
|
203
|
+
registered_raw: str | None = None
|
|
204
|
+
last_seen_raw: str | None = None
|
|
205
|
+
about: str | None = None
|
|
206
|
+
skills: list[str] = Field(default_factory=list)
|
|
207
|
+
specializations: list[str] = Field(default_factory=list)
|
|
208
|
+
projects_count: int | None = None
|
|
209
|
+
vacancies_count: int | None = None
|
|
210
|
+
contests_count: int | None = None
|
|
211
|
+
reviews: list[Review] = Field(default_factory=list)
|
|
212
|
+
projects: list[ProjectSummary] = Field(default_factory=list)
|
|
213
|
+
portfolio: list[PortfolioItem] = Field(default_factory=list)
|
|
214
|
+
links: list[Link] = Field(default_factory=list)
|
|
215
|
+
images: list[Image] = Field(default_factory=list)
|
|
216
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
217
|
+
raw_text: str | None = None
|
|
218
|
+
raw_html: str | None = None
|
|
219
|
+
source: SourceInfo | None = None
|
|
220
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class Heading(FLModel):
|
|
224
|
+
level: int
|
|
225
|
+
text: str
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TableData(FLModel):
|
|
229
|
+
headers: list[str] = Field(default_factory=list)
|
|
230
|
+
rows: list[list[str]] = Field(default_factory=list)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class PageData(FLModel):
|
|
234
|
+
url: str
|
|
235
|
+
title: str | None = None
|
|
236
|
+
canonical_url: str | None = None
|
|
237
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
238
|
+
headings: list[Heading] = Field(default_factory=list)
|
|
239
|
+
paragraphs: list[str] = Field(default_factory=list)
|
|
240
|
+
lists: list[list[str]] = Field(default_factory=list)
|
|
241
|
+
tables: list[TableData] = Field(default_factory=list)
|
|
242
|
+
links: list[Link] = Field(default_factory=list)
|
|
243
|
+
images: list[Image] = Field(default_factory=list)
|
|
244
|
+
json_ld: list[Any] = Field(default_factory=list)
|
|
245
|
+
text: str | None = None
|
|
246
|
+
raw_html: str | None = None
|
|
247
|
+
source: SourceInfo | None = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class FreelancerPage(FLModel):
|
|
251
|
+
page: int
|
|
252
|
+
url: str
|
|
253
|
+
items: list[FreelancerSummary] = Field(default_factory=list)
|
|
254
|
+
has_next: bool = False
|
|
255
|
+
next_url: str | None = None
|
|
256
|
+
diagnostics: ParseDiagnostics
|
|
257
|
+
raw_html: str | None = None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class ProjectPage(FLModel):
|
|
261
|
+
page: int
|
|
262
|
+
url: str
|
|
263
|
+
items: list[ProjectSummary] = Field(default_factory=list)
|
|
264
|
+
has_next: bool = False
|
|
265
|
+
next_url: str | None = None
|
|
266
|
+
diagnostics: ParseDiagnostics
|
|
267
|
+
raw_html: str | None = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class Category(FLModel):
|
|
271
|
+
name: str
|
|
272
|
+
url: str
|
|
273
|
+
slug: str | None = None
|
|
274
|
+
parent: str | None = None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class EndpointMetrics(FLModel):
|
|
278
|
+
requests: int = 0
|
|
279
|
+
failures: int = 0
|
|
280
|
+
retries: int = 0
|
|
281
|
+
latency_seconds_total: float = 0.0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class RequestMetrics(FLModel):
|
|
285
|
+
requests_total: int = 0
|
|
286
|
+
responses_total: int = 0
|
|
287
|
+
retries_total: int = 0
|
|
288
|
+
failures_total: int = 0
|
|
289
|
+
blocked_total: int = 0
|
|
290
|
+
rate_limited_total: int = 0
|
|
291
|
+
parse_failures_total: int = 0
|
|
292
|
+
selector_drift_total: int = 0
|
|
293
|
+
event_handler_failures_total: int = 0
|
|
294
|
+
bytes_received: int = 0
|
|
295
|
+
latency_seconds_total: float = 0.0
|
|
296
|
+
latency_samples: list[float] = Field(default_factory=list)
|
|
297
|
+
endpoints: dict[str, EndpointMetrics] = Field(default_factory=dict)
|
flru/observability.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from .models import EndpointMetrics, RequestMetrics
|
|
12
|
+
from .security import redact_url
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("flru")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True, frozen=True)
|
|
18
|
+
class RequestEvent:
|
|
19
|
+
phase: Literal["start", "retry", "success", "failure", "blocked", "parse_failure"]
|
|
20
|
+
method: str
|
|
21
|
+
url: str
|
|
22
|
+
attempt: int
|
|
23
|
+
endpoint: str = "unknown"
|
|
24
|
+
proxy: str | None = None
|
|
25
|
+
status_code: int | None = None
|
|
26
|
+
elapsed: float | None = None
|
|
27
|
+
error: str | None = None
|
|
28
|
+
|
|
29
|
+
def safe(self) -> RequestEvent:
|
|
30
|
+
return RequestEvent(
|
|
31
|
+
phase=self.phase,
|
|
32
|
+
method=self.method,
|
|
33
|
+
url=redact_url(self.url) or self.url,
|
|
34
|
+
attempt=self.attempt,
|
|
35
|
+
endpoint=self.endpoint,
|
|
36
|
+
proxy=redact_url(self.proxy),
|
|
37
|
+
status_code=self.status_code,
|
|
38
|
+
elapsed=self.elapsed,
|
|
39
|
+
error=self.error,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
EventHandler = Callable[[RequestEvent], None | Awaitable[None]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Metrics:
|
|
47
|
+
def __init__(self, *, max_latency_samples: int = 1000) -> None:
|
|
48
|
+
self._data = RequestMetrics()
|
|
49
|
+
self._lock = asyncio.Lock()
|
|
50
|
+
self._max_latency_samples = max_latency_samples
|
|
51
|
+
|
|
52
|
+
async def mutate(self, *, endpoint: str | None = None, **changes: int | float) -> None:
|
|
53
|
+
async with self._lock:
|
|
54
|
+
for key, value in changes.items():
|
|
55
|
+
current = getattr(self._data, key)
|
|
56
|
+
setattr(self._data, key, current + value)
|
|
57
|
+
if endpoint:
|
|
58
|
+
item = self._data.endpoints.setdefault(endpoint, EndpointMetrics())
|
|
59
|
+
if "requests_total" in changes:
|
|
60
|
+
item.requests += int(changes["requests_total"])
|
|
61
|
+
if "failures_total" in changes:
|
|
62
|
+
item.failures += int(changes["failures_total"])
|
|
63
|
+
if "retries_total" in changes:
|
|
64
|
+
item.retries += int(changes["retries_total"])
|
|
65
|
+
if "latency_seconds_total" in changes:
|
|
66
|
+
latency = float(changes["latency_seconds_total"])
|
|
67
|
+
item.latency_seconds_total += latency
|
|
68
|
+
self._data.latency_samples.append(latency)
|
|
69
|
+
if len(self._data.latency_samples) > self._max_latency_samples:
|
|
70
|
+
del self._data.latency_samples[: len(self._data.latency_samples) // 2]
|
|
71
|
+
|
|
72
|
+
async def snapshot(self) -> RequestMetrics:
|
|
73
|
+
async with self._lock:
|
|
74
|
+
return self._data.model_copy(deep=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Timer:
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.started = monotonic()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def elapsed(self) -> float:
|
|
83
|
+
return monotonic() - self.started
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def emit(handler: EventHandler | None, event: RequestEvent) -> bool:
|
|
87
|
+
"""Run an event handler safely. Returns ``False`` when the handler failed."""
|
|
88
|
+
if handler is None:
|
|
89
|
+
return True
|
|
90
|
+
try:
|
|
91
|
+
result = handler(event.safe())
|
|
92
|
+
if inspect.isawaitable(result):
|
|
93
|
+
await result
|
|
94
|
+
return True
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.exception("FL.ru event handler failed")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class StructuredLogHandler:
|
|
101
|
+
def __init__(self, log: logging.Logger | None = None) -> None:
|
|
102
|
+
self.log = log or logger
|
|
103
|
+
|
|
104
|
+
def __call__(self, event: RequestEvent) -> None:
|
|
105
|
+
self.log.info(
|
|
106
|
+
"flru_request",
|
|
107
|
+
extra={
|
|
108
|
+
"flru_phase": event.phase,
|
|
109
|
+
"flru_method": event.method,
|
|
110
|
+
"flru_url": event.url,
|
|
111
|
+
"flru_endpoint": event.endpoint,
|
|
112
|
+
"flru_attempt": event.attempt,
|
|
113
|
+
"flru_proxy": event.proxy,
|
|
114
|
+
"flru_status_code": event.status_code,
|
|
115
|
+
"flru_elapsed": event.elapsed,
|
|
116
|
+
"flru_error": event.error,
|
|
117
|
+
},
|
|
118
|
+
)
|
flru/parsers/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .common import parse_generic_page
|
|
2
|
+
from .freelancers import parse_freelancer_list
|
|
3
|
+
from .projects import parse_project_detail, parse_project_list, with_page
|
|
4
|
+
from .users import parse_user_profile
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"parse_freelancer_list",
|
|
8
|
+
"parse_generic_page",
|
|
9
|
+
"parse_project_detail",
|
|
10
|
+
"parse_project_list",
|
|
11
|
+
"parse_user_profile",
|
|
12
|
+
"with_page",
|
|
13
|
+
]
|