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/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
+ )
@@ -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
+ ]