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/client.py
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import email.utils
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Mapping, Sequence
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Self, cast
|
|
10
|
+
from urllib.parse import parse_qs, urlencode, urljoin, urlsplit
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from bs4 import BeautifulSoup
|
|
14
|
+
|
|
15
|
+
from .batch import BatchError, BatchResult
|
|
16
|
+
from .config import ClientConfig
|
|
17
|
+
from .cookies import load_netscape_cookies
|
|
18
|
+
from .exceptions import EmptyPageError, ParseError, RobotsDeniedError, SelectorDriftError
|
|
19
|
+
from .filters import ProjectFilters
|
|
20
|
+
from .models import (
|
|
21
|
+
Category,
|
|
22
|
+
CrawlCheckpoint,
|
|
23
|
+
FreelancerPage,
|
|
24
|
+
FreelancerSummary,
|
|
25
|
+
PageData,
|
|
26
|
+
PortfolioItem,
|
|
27
|
+
ProjectDetail,
|
|
28
|
+
ProjectPage,
|
|
29
|
+
ProjectSummary,
|
|
30
|
+
RequestMetrics,
|
|
31
|
+
Review,
|
|
32
|
+
UserProfile,
|
|
33
|
+
)
|
|
34
|
+
from .observability import EventHandler, RequestEvent
|
|
35
|
+
from .parsers import (
|
|
36
|
+
parse_freelancer_list,
|
|
37
|
+
parse_generic_page,
|
|
38
|
+
parse_project_detail,
|
|
39
|
+
parse_project_list,
|
|
40
|
+
parse_user_profile,
|
|
41
|
+
with_page,
|
|
42
|
+
)
|
|
43
|
+
from .robots import RobotsPolicy
|
|
44
|
+
from .state import CrawlStateStore, record_for
|
|
45
|
+
from .transport import ResilientTransport
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FLClient:
|
|
49
|
+
"""Asynchronous, read-only client/parser for public FL.ru pages."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
config: ClientConfig | None = None,
|
|
54
|
+
*,
|
|
55
|
+
event_handler: EventHandler | None = None,
|
|
56
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.config = config or ClientConfig()
|
|
59
|
+
self._event_handler = event_handler
|
|
60
|
+
self._transport = ResilientTransport(
|
|
61
|
+
self.config,
|
|
62
|
+
event_handler=event_handler,
|
|
63
|
+
client_transport=transport,
|
|
64
|
+
)
|
|
65
|
+
timeout = httpx.Timeout(
|
|
66
|
+
connect=self.config.timeout.connect,
|
|
67
|
+
read=self.config.timeout.read,
|
|
68
|
+
write=self.config.timeout.write,
|
|
69
|
+
pool=self.config.timeout.pool,
|
|
70
|
+
)
|
|
71
|
+
self._robots = RobotsPolicy(
|
|
72
|
+
base_url=self.config.base_url,
|
|
73
|
+
user_agent=self.config.user_agents[0],
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
verify=self.config.verify_ssl,
|
|
76
|
+
cache_ttl=self.config.robots_cache_ttl,
|
|
77
|
+
fail_closed=self.config.robots_fail_closed,
|
|
78
|
+
)
|
|
79
|
+
self._closed = False
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_cookie_file(
|
|
83
|
+
cls,
|
|
84
|
+
path: str | Path,
|
|
85
|
+
config: ClientConfig | None = None,
|
|
86
|
+
**kwargs: Any,
|
|
87
|
+
) -> Self:
|
|
88
|
+
base = config or ClientConfig()
|
|
89
|
+
return cls(config=base.with_cookies(load_netscape_cookies(path)), **kwargs)
|
|
90
|
+
|
|
91
|
+
async def __aenter__(self) -> FLClient:
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def __aexit__(self, *_: object) -> None:
|
|
95
|
+
await self.close()
|
|
96
|
+
|
|
97
|
+
async def close(self) -> None:
|
|
98
|
+
if self._closed:
|
|
99
|
+
return
|
|
100
|
+
self._closed = True
|
|
101
|
+
await self._transport.close()
|
|
102
|
+
|
|
103
|
+
async def metrics(self) -> RequestMetrics:
|
|
104
|
+
return await self._transport.metrics.snapshot()
|
|
105
|
+
|
|
106
|
+
async def proxy_health(self) -> list[dict[str, Any]]:
|
|
107
|
+
states = await self._transport.proxy_pool.snapshot()
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
"url": state.safe_url,
|
|
111
|
+
"failures": state.failures,
|
|
112
|
+
"successes": state.successes,
|
|
113
|
+
"cooldown_until": state.cooldown_until,
|
|
114
|
+
"last_error": state.last_error,
|
|
115
|
+
}
|
|
116
|
+
for state in states
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
def update_cookies(self, cookies: Mapping[str, str]) -> None:
|
|
120
|
+
self._transport.update_cookies(dict(cookies))
|
|
121
|
+
|
|
122
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
|
123
|
+
absolute = self._absolute(url)
|
|
124
|
+
if self.config.respect_robots_txt and not await self._robots.allowed(absolute):
|
|
125
|
+
raise RobotsDeniedError(f"robots.txt denies access to {absolute}")
|
|
126
|
+
return await self._transport.request(method, absolute, **kwargs)
|
|
127
|
+
|
|
128
|
+
async def _get_document(
|
|
129
|
+
self,
|
|
130
|
+
url: str,
|
|
131
|
+
*,
|
|
132
|
+
params: Mapping[str, Any] | None = None,
|
|
133
|
+
) -> tuple[str, str, datetime]:
|
|
134
|
+
response = await self.request("GET", url, params=params)
|
|
135
|
+
fetched_at = self._response_datetime(response)
|
|
136
|
+
return response.text, str(response.url), fetched_at
|
|
137
|
+
|
|
138
|
+
async def get_html(self, url: str, *, params: Mapping[str, Any] | None = None) -> str:
|
|
139
|
+
html, _, _ = await self._get_document(url, params=params)
|
|
140
|
+
return html
|
|
141
|
+
|
|
142
|
+
async def get_page(self, url: str) -> PageData:
|
|
143
|
+
absolute = self._absolute(url)
|
|
144
|
+
html, effective_url, fetched_at = await self._get_document(absolute)
|
|
145
|
+
return parse_generic_page(
|
|
146
|
+
html,
|
|
147
|
+
effective_url,
|
|
148
|
+
store_raw_html=self.config.store_raw_html,
|
|
149
|
+
fetched_at=fetched_at,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def get_projects_page(
|
|
153
|
+
self,
|
|
154
|
+
page: int = 1,
|
|
155
|
+
*,
|
|
156
|
+
category: str | None = None,
|
|
157
|
+
filters: ProjectFilters | None = None,
|
|
158
|
+
params: Mapping[str, Any] | None = None,
|
|
159
|
+
url: str | None = None,
|
|
160
|
+
) -> ProjectPage:
|
|
161
|
+
request_url = url or self._projects_url(category or (filters.category if filters else None))
|
|
162
|
+
query = {**(filters.to_params() if filters else {}), **dict(params or {})}
|
|
163
|
+
if page != 1 and not url:
|
|
164
|
+
query["page"] = page
|
|
165
|
+
html, effective_url, fetched_at = await self._get_document(request_url, params=query)
|
|
166
|
+
try:
|
|
167
|
+
result = parse_project_list(
|
|
168
|
+
html,
|
|
169
|
+
effective_url,
|
|
170
|
+
page=page,
|
|
171
|
+
store_raw_html=self.config.store_raw_html,
|
|
172
|
+
fetched_at=fetched_at,
|
|
173
|
+
timezone_name=self.config.parser_timezone,
|
|
174
|
+
)
|
|
175
|
+
self._validate_project_page(result)
|
|
176
|
+
return result
|
|
177
|
+
except ParseError as exc:
|
|
178
|
+
await self._record_parse_failure(effective_url, html, exc)
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
async def get_projects(
|
|
182
|
+
self,
|
|
183
|
+
page: int = 1,
|
|
184
|
+
*,
|
|
185
|
+
category: str | None = None,
|
|
186
|
+
filters: ProjectFilters | None = None,
|
|
187
|
+
params: Mapping[str, Any] | None = None,
|
|
188
|
+
) -> list[ProjectSummary]:
|
|
189
|
+
return (
|
|
190
|
+
await self.get_projects_page(
|
|
191
|
+
page,
|
|
192
|
+
category=category,
|
|
193
|
+
filters=filters,
|
|
194
|
+
params=params,
|
|
195
|
+
)
|
|
196
|
+
).items
|
|
197
|
+
|
|
198
|
+
async def get_projects_batch_result(
|
|
199
|
+
self,
|
|
200
|
+
pages: Iterable[int],
|
|
201
|
+
*,
|
|
202
|
+
category: str | None = None,
|
|
203
|
+
filters: ProjectFilters | None = None,
|
|
204
|
+
params: Mapping[str, Any] | None = None,
|
|
205
|
+
concurrency: int | None = None,
|
|
206
|
+
) -> BatchResult[int, ProjectPage]:
|
|
207
|
+
page_numbers = list(pages)
|
|
208
|
+
semaphore = asyncio.Semaphore(concurrency or self.config.rate_limit.max_concurrency)
|
|
209
|
+
|
|
210
|
+
async def load(page: int) -> ProjectPage:
|
|
211
|
+
async with semaphore:
|
|
212
|
+
return await self.get_projects_page(
|
|
213
|
+
page,
|
|
214
|
+
category=category,
|
|
215
|
+
filters=filters,
|
|
216
|
+
params=params,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
values = await asyncio.gather(*(load(page) for page in page_numbers), return_exceptions=True)
|
|
220
|
+
result: BatchResult[int, ProjectPage] = BatchResult()
|
|
221
|
+
for page, value in zip(page_numbers, values, strict=True):
|
|
222
|
+
if isinstance(value, asyncio.CancelledError):
|
|
223
|
+
raise value
|
|
224
|
+
if isinstance(value, Exception):
|
|
225
|
+
result.failed.append(BatchError(page, value))
|
|
226
|
+
else:
|
|
227
|
+
result.successful.append(value)
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
async def get_projects_batch(
|
|
231
|
+
self,
|
|
232
|
+
pages: Iterable[int],
|
|
233
|
+
*,
|
|
234
|
+
category: str | None = None,
|
|
235
|
+
filters: ProjectFilters | None = None,
|
|
236
|
+
params: Mapping[str, Any] | None = None,
|
|
237
|
+
concurrency: int | None = None,
|
|
238
|
+
return_exceptions: bool = False,
|
|
239
|
+
) -> list[ProjectPage | Exception]:
|
|
240
|
+
"""Compatibility API; prefer :meth:`get_projects_batch_result`."""
|
|
241
|
+
page_numbers = list(pages)
|
|
242
|
+
result = await self.get_projects_batch_result(
|
|
243
|
+
page_numbers,
|
|
244
|
+
category=category,
|
|
245
|
+
filters=filters,
|
|
246
|
+
params=params,
|
|
247
|
+
concurrency=concurrency,
|
|
248
|
+
)
|
|
249
|
+
if result.failed and not return_exceptions:
|
|
250
|
+
raise result.failed[0].error
|
|
251
|
+
by_page: dict[int, ProjectPage | Exception] = {
|
|
252
|
+
item.page: item for item in result.successful
|
|
253
|
+
}
|
|
254
|
+
by_page.update({item.key: item.error for item in result.failed})
|
|
255
|
+
return [by_page[page] for page in page_numbers]
|
|
256
|
+
|
|
257
|
+
async def iter_project_pages(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
start_page: int = 1,
|
|
261
|
+
max_pages: int | None = None,
|
|
262
|
+
batch_size: int = 3,
|
|
263
|
+
category: str | None = None,
|
|
264
|
+
filters: ProjectFilters | None = None,
|
|
265
|
+
params: Mapping[str, Any] | None = None,
|
|
266
|
+
fail_fast: bool = True,
|
|
267
|
+
) -> AsyncIterator[ProjectPage]:
|
|
268
|
+
if batch_size <= 0:
|
|
269
|
+
raise ValueError("batch_size must be positive")
|
|
270
|
+
current, emitted = start_page, 0
|
|
271
|
+
while max_pages is None or emitted < max_pages:
|
|
272
|
+
size = batch_size if max_pages is None else min(batch_size, max_pages - emitted)
|
|
273
|
+
page_numbers = list(range(current, current + size))
|
|
274
|
+
batch = await self.get_projects_batch_result(
|
|
275
|
+
page_numbers,
|
|
276
|
+
category=category,
|
|
277
|
+
filters=filters,
|
|
278
|
+
params=params,
|
|
279
|
+
concurrency=batch_size,
|
|
280
|
+
)
|
|
281
|
+
if batch.failed and fail_fast:
|
|
282
|
+
raise batch.failed[0].error
|
|
283
|
+
pages = sorted(batch.successful, key=lambda item: item.page)
|
|
284
|
+
if not pages:
|
|
285
|
+
break
|
|
286
|
+
stop = False
|
|
287
|
+
for page in pages:
|
|
288
|
+
yield page
|
|
289
|
+
emitted += 1
|
|
290
|
+
if not page.has_next:
|
|
291
|
+
stop = True
|
|
292
|
+
break
|
|
293
|
+
if stop or (batch.failed and not fail_fast):
|
|
294
|
+
break
|
|
295
|
+
current = self._page_number(pages[-1].next_url) or pages[-1].page + 1
|
|
296
|
+
|
|
297
|
+
async def iter_projects(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
start_page: int = 1,
|
|
301
|
+
max_pages: int | None = None,
|
|
302
|
+
batch_size: int = 3,
|
|
303
|
+
category: str | None = None,
|
|
304
|
+
filters: ProjectFilters | None = None,
|
|
305
|
+
params: Mapping[str, Any] | None = None,
|
|
306
|
+
deduplicate: bool = True,
|
|
307
|
+
fail_fast: bool = True,
|
|
308
|
+
) -> AsyncIterator[ProjectSummary]:
|
|
309
|
+
seen: set[int] = set()
|
|
310
|
+
async for page in self.iter_project_pages(
|
|
311
|
+
start_page=start_page,
|
|
312
|
+
max_pages=max_pages,
|
|
313
|
+
batch_size=batch_size,
|
|
314
|
+
category=category,
|
|
315
|
+
filters=filters,
|
|
316
|
+
params=params,
|
|
317
|
+
fail_fast=fail_fast,
|
|
318
|
+
):
|
|
319
|
+
for item in page.items:
|
|
320
|
+
if deduplicate and item.id in seen:
|
|
321
|
+
continue
|
|
322
|
+
seen.add(item.id)
|
|
323
|
+
yield item
|
|
324
|
+
|
|
325
|
+
async def iter_new_projects(
|
|
326
|
+
self,
|
|
327
|
+
state: CrawlStateStore,
|
|
328
|
+
*,
|
|
329
|
+
namespace: str = "projects",
|
|
330
|
+
stop_after_known: int = 20,
|
|
331
|
+
resume: bool = True,
|
|
332
|
+
**kwargs: Any,
|
|
333
|
+
) -> AsyncIterator[ProjectSummary]:
|
|
334
|
+
checkpoint = await state.get_checkpoint(namespace) if resume else None
|
|
335
|
+
if checkpoint and checkpoint.next_page and "start_page" not in kwargs:
|
|
336
|
+
kwargs["start_page"] = checkpoint.next_page
|
|
337
|
+
consecutive_known = checkpoint.consecutive_known if checkpoint else 0
|
|
338
|
+
async for page in self.iter_project_pages(**kwargs):
|
|
339
|
+
records = []
|
|
340
|
+
for project in page.items:
|
|
341
|
+
previous = await state.get(project.id)
|
|
342
|
+
record = record_for(project, previous)
|
|
343
|
+
records.append(record)
|
|
344
|
+
if previous is not None and previous.content_hash == record.content_hash:
|
|
345
|
+
consecutive_known += 1
|
|
346
|
+
else:
|
|
347
|
+
consecutive_known = 0
|
|
348
|
+
yield project
|
|
349
|
+
if consecutive_known >= stop_after_known:
|
|
350
|
+
await state.save_many(records)
|
|
351
|
+
await state.save_checkpoint(CrawlCheckpoint(
|
|
352
|
+
namespace=namespace,
|
|
353
|
+
next_url=page.next_url,
|
|
354
|
+
next_page=self._page_number(page.next_url),
|
|
355
|
+
updated_at=datetime.now(timezone.utc),
|
|
356
|
+
consecutive_known=consecutive_known,
|
|
357
|
+
))
|
|
358
|
+
return
|
|
359
|
+
await state.save_many(records)
|
|
360
|
+
await state.save_checkpoint(CrawlCheckpoint(
|
|
361
|
+
namespace=namespace,
|
|
362
|
+
next_url=page.next_url,
|
|
363
|
+
next_page=self._page_number(page.next_url),
|
|
364
|
+
updated_at=datetime.now(timezone.utc),
|
|
365
|
+
consecutive_known=consecutive_known,
|
|
366
|
+
))
|
|
367
|
+
|
|
368
|
+
async def crawl_projects(self, **kwargs: Any) -> list[ProjectSummary]:
|
|
369
|
+
return [project async for project in self.iter_projects(**kwargs)]
|
|
370
|
+
|
|
371
|
+
async def iter_project_details(
|
|
372
|
+
self,
|
|
373
|
+
projects: AsyncIterator[ProjectSummary] | Iterable[ProjectSummary],
|
|
374
|
+
*,
|
|
375
|
+
workers: int = 4,
|
|
376
|
+
queue_size: int = 50,
|
|
377
|
+
fail_fast: bool = True,
|
|
378
|
+
) -> AsyncIterator[ProjectDetail]:
|
|
379
|
+
"""Bounded producer/worker pipeline with backpressure."""
|
|
380
|
+
input_queue: asyncio.Queue[ProjectSummary | None] = asyncio.Queue(queue_size)
|
|
381
|
+
output_queue: asyncio.Queue[ProjectDetail | Exception | None] = asyncio.Queue(queue_size)
|
|
382
|
+
|
|
383
|
+
async def producer() -> None:
|
|
384
|
+
if isinstance(projects, AsyncIterable):
|
|
385
|
+
async for item in projects:
|
|
386
|
+
await input_queue.put(item)
|
|
387
|
+
else:
|
|
388
|
+
for item in projects:
|
|
389
|
+
await input_queue.put(item)
|
|
390
|
+
for _ in range(workers):
|
|
391
|
+
await input_queue.put(None)
|
|
392
|
+
|
|
393
|
+
async def worker() -> None:
|
|
394
|
+
while (item := await input_queue.get()) is not None:
|
|
395
|
+
try:
|
|
396
|
+
await output_queue.put(await self.get_project(item.url))
|
|
397
|
+
except Exception as exc:
|
|
398
|
+
await output_queue.put(exc)
|
|
399
|
+
finally:
|
|
400
|
+
input_queue.task_done()
|
|
401
|
+
input_queue.task_done()
|
|
402
|
+
await output_queue.put(None)
|
|
403
|
+
|
|
404
|
+
tasks = [asyncio.create_task(producer())]
|
|
405
|
+
tasks.extend(asyncio.create_task(worker()) for _ in range(workers))
|
|
406
|
+
finished = 0
|
|
407
|
+
try:
|
|
408
|
+
while finished < workers:
|
|
409
|
+
value = await output_queue.get()
|
|
410
|
+
if value is None:
|
|
411
|
+
finished += 1
|
|
412
|
+
elif isinstance(value, Exception):
|
|
413
|
+
if fail_fast:
|
|
414
|
+
raise value
|
|
415
|
+
else:
|
|
416
|
+
yield value
|
|
417
|
+
finally:
|
|
418
|
+
for task in tasks:
|
|
419
|
+
if not task.done():
|
|
420
|
+
task.cancel()
|
|
421
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
422
|
+
|
|
423
|
+
async def get_freelancers_page(self, page: int = 1, *, category: str | None = None) -> FreelancerPage:
|
|
424
|
+
url = self._freelancers_url(category, page)
|
|
425
|
+
html, effective_url, fetched_at = await self._get_document(url)
|
|
426
|
+
try:
|
|
427
|
+
result = parse_freelancer_list(
|
|
428
|
+
html,
|
|
429
|
+
effective_url,
|
|
430
|
+
page=page,
|
|
431
|
+
store_raw_html=self.config.store_raw_html,
|
|
432
|
+
fetched_at=fetched_at,
|
|
433
|
+
)
|
|
434
|
+
if (
|
|
435
|
+
self.config.strict_parsing
|
|
436
|
+
and not result.items
|
|
437
|
+
and "catalog_end" not in result.diagnostics.warnings
|
|
438
|
+
):
|
|
439
|
+
if result.diagnostics.candidate_links_found:
|
|
440
|
+
raise SelectorDriftError(f"Freelancer selectors drifted at {effective_url}")
|
|
441
|
+
raise EmptyPageError(f"Unexpected empty freelancer page: {effective_url}")
|
|
442
|
+
return result
|
|
443
|
+
except ParseError as exc:
|
|
444
|
+
await self._record_parse_failure(effective_url, html, exc)
|
|
445
|
+
raise
|
|
446
|
+
|
|
447
|
+
async def get_freelancers(self, page: int = 1, *, category: str | None = None) -> list[FreelancerSummary]:
|
|
448
|
+
return (await self.get_freelancers_page(page, category=category)).items
|
|
449
|
+
|
|
450
|
+
async def get_freelancers_batch_result(
|
|
451
|
+
self,
|
|
452
|
+
pages: Iterable[int],
|
|
453
|
+
*,
|
|
454
|
+
category: str | None = None,
|
|
455
|
+
concurrency: int | None = None,
|
|
456
|
+
) -> BatchResult[int, FreelancerPage]:
|
|
457
|
+
page_numbers = list(pages)
|
|
458
|
+
semaphore = asyncio.Semaphore(concurrency or self.config.rate_limit.max_concurrency)
|
|
459
|
+
|
|
460
|
+
async def load(page: int) -> FreelancerPage:
|
|
461
|
+
async with semaphore:
|
|
462
|
+
return await self.get_freelancers_page(page, category=category)
|
|
463
|
+
|
|
464
|
+
values = await asyncio.gather(*(load(page) for page in page_numbers), return_exceptions=True)
|
|
465
|
+
result: BatchResult[int, FreelancerPage] = BatchResult()
|
|
466
|
+
for page, value in zip(page_numbers, values, strict=True):
|
|
467
|
+
if isinstance(value, asyncio.CancelledError):
|
|
468
|
+
raise value
|
|
469
|
+
if isinstance(value, Exception):
|
|
470
|
+
result.failed.append(BatchError(page, value))
|
|
471
|
+
else:
|
|
472
|
+
result.successful.append(value)
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
async def get_freelancers_batch(
|
|
476
|
+
self,
|
|
477
|
+
pages: Iterable[int],
|
|
478
|
+
*,
|
|
479
|
+
category: str | None = None,
|
|
480
|
+
concurrency: int | None = None,
|
|
481
|
+
return_exceptions: bool = False,
|
|
482
|
+
) -> list[FreelancerPage | Exception]:
|
|
483
|
+
page_numbers = list(pages)
|
|
484
|
+
result = await self.get_freelancers_batch_result(page_numbers, category=category, concurrency=concurrency)
|
|
485
|
+
if result.failed and not return_exceptions:
|
|
486
|
+
raise result.failed[0].error
|
|
487
|
+
mapping: dict[int, FreelancerPage | Exception] = {
|
|
488
|
+
item.page: item for item in result.successful
|
|
489
|
+
}
|
|
490
|
+
mapping.update({error.key: error.error for error in result.failed})
|
|
491
|
+
return [mapping[page] for page in page_numbers]
|
|
492
|
+
|
|
493
|
+
async def iter_freelancers(
|
|
494
|
+
self,
|
|
495
|
+
*,
|
|
496
|
+
start_page: int = 1,
|
|
497
|
+
max_pages: int | None = None,
|
|
498
|
+
batch_size: int = 3,
|
|
499
|
+
category: str | None = None,
|
|
500
|
+
deduplicate: bool = True,
|
|
501
|
+
fail_fast: bool = True,
|
|
502
|
+
) -> AsyncIterator[FreelancerSummary]:
|
|
503
|
+
seen: set[str] = set()
|
|
504
|
+
current, emitted = start_page, 0
|
|
505
|
+
while max_pages is None or emitted < max_pages:
|
|
506
|
+
size = batch_size if max_pages is None else min(batch_size, max_pages - emitted)
|
|
507
|
+
batch = await self.get_freelancers_batch_result(range(current, current + size), category=category, concurrency=batch_size)
|
|
508
|
+
if batch.failed and fail_fast:
|
|
509
|
+
raise batch.failed[0].error
|
|
510
|
+
pages = sorted(batch.successful, key=lambda item: item.page)
|
|
511
|
+
if not pages:
|
|
512
|
+
break
|
|
513
|
+
stop = False
|
|
514
|
+
for page in pages:
|
|
515
|
+
emitted += 1
|
|
516
|
+
for item in page.items:
|
|
517
|
+
key = item.username or item.url or item.name or ""
|
|
518
|
+
if deduplicate and key in seen:
|
|
519
|
+
continue
|
|
520
|
+
seen.add(key)
|
|
521
|
+
yield item
|
|
522
|
+
if not page.has_next:
|
|
523
|
+
stop = True
|
|
524
|
+
break
|
|
525
|
+
if stop or (batch.failed and not fail_fast):
|
|
526
|
+
break
|
|
527
|
+
current = self._page_number(pages[-1].next_url) or pages[-1].page + 1
|
|
528
|
+
|
|
529
|
+
async def get_project(self, project: int | str) -> ProjectDetail:
|
|
530
|
+
url = self._project_url(project)
|
|
531
|
+
html, effective_url, fetched_at = await self._get_document(url)
|
|
532
|
+
try:
|
|
533
|
+
return parse_project_detail(
|
|
534
|
+
html,
|
|
535
|
+
effective_url,
|
|
536
|
+
store_raw_html=self.config.store_raw_html,
|
|
537
|
+
fetched_at=fetched_at,
|
|
538
|
+
timezone_name=self.config.parser_timezone,
|
|
539
|
+
)
|
|
540
|
+
except ValueError as exc:
|
|
541
|
+
error = ParseError(str(exc))
|
|
542
|
+
await self._record_parse_failure(effective_url, html, error)
|
|
543
|
+
raise error from exc
|
|
544
|
+
|
|
545
|
+
async def get_project_details_result(
|
|
546
|
+
self,
|
|
547
|
+
projects: Sequence[ProjectSummary | int | str],
|
|
548
|
+
*,
|
|
549
|
+
concurrency: int | None = None,
|
|
550
|
+
) -> BatchResult[int | str, ProjectDetail]:
|
|
551
|
+
semaphore = asyncio.Semaphore(concurrency or self.config.rate_limit.max_concurrency)
|
|
552
|
+
|
|
553
|
+
async def load(project: ProjectSummary | int | str) -> ProjectDetail:
|
|
554
|
+
value: int | str = project.url if isinstance(project, ProjectSummary) else project
|
|
555
|
+
async with semaphore:
|
|
556
|
+
return await self.get_project(value)
|
|
557
|
+
|
|
558
|
+
values = await asyncio.gather(*(load(project) for project in projects), return_exceptions=True)
|
|
559
|
+
result: BatchResult[int | str, ProjectDetail] = BatchResult()
|
|
560
|
+
for project, value in zip(projects, values, strict=True):
|
|
561
|
+
key: int | str = project.id if isinstance(project, ProjectSummary) else project
|
|
562
|
+
if isinstance(value, asyncio.CancelledError):
|
|
563
|
+
raise value
|
|
564
|
+
if isinstance(value, Exception):
|
|
565
|
+
result.failed.append(BatchError(key, value))
|
|
566
|
+
else:
|
|
567
|
+
result.successful.append(value)
|
|
568
|
+
return result
|
|
569
|
+
|
|
570
|
+
async def get_project_details(
|
|
571
|
+
self,
|
|
572
|
+
projects: Sequence[ProjectSummary | int | str],
|
|
573
|
+
*,
|
|
574
|
+
concurrency: int | None = None,
|
|
575
|
+
return_exceptions: bool = False,
|
|
576
|
+
) -> list[ProjectDetail | Exception]:
|
|
577
|
+
result = await self.get_project_details_result(projects, concurrency=concurrency)
|
|
578
|
+
if result.failed and not return_exceptions:
|
|
579
|
+
raise result.failed[0].error
|
|
580
|
+
success = iter(result.successful)
|
|
581
|
+
errors = {item.key: item.error for item in result.failed}
|
|
582
|
+
output: list[ProjectDetail | Exception] = []
|
|
583
|
+
for project in projects:
|
|
584
|
+
key: int | str = project.id if isinstance(project, ProjectSummary) else project
|
|
585
|
+
output.append(errors[key] if key in errors else next(success))
|
|
586
|
+
return output
|
|
587
|
+
|
|
588
|
+
async def get_user(
|
|
589
|
+
self,
|
|
590
|
+
user: str,
|
|
591
|
+
*,
|
|
592
|
+
include_projects: bool = False,
|
|
593
|
+
include_reviews: bool = False,
|
|
594
|
+
include_portfolio: bool = False,
|
|
595
|
+
pages: int = 1,
|
|
596
|
+
) -> UserProfile:
|
|
597
|
+
url = self._user_url(user)
|
|
598
|
+
html, effective_url, fetched_at = await self._get_document(url)
|
|
599
|
+
profile = parse_user_profile(
|
|
600
|
+
html,
|
|
601
|
+
effective_url,
|
|
602
|
+
store_raw_html=self.config.store_raw_html,
|
|
603
|
+
fetched_at=fetched_at,
|
|
604
|
+
)
|
|
605
|
+
tasks: list[tuple[str, Any]] = []
|
|
606
|
+
if include_projects:
|
|
607
|
+
tasks.append(("projects", self.get_user_projects(user, pages=pages)))
|
|
608
|
+
if include_reviews:
|
|
609
|
+
tasks.append(("reviews", self.get_user_reviews(user, pages=pages)))
|
|
610
|
+
if include_portfolio:
|
|
611
|
+
tasks.append(("portfolio", self.get_user_portfolio(user, pages=pages)))
|
|
612
|
+
if tasks:
|
|
613
|
+
values = await asyncio.gather(*(task for _, task in tasks))
|
|
614
|
+
for (field, _), value in zip(tasks, values, strict=True):
|
|
615
|
+
setattr(profile, field, value)
|
|
616
|
+
return profile
|
|
617
|
+
|
|
618
|
+
async def get_user_projects(self, user: str, *, pages: int = 1) -> list[ProjectSummary]:
|
|
619
|
+
base = self._user_url(user)
|
|
620
|
+
urls = [with_page(base, page) if page > 1 else base for page in range(1, pages + 1)]
|
|
621
|
+
documents = await asyncio.gather(*(self._get_document(url) for url in urls))
|
|
622
|
+
result: dict[int, ProjectSummary] = {}
|
|
623
|
+
for page, (html, effective_url, fetched_at) in enumerate(documents, 1):
|
|
624
|
+
parsed = parse_project_list(html, effective_url, page=page, fetched_at=fetched_at)
|
|
625
|
+
result.update({project.id: project for project in parsed.items})
|
|
626
|
+
return list(result.values())
|
|
627
|
+
|
|
628
|
+
async def get_user_reviews(self, user: str, *, pages: int = 1) -> list[Review]:
|
|
629
|
+
values = await self._collect_user_section(user, "opinions", "reviews", pages)
|
|
630
|
+
return [cast(Review, value) for value in values]
|
|
631
|
+
|
|
632
|
+
async def get_user_portfolio(self, user: str, *, pages: int = 1) -> list[PortfolioItem]:
|
|
633
|
+
values = await self._collect_user_section(user, "portfolio", "portfolio", pages)
|
|
634
|
+
return [cast(PortfolioItem, value) for value in values]
|
|
635
|
+
|
|
636
|
+
async def _collect_user_section(self, user: str, section: str, field: str, pages: int) -> list[Any]:
|
|
637
|
+
username = self._username(user)
|
|
638
|
+
base = self._absolute(f"/users/{username}/{section}/")
|
|
639
|
+
urls = [with_page(base, page) if page > 1 else base for page in range(1, pages + 1)]
|
|
640
|
+
documents = await asyncio.gather(*(self._get_document(url) for url in urls))
|
|
641
|
+
result: list[Any] = []
|
|
642
|
+
seen: set[str] = set()
|
|
643
|
+
for html, effective_url, fetched_at in documents:
|
|
644
|
+
profile = parse_user_profile(html, effective_url, fetched_at=fetched_at)
|
|
645
|
+
for value in getattr(profile, field):
|
|
646
|
+
key = value.model_dump_json()
|
|
647
|
+
if key not in seen:
|
|
648
|
+
seen.add(key)
|
|
649
|
+
result.append(value)
|
|
650
|
+
return result
|
|
651
|
+
|
|
652
|
+
async def get_categories(self) -> list[Category]:
|
|
653
|
+
url = self._absolute("/projects/")
|
|
654
|
+
html = await self.get_html(url)
|
|
655
|
+
soup = BeautifulSoup(html, "lxml")
|
|
656
|
+
result: list[Category] = []
|
|
657
|
+
seen: set[str] = set()
|
|
658
|
+
for anchor in soup.select("a[href*='/projects/category/']"):
|
|
659
|
+
href = self._absolute(str(anchor.get("href")))
|
|
660
|
+
name = " ".join(anchor.get_text(" ", strip=True).split())
|
|
661
|
+
if not name or href in seen:
|
|
662
|
+
continue
|
|
663
|
+
seen.add(href)
|
|
664
|
+
match = re.search(r"/projects/category/(.+?)/?$", urlsplit(href).path)
|
|
665
|
+
slug = match.group(1) if match else None
|
|
666
|
+
result.append(Category(
|
|
667
|
+
name=name,
|
|
668
|
+
url=href,
|
|
669
|
+
slug=slug,
|
|
670
|
+
parent=slug.split("/", 1)[0] if slug and "/" in slug else None,
|
|
671
|
+
))
|
|
672
|
+
return result
|
|
673
|
+
|
|
674
|
+
def _validate_project_page(self, page: ProjectPage) -> None:
|
|
675
|
+
if not self.config.strict_parsing or page.items:
|
|
676
|
+
return
|
|
677
|
+
diagnostics = page.diagnostics
|
|
678
|
+
if diagnostics.candidate_links_found > 0:
|
|
679
|
+
raise SelectorDriftError(
|
|
680
|
+
f"Found {diagnostics.candidate_links_found} project links but parsed no items at {page.url}"
|
|
681
|
+
)
|
|
682
|
+
if "catalog_end" not in diagnostics.warnings:
|
|
683
|
+
raise EmptyPageError(f"Unexpected empty project page: {page.url}")
|
|
684
|
+
|
|
685
|
+
async def _record_parse_failure(
|
|
686
|
+
self,
|
|
687
|
+
url: str,
|
|
688
|
+
html: str,
|
|
689
|
+
error: ParseError | None = None,
|
|
690
|
+
) -> None:
|
|
691
|
+
changes = {"parse_failures_total": 1}
|
|
692
|
+
if isinstance(error, SelectorDriftError):
|
|
693
|
+
changes["selector_drift_total"] = 1
|
|
694
|
+
await self._transport.metrics.mutate(**changes)
|
|
695
|
+
await self._transport.emit_event(
|
|
696
|
+
RequestEvent("parse_failure", "GET", url, 1, self._endpoint(url))
|
|
697
|
+
)
|
|
698
|
+
if not self.config.store_failed_html or not self.config.failed_html_directory:
|
|
699
|
+
return
|
|
700
|
+
directory = Path(self.config.failed_html_directory)
|
|
701
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
702
|
+
safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", url)[:160]
|
|
703
|
+
await asyncio.to_thread((directory / f"{safe_name}.html").write_text, html, encoding="utf-8")
|
|
704
|
+
|
|
705
|
+
def _absolute(self, url: str) -> str:
|
|
706
|
+
return urljoin(self.config.base_url.rstrip("/") + "/", url)
|
|
707
|
+
|
|
708
|
+
def _projects_url(self, category: str | None) -> str:
|
|
709
|
+
if not category:
|
|
710
|
+
return self._absolute("/projects/")
|
|
711
|
+
if category.startswith(("http://", "https://")):
|
|
712
|
+
return category
|
|
713
|
+
category = category.strip("/")
|
|
714
|
+
if category.startswith("projects/category/"):
|
|
715
|
+
return self._absolute(f"/{category}/")
|
|
716
|
+
return self._absolute(f"/projects/category/{category}/")
|
|
717
|
+
|
|
718
|
+
def _freelancers_url(self, category: str | None, page: int) -> str:
|
|
719
|
+
if category and category.startswith(("http://", "https://")):
|
|
720
|
+
base = category.rstrip("/") + "/"
|
|
721
|
+
elif category:
|
|
722
|
+
base = self._absolute(f"/freelancers/{category.strip('/')}/")
|
|
723
|
+
else:
|
|
724
|
+
base = self._absolute("/freelancers/")
|
|
725
|
+
return base if page == 1 else urljoin(base, f"page-{page}/")
|
|
726
|
+
|
|
727
|
+
def _project_url(self, project: int | str) -> str:
|
|
728
|
+
if isinstance(project, int) or str(project).isdigit():
|
|
729
|
+
return self._absolute(f"/projects/{project}/")
|
|
730
|
+
return self._absolute(str(project))
|
|
731
|
+
|
|
732
|
+
def _username(self, user: str) -> str:
|
|
733
|
+
if user.startswith(("http://", "https://")) or "/users/" in user:
|
|
734
|
+
match = re.search(r"/users/([^/?#]+)/?", urlsplit(self._absolute(user)).path)
|
|
735
|
+
if not match:
|
|
736
|
+
raise ValueError(f"Could not extract username from {user!r}")
|
|
737
|
+
return match.group(1)
|
|
738
|
+
return user.strip("/")
|
|
739
|
+
|
|
740
|
+
def _user_url(self, user: str) -> str:
|
|
741
|
+
return self._absolute(f"/users/{self._username(user)}/")
|
|
742
|
+
|
|
743
|
+
@staticmethod
|
|
744
|
+
def _page_number(url: str | None) -> int | None:
|
|
745
|
+
if not url:
|
|
746
|
+
return None
|
|
747
|
+
query = parse_qs(urlsplit(url).query)
|
|
748
|
+
if query.get("page") and query["page"][0].isdigit():
|
|
749
|
+
return int(query["page"][0])
|
|
750
|
+
if match := re.search(r"/page-(\d+)/?", urlsplit(url).path):
|
|
751
|
+
return int(match.group(1))
|
|
752
|
+
return None
|
|
753
|
+
|
|
754
|
+
@staticmethod
|
|
755
|
+
def _response_datetime(response: httpx.Response) -> datetime:
|
|
756
|
+
if value := response.headers.get("Date"):
|
|
757
|
+
try:
|
|
758
|
+
parsed = email.utils.parsedate_to_datetime(value)
|
|
759
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
760
|
+
except (TypeError, ValueError):
|
|
761
|
+
pass
|
|
762
|
+
return datetime.now(timezone.utc)
|
|
763
|
+
|
|
764
|
+
@staticmethod
|
|
765
|
+
def _endpoint(url: str) -> str:
|
|
766
|
+
path = urlsplit(url).path.strip("/")
|
|
767
|
+
return path.split("/", 1)[0] or "root"
|
|
768
|
+
|
|
769
|
+
@staticmethod
|
|
770
|
+
def _url_with_params(url: str, params: Mapping[str, Any]) -> str:
|
|
771
|
+
return f"{url}?{urlencode(params, doseq=True)}" if params else url
|