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