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/parsers/common.py ADDED
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from datetime import datetime, timedelta, timezone
7
+ from decimal import Decimal, InvalidOperation
8
+ from typing import Any, Iterable
9
+ from urllib.parse import urljoin, urlsplit
10
+ from zoneinfo import ZoneInfo
11
+
12
+ from bs4 import BeautifulSoup, Tag
13
+
14
+ from ..models import Heading, Image, Link, Money, PageData, SourceInfo, TableData
15
+
16
+ SPACE_RE = re.compile(r"[\s\u00a0]+")
17
+ PROJECT_URL_RE = re.compile(r"/projects/(?P<id>\d+)(?:(?:/[^?#]*)?\.html|/)?(?:[?#].*)?$")
18
+ USER_URL_RE = re.compile(r"/users/(?P<username>[^/?#]+)/?(?:[^?#]*)?$")
19
+ NUMBER_RE = re.compile(r"\d[\d\s\u00a0]*")
20
+ MONTHS_RU = {
21
+ "января": 1, "февраля": 2, "марта": 3, "апреля": 4, "мая": 5, "июня": 6,
22
+ "июля": 7, "августа": 8, "сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
23
+ }
24
+
25
+
26
+ def utc_now() -> datetime:
27
+ return datetime.now(timezone.utc)
28
+
29
+
30
+ def page_fingerprint(html: str) -> str:
31
+ normalized = SPACE_RE.sub(" ", html).strip()
32
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
33
+
34
+
35
+ def clean_text(value: str | None) -> str | None:
36
+ if value is None:
37
+ return None
38
+ text = SPACE_RE.sub(" ", value).strip()
39
+ return text or None
40
+
41
+
42
+ def text_of(node: Tag | None, separator: str = " ") -> str | None:
43
+ return clean_text(node.get_text(separator, strip=True)) if node else None
44
+
45
+
46
+ def multiline_text_of(node: Tag | BeautifulSoup | None) -> str | None:
47
+ if node is None:
48
+ return None
49
+ lines = [clean_text(str(value)) for value in node.stripped_strings]
50
+ return "\n".join(line for line in lines if line) or None
51
+
52
+
53
+ def absolute_url(base_url: str, value: str | None) -> str | None:
54
+ return urljoin(base_url, value) if value else None
55
+
56
+
57
+ def project_id_from_url(url: str) -> int | None:
58
+ match = PROJECT_URL_RE.search(urlsplit(url).path)
59
+ return int(match.group("id")) if match else None
60
+
61
+
62
+ def username_from_url(url: str) -> str | None:
63
+ match = USER_URL_RE.search(urlsplit(url).path)
64
+ return match.group("username") if match else None
65
+
66
+
67
+ def first_text(root: Tag | BeautifulSoup, selectors: Iterable[str]) -> str | None:
68
+ for selector in selectors:
69
+ value = text_of(root.select_one(selector))
70
+ if value:
71
+ return value
72
+ return None
73
+
74
+
75
+ def first_attr(root: Tag | BeautifulSoup, selectors: Iterable[str], attribute: str) -> str | None:
76
+ for selector in selectors:
77
+ node = root.select_one(selector)
78
+ if node and node.get(attribute):
79
+ return str(node.get(attribute))
80
+ return None
81
+
82
+
83
+ def parse_int(value: str | None) -> int | None:
84
+ match = NUMBER_RE.search(value or "")
85
+ return int(re.sub(r"\D", "", match.group())) if match else None
86
+
87
+
88
+ def parse_decimal(value: str | None) -> Decimal | None:
89
+ match = re.search(r"-?\d[\d\s\u00a0]*(?:[.,]\d+)?", value or "")
90
+ if not match:
91
+ return None
92
+ normalized = re.sub(r"[\s\u00a0]", "", match.group()).replace(",", ".")
93
+ try:
94
+ return Decimal(normalized)
95
+ except InvalidOperation:
96
+ return None
97
+
98
+
99
+ def parse_money(value: str | None) -> Money | None:
100
+ raw = clean_text(value)
101
+ if not raw:
102
+ return None
103
+ folded = raw.casefold()
104
+ currency = "RUB" if "₽" in raw or "руб" in folded else "USD" if "$" in raw or "usd" in folded else "EUR" if "€" in raw or "eur" in folded else None
105
+ amounts = [parse_decimal(part) for part in re.findall(r"\d[\d\s\u00a0]*(?:[.,]\d+)?", raw)]
106
+ numbers = [item for item in amounts if item is not None]
107
+ return Money(
108
+ amount_min=numbers[0] if numbers else None,
109
+ amount_max=numbers[1] if len(numbers) > 1 else (numbers[0] if numbers else None),
110
+ currency=currency,
111
+ negotiable="договор" in folded,
112
+ interview_based="собеседован" in folded,
113
+ raw=raw,
114
+ )
115
+
116
+
117
+ def parse_ru_datetime(
118
+ value: str | None,
119
+ *,
120
+ now: datetime | None = None,
121
+ timezone_name: str = "Europe/Moscow",
122
+ ) -> datetime | None:
123
+ if not (text := clean_text(value)):
124
+ return None
125
+ zone = ZoneInfo(timezone_name)
126
+ now = now or datetime.now(zone)
127
+ if now.tzinfo is None:
128
+ now = now.replace(tzinfo=zone)
129
+ folded = text.casefold()
130
+ if folded.startswith("сегодня"):
131
+ time_match = re.search(r"(\d{1,2}):(\d{2})", folded)
132
+ return now.replace(hour=int(time_match.group(1)) if time_match else 0, minute=int(time_match.group(2)) if time_match else 0, second=0, microsecond=0)
133
+ if folded.startswith("вчера"):
134
+ time_match = re.search(r"(\d{1,2}):(\d{2})", folded)
135
+ value_dt = now - timedelta(days=1)
136
+ return value_dt.replace(hour=int(time_match.group(1)) if time_match else 0, minute=int(time_match.group(2)) if time_match else 0, second=0, microsecond=0)
137
+ absolute = re.search(r"(?P<day>\d{1,2})\s+(?P<month>[а-яё]+)(?:\s+(?P<year>\d{4}))?(?:[^\d]+(?P<hour>\d{1,2}):(?P<minute>\d{2}))?", folded)
138
+ if absolute and absolute.group("month") in MONTHS_RU:
139
+ try:
140
+ return datetime(
141
+ int(absolute.group("year") or now.year), MONTHS_RU[absolute.group("month")],
142
+ int(absolute.group("day")), int(absolute.group("hour") or 0),
143
+ int(absolute.group("minute") or 0), tzinfo=zone,
144
+ )
145
+ except ValueError:
146
+ return None
147
+ relative = re.search(r"(?P<count>\d+)\s+(?P<unit>минут\w*|час\w*|дн\w*|день|дня|недел\w*|месяц\w*)\s+назад", folded)
148
+ if relative:
149
+ count, unit = int(relative.group("count")), relative.group("unit")
150
+ delta = timedelta(minutes=count) if unit.startswith("минут") else timedelta(hours=count) if unit.startswith("час") else timedelta(days=count) if unit.startswith(("дн", "день", "дня")) else timedelta(weeks=count) if unit.startswith("недел") else timedelta(days=count * 30)
151
+ return now - delta
152
+ return None
153
+
154
+
155
+ def extract_meta(soup: BeautifulSoup) -> dict[str, str]:
156
+ return {
157
+ str(node.get("name") or node.get("property")): str(node.get("content"))
158
+ for node in soup.select("meta[name], meta[property]")
159
+ if (node.get("name") or node.get("property")) and node.get("content")
160
+ }
161
+
162
+
163
+ def extract_json_ld(soup: BeautifulSoup) -> list[Any]:
164
+ result: list[Any] = []
165
+ for node in soup.select('script[type="application/ld+json"]'):
166
+ try:
167
+ result.append(json.loads(node.get_text()))
168
+ except (json.JSONDecodeError, TypeError):
169
+ continue
170
+ return result
171
+
172
+
173
+ def extract_links(root: Tag | BeautifulSoup, base_url: str) -> list[Link]:
174
+ result: list[Link] = []
175
+ seen: set[str] = set()
176
+ for node in root.select("a[href]"):
177
+ url = absolute_url(base_url, str(node.get("href")))
178
+ if not url or url in seen or url.startswith(("javascript:", "mailto:")):
179
+ continue
180
+ seen.add(url)
181
+ rel_value = node.get("rel")
182
+ rel = " ".join(rel_value) if isinstance(rel_value, list) else clean_text(str(rel_value or ""))
183
+ result.append(Link(text=text_of(node), url=url, rel=rel))
184
+ return result
185
+
186
+
187
+ def extract_images(root: Tag | BeautifulSoup, base_url: str) -> list[Image]:
188
+ result: list[Image] = []
189
+ seen: set[str] = set()
190
+ for node in root.select("img[src], img[data-src]"):
191
+ url = absolute_url(base_url, str(node.get("src") or node.get("data-src")))
192
+ if not url or url in seen:
193
+ continue
194
+ seen.add(url)
195
+ result.append(Image(url=url, alt=clean_text(str(node.get("alt") or "")), title=clean_text(str(node.get("title") or ""))))
196
+ return result
197
+
198
+
199
+ def parse_generic_page(html: str, url: str, *, store_raw_html: bool = False, fetched_at: datetime | None = None) -> PageData:
200
+ soup = BeautifulSoup(html, "lxml")
201
+ main = soup.select_one("main") or soup.body or soup
202
+ tables: list[TableData] = []
203
+ for table in main.select("table"):
204
+ rows, headers = [], []
205
+ for row in table.select("tr"):
206
+ cells = [text_of(cell) or "" for cell in row.select("th,td")]
207
+ if not cells:
208
+ continue
209
+ if row.select("th") and not headers:
210
+ headers = cells
211
+ else:
212
+ rows.append(cells)
213
+ tables.append(TableData(headers=headers, rows=rows))
214
+ canonical = first_attr(soup, ['link[rel="canonical"]'], "href")
215
+ return PageData(
216
+ url=url,
217
+ title=clean_text(soup.title.string if soup.title else None),
218
+ canonical_url=absolute_url(url, canonical),
219
+ metadata=extract_meta(soup),
220
+ headings=[Heading(level=int(node.name[1]), text=text_of(node) or "") for node in main.select("h1,h2,h3,h4,h5,h6") if text_of(node)],
221
+ paragraphs=[text for node in main.select("p") if (text := text_of(node))],
222
+ lists=[items for node in main.select("ul,ol") if (items := [text for li in node.find_all("li", recursive=False) if (text := text_of(li))])],
223
+ tables=tables,
224
+ links=extract_links(main, url), images=extract_images(main, url), json_ld=extract_json_ld(soup),
225
+ text=multiline_text_of(main), raw_html=html if store_raw_html else None,
226
+ source=SourceInfo(source_url=url, fetched_at=fetched_at or utc_now()),
227
+ )
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from urllib.parse import urlsplit
6
+
7
+ from bs4 import BeautifulSoup, Tag
8
+
9
+ from ..models import FreelancerPage, FreelancerSummary, Link, ParseDiagnostics
10
+ from .common import (
11
+ absolute_url, clean_text, multiline_text_of, page_fingerprint, parse_decimal, parse_int,
12
+ text_of, username_from_url, utc_now,
13
+ )
14
+
15
+ USER_HREF_RE = re.compile(r"^/users/[^/?#]+/?$")
16
+
17
+
18
+ def _find_card(anchor: Tag) -> Tag:
19
+ for parent in anchor.parents:
20
+ if not isinstance(parent, Tag):
21
+ continue
22
+ classes = " ".join(parent.get("class", []))
23
+ if parent.name in {"article", "li"} or re.search(r"(?:freelancer|user-card|b-user|catalog-item)", classes, re.IGNORECASE):
24
+ return parent
25
+ if parent.name in {"main", "body"}:
26
+ break
27
+ return anchor.find_parent("div") or anchor
28
+
29
+
30
+ def _next_url(soup: BeautifulSoup, url: str, page: int) -> str | None:
31
+ explicit = soup.select_one('a[rel="next"], a[aria-label*="След" i], a[title*="След" i]')
32
+ if explicit and explicit.get("href"):
33
+ return absolute_url(url, str(explicit.get("href")))
34
+ wanted = f"page-{page + 1}"
35
+ return next((absolute_url(url, str(anchor.get("href"))) for anchor in soup.select("a[href]") if wanted in str(anchor.get("href"))), None)
36
+
37
+
38
+ def parse_freelancer_list(
39
+ html: str, url: str, *, page: int = 1, store_raw_html: bool = False,
40
+ fetched_at: datetime | None = None,
41
+ ) -> FreelancerPage:
42
+ _ = fetched_at or utc_now()
43
+ soup = BeautifulSoup(html, "lxml")
44
+ root_selector = "main" if soup.select_one("main") else "body"
45
+ root = soup.select_one(root_selector) or soup
46
+ candidates = root.find_all("a", href=USER_HREF_RE)
47
+ items: list[FreelancerSummary] = []
48
+ seen: set[str] = set()
49
+ for anchor in candidates:
50
+ profile_url = absolute_url(url, str(anchor.get("href")))
51
+ username, name = username_from_url(profile_url or ""), text_of(anchor)
52
+ if not profile_url or not username or not name or username in seen:
53
+ continue
54
+ card = _find_card(anchor)
55
+ card_text = multiline_text_of(card) or ""
56
+ if not any(marker in card_text.casefold() for marker in ("опыт:", "портфолио:", "отзыв", "предложить заказ", "сроки и цены")):
57
+ continue
58
+ seen.add(username)
59
+ avatar = card.select_one("img[src], img[data-src]")
60
+ experience = re.search(r"Опыт:\s*([^\n]+)", card_text, re.IGNORECASE)
61
+ portfolio = re.search(r"Портфолио:\s*(\d+)\s+работ", card_text, re.IGNORECASE)
62
+ reviews = re.search(r"(\d+)\s+отзыв", card_text, re.IGNORECASE)
63
+ rating = re.search(r"Рейтинг\s*[:]?\s*([\d\s.,]+)", card_text, re.IGNORECASE)
64
+ specialization = next((clean_text(line) for line in card_text.splitlines() if ":" in line and not line.casefold().startswith(("опыт:", "портфолио:"))), None)
65
+ location = None
66
+ if "," in name:
67
+ name, location = [clean_text(part) for part in name.split(",", 1)]
68
+ portfolio_links = [
69
+ Link(text=text_of(item_anchor), url=href)
70
+ for item_anchor in card.select("a[href]")
71
+ if (href := absolute_url(url, str(item_anchor.get("href")))) and href != profile_url and "/portfolio/" in urlsplit(href).path
72
+ ]
73
+ items.append(FreelancerSummary(
74
+ username=username, name=name, url=profile_url,
75
+ avatar_url=absolute_url(url, str(avatar.get("src") or avatar.get("data-src"))) if avatar else None,
76
+ role="freelancer", location=location,
77
+ rating=parse_decimal(rating.group(1)) if rating else None,
78
+ specialization=specialization,
79
+ experience_raw=clean_text(experience.group(1)) if experience else None,
80
+ portfolio_count=parse_int(portfolio.group(1)) if portfolio else None,
81
+ reviews_count=parse_int(reviews.group(1)) if reviews else None,
82
+ description=card_text, portfolio_links=portfolio_links,
83
+ extra={"card_text": card_text, "field_sources": {"profile": "user_url_pattern"}},
84
+ ))
85
+ next_url = _next_url(soup, url, page)
86
+ warnings = ["catalog_end"] if not items and not next_url and page > 1 else []
87
+ return FreelancerPage(
88
+ page=page, url=url, items=items, has_next=next_url is not None, next_url=next_url,
89
+ diagnostics=ParseDiagnostics(
90
+ cards_found=len({id(_find_card(anchor)) for anchor in candidates}),
91
+ candidate_links_found=len(candidates), selectors_matched=[root_selector],
92
+ missing_required=[] if items else ["items"], warnings=warnings,
93
+ field_sources={"items": "user_url_pattern"}, confidence=1.0 if items else 0.8 if warnings else 0.2,
94
+ page_fingerprint=page_fingerprint(html),
95
+ ),
96
+ raw_html=html if store_raw_html else None,
97
+ )
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
6
+
7
+ from bs4 import BeautifulSoup, Tag
8
+
9
+ from ..models import (
10
+ Attachment,
11
+ ParseDiagnostics,
12
+ ProjectDetail,
13
+ ProjectKind,
14
+ ProjectPage,
15
+ ProjectStatus,
16
+ ProjectSummary,
17
+ SourceInfo,
18
+ UserSummary,
19
+ )
20
+ from .common import (
21
+ PROJECT_URL_RE,
22
+ absolute_url,
23
+ clean_text,
24
+ extract_images,
25
+ extract_json_ld,
26
+ extract_links,
27
+ extract_meta,
28
+ first_attr,
29
+ first_text,
30
+ multiline_text_of,
31
+ page_fingerprint,
32
+ parse_money,
33
+ parse_ru_datetime,
34
+ project_id_from_url,
35
+ text_of,
36
+ username_from_url,
37
+ utc_now,
38
+ )
39
+
40
+ TITLE_SELECTORS = ("h1", '[itemprop="name"]', ".b-page__title", ".project-title")
41
+ DESCRIPTION_SELECTORS = (
42
+ '[itemprop="description"]', ".b-layout__txt_padbot_20", ".b-post__txt",
43
+ ".project-description", '[class*="description"]',
44
+ )
45
+
46
+
47
+ def _find_card(anchor: Tag) -> Tag:
48
+ for parent in anchor.parents:
49
+ if not isinstance(parent, Tag):
50
+ continue
51
+ classes = " ".join(parent.get("class", []))
52
+ if parent.name == "article" or re.search(r"(?:^|[-_ ])(?:project|b-post)(?:[-_ ]|$)", classes):
53
+ return parent
54
+ if parent.name in {"main", "body"}:
55
+ break
56
+ heading = anchor.find_parent(["h2", "h3"])
57
+ if heading and isinstance(heading.parent, Tag) and heading.parent.name not in {"main", "body"}:
58
+ return heading.parent
59
+ return anchor.parent if isinstance(anchor.parent, Tag) else anchor
60
+
61
+
62
+ def _kind(text: str) -> ProjectKind:
63
+ folded = text.casefold()
64
+ if "вакансия" in folded:
65
+ return ProjectKind.VACANCY
66
+ if "конкурс" in folded:
67
+ return ProjectKind.CONTEST
68
+ if "заказ" in folded or "проект" in folded:
69
+ return ProjectKind.ORDER
70
+ return ProjectKind.UNKNOWN
71
+
72
+
73
+ def _status(text: str) -> ProjectStatus:
74
+ folded = text.casefold()
75
+ if "исполнитель определ" in folded or "исполнитель выбран" in folded:
76
+ return ProjectStatus.EXECUTOR_SELECTED
77
+ if any(value in folded for value in ("закрыт", "завершен", "завершён")):
78
+ return ProjectStatus.CLOSED
79
+ if "откликнуться" in folded:
80
+ return ProjectStatus.OPEN
81
+ return ProjectStatus.UNKNOWN
82
+
83
+
84
+ def _candidate_description(card: Tag, title: str) -> tuple[str | None, str]:
85
+ value = first_text(card, DESCRIPTION_SELECTORS)
86
+ if value and value != title:
87
+ return value, "css"
88
+ chunks = [
89
+ text for node in card.find_all(["p", "div"], recursive=True)
90
+ if (text := text_of(node)) and text != title and len(text) >= 20
91
+ and not any(token in text.casefold() for token in ("откликнуться", "ответов", "больше 300"))
92
+ ]
93
+ return (max(chunks, key=len), "text_fallback") if chunks else (None, "missing")
94
+
95
+
96
+ def _budget_text(card: Tag) -> tuple[str | None, str]:
97
+ value = first_text(card, ('[class*="price"]', '[class*="budget"]', ".b-post__price", '[itemprop="price"]'))
98
+ if value:
99
+ return value, "css"
100
+ text = text_of(card) or ""
101
+ for pattern in (
102
+ r"(?:от\s+)?\d[\d\s\u00a0]*(?:\s*[₽$€]|\s*(?:руб(?:лей|ля|ль)?|USD|EUR))(?:\s*[–-]\s*\d[\d\s\u00a0]*(?:\s*[₽$€]|\s*(?:руб(?:лей|ля|ль)?|USD|EUR)))?",
103
+ r"по договоренности", r"по результатам собеседования",
104
+ ):
105
+ if match := re.search(pattern, text, re.IGNORECASE):
106
+ return clean_text(match.group()), "text_fallback"
107
+ return None, "missing"
108
+
109
+
110
+ def _published_text(text: str) -> str | None:
111
+ for pattern in (
112
+ r"(?:сегодня|вчера)[^\n]{0,20}\d{1,2}:\d{2}",
113
+ r"\d{1,2}\s+[А-Яа-яЁё]+(?:\s+\d{4})?[^\n]{0,20}\d{1,2}:\d{2}",
114
+ r"\d+\s+(?:минут\w*|час\w*|дн\w*|недел\w*)\s+назад",
115
+ ):
116
+ if match := re.search(pattern, text, re.IGNORECASE):
117
+ return clean_text(match.group())
118
+ return None
119
+
120
+
121
+ def _next_page_url(soup: BeautifulSoup, url: str, page: int) -> str | None:
122
+ explicit = soup.select_one('a[rel="next"], a[aria-label*="След" i], a[title*="След" i]')
123
+ if explicit and explicit.get("href"):
124
+ return absolute_url(url, str(explicit.get("href")))
125
+ for anchor in soup.select("a[href]"):
126
+ href = absolute_url(url, str(anchor.get("href")))
127
+ if href and parse_qs(urlsplit(href).query).get("page") == [str(page + 1)]:
128
+ return href
129
+ return None
130
+
131
+
132
+ def parse_project_list(
133
+ html: str,
134
+ url: str,
135
+ *,
136
+ page: int = 1,
137
+ store_raw_html: bool = False,
138
+ fetched_at: datetime | None = None,
139
+ timezone_name: str = "Europe/Moscow",
140
+ ) -> ProjectPage:
141
+ fetched_at = fetched_at or utc_now()
142
+ soup = BeautifulSoup(html, "lxml")
143
+ root_selector = "#projects-list" if soup.select_one("#projects-list") else "main" if soup.select_one("main") else "body"
144
+ root = soup.select_one(root_selector) or soup
145
+ anchors = root.find_all("a", href=PROJECT_URL_RE)
146
+ items: list[ProjectSummary] = []
147
+ seen: set[int] = set()
148
+
149
+ for anchor in anchors:
150
+ href = absolute_url(url, str(anchor.get("href")))
151
+ project_id = project_id_from_url(href or "")
152
+ title = text_of(anchor)
153
+ if project_id is None or not title or project_id in seen or not href:
154
+ continue
155
+ seen.add(project_id)
156
+ card = _find_card(anchor)
157
+ card_text = multiline_text_of(card) or ""
158
+ user_anchor = card.find("a", href=re.compile(r"/users/[^/?#]+/?"))
159
+ customer = None
160
+ if user_anchor:
161
+ user_url = absolute_url(url, str(user_anchor.get("href")))
162
+ customer = UserSummary(username=username_from_url(user_url or ""), name=text_of(user_anchor), url=user_url)
163
+ description, description_source = _candidate_description(card, title)
164
+ budget_raw, budget_source = _budget_text(card)
165
+ response_match = re.search(r"(\d+)\s+ответ", card_text, re.IGNORECASE)
166
+ views_match = re.search(r"(?:больше\s+)?\d+\s+(?:просмотр|$)", card_text, re.IGNORECASE)
167
+ location_match = re.search(r"(?:Вакансия|Заказ)\s*\(([^)]+)\)", card_text, re.IGNORECASE)
168
+ image = card.select_one("img[src], img[data-src]")
169
+ published_raw = _published_text(card_text)
170
+ items.append(ProjectSummary(
171
+ id=project_id, title=title, url=href, description=description,
172
+ budget=parse_money(budget_raw), kind=_kind(card_text), status=_status(card_text),
173
+ location=clean_text(location_match.group(1)) if location_match else None,
174
+ published_at=parse_ru_datetime(published_raw, now=fetched_at, timezone_name=timezone_name),
175
+ published_raw=published_raw,
176
+ responses_count=int(response_match.group(1)) if response_match else None,
177
+ views_raw=clean_text(views_match.group()) if views_match else None,
178
+ customer=customer,
179
+ image_url=absolute_url(url, str(image.get("src") or image.get("data-src"))) if image else None,
180
+ source=SourceInfo(source_url=url, fetched_at=fetched_at),
181
+ extra={"card_text": card_text, "field_sources": {"title": "url_anchor", "description": description_source, "budget": budget_source}},
182
+ ))
183
+
184
+ next_url = _next_page_url(soup, url, page)
185
+ lower_text = (multiline_text_of(root) or "").casefold()
186
+ warnings: list[str] = []
187
+ if not items and not next_url and (page > 1 or any(marker in lower_text for marker in ("ничего не найдено", "нет проектов", "заказов не найдено"))):
188
+ warnings.append("catalog_end")
189
+ missing = [] if items else ["items"]
190
+ confidence = 1.0 if items else 0.8 if "catalog_end" in warnings else 0.2
191
+ diagnostics = ParseDiagnostics(
192
+ cards_found=len({id(_find_card(anchor)) for anchor in anchors}),
193
+ candidate_links_found=len(anchors),
194
+ selectors_matched=[root_selector], missing_required=missing, warnings=warnings,
195
+ field_sources={"items": "project_url_pattern"}, confidence=confidence,
196
+ page_fingerprint=page_fingerprint(html),
197
+ )
198
+ return ProjectPage(
199
+ page=page, url=url, items=items, has_next=next_url is not None, next_url=next_url,
200
+ diagnostics=diagnostics, raw_html=html if store_raw_html else None,
201
+ )
202
+
203
+
204
+ def _description_from_detail(root: Tag, title: str) -> tuple[str | None, str]:
205
+ value = first_text(root, DESCRIPTION_SELECTORS)
206
+ if value and value != title:
207
+ return value, "css"
208
+ candidates = [
209
+ text for node in root.find_all(["p", "div"], recursive=True)
210
+ if (text := multiline_text_of(node)) and text != title and len(text) >= 30
211
+ and not any(token in text.casefold() for token in ("зарегистрирован:", "информация о заказчике", "посмотреть другие заказы", "выберите способ верификации"))
212
+ ]
213
+ return (max(candidates, key=len), "text_fallback") if candidates else (None, "missing")
214
+
215
+
216
+ def parse_project_detail(
217
+ html: str,
218
+ url: str,
219
+ *,
220
+ store_raw_html: bool = False,
221
+ fetched_at: datetime | None = None,
222
+ timezone_name: str = "Europe/Moscow",
223
+ ) -> ProjectDetail:
224
+ fetched_at = fetched_at or utc_now()
225
+ soup = BeautifulSoup(html, "lxml")
226
+ root = soup.select_one("main") or soup.body or soup
227
+ json_ld = extract_json_ld(soup)
228
+ json_title = next((item.get("name") for item in json_ld if isinstance(item, dict) and item.get("name")), None)
229
+ css_title = first_text(root, TITLE_SELECTORS)
230
+ meta_title = clean_text(first_attr(soup, ['meta[property="og:title"]'], "content"))
231
+ title = clean_text(str(json_title)) if json_title else css_title or meta_title
232
+ project_id = project_id_from_url(url)
233
+ if not title or project_id is None:
234
+ raise ValueError(f"Could not identify FL.ru project page: {url}")
235
+
236
+ text = multiline_text_of(root) or ""
237
+ budget_match = re.search(r"Бюджет:\s*([^\n]+)", text, re.IGNORECASE)
238
+ published_match = re.search(r"Опубликован(?:о)?\s+([^\n]+)", text, re.IGNORECASE)
239
+ updated_match = re.search(r"Последнее изменение:\s*([^\n]+)", text, re.IGNORECASE)
240
+ response_match = re.search(r"(\d+)\s+ответ", text, re.IGNORECASE)
241
+ user_anchor = root.find("a", href=re.compile(r"/users/[^/?#]+/?"))
242
+ customer = None
243
+ if user_anchor:
244
+ user_url = absolute_url(url, str(user_anchor.get("href")))
245
+ customer = UserSummary(username=username_from_url(user_url or ""), name=text_of(user_anchor), url=user_url)
246
+ executor = None
247
+ if marker := root.find(string=re.compile(r"Заказчик выбрал исполнителя", re.IGNORECASE)):
248
+ parent = marker.parent if isinstance(marker.parent, Tag) else root
249
+ if executor_anchor := parent.find_next("a", href=re.compile(r"/users/[^/?#]+/?")):
250
+ executor_url = absolute_url(url, str(executor_anchor.get("href")))
251
+ executor = UserSummary(username=username_from_url(executor_url or ""), name=text_of(executor_anchor), url=executor_url)
252
+
253
+ links, images = extract_links(root, url), extract_images(root, url)
254
+ attachments = [Attachment(name=link.text, url=link.url) for link in links if re.search(r"\.(?:pdf|docx?|xlsx?|zip|rar|7z|png|jpe?g)(?:\?|$)", link.url, re.IGNORECASE)]
255
+ breadcrumbs = [text_of(node) or "" for node in root.select('[class*="breadcrumb"] a, nav[aria-label*="breadcrumb" i] a') if text_of(node)]
256
+ if not breadcrumbs:
257
+ breadcrumbs = [text_of(node) or "" for node in root.select("a[href*='/projects/category/']")[:4] if text_of(node)]
258
+ full_description, description_source = _description_from_detail(root, title)
259
+ published_raw = clean_text(published_match.group(1)) if published_match else None
260
+ updated_raw = clean_text(updated_match.group(1)) if updated_match else None
261
+ return ProjectDetail(
262
+ id=project_id, title=title, url=url, description=full_description, full_description=full_description,
263
+ budget=parse_money(budget_match.group(1) if budget_match else None), kind=_kind(text), status=_status(text),
264
+ category=breadcrumbs[-2] if len(breadcrumbs) >= 2 else None,
265
+ subcategory=breadcrumbs[-1] if breadcrumbs else None,
266
+ published_at=parse_ru_datetime(published_raw, now=fetched_at, timezone_name=timezone_name), published_raw=published_raw,
267
+ updated_at=parse_ru_datetime(updated_raw, now=fetched_at, timezone_name=timezone_name), updated_raw=updated_raw,
268
+ responses_count=int(response_match.group(1)) if response_match else None,
269
+ customer=customer, executor=executor, breadcrumbs=breadcrumbs, attachments=attachments,
270
+ links=links, images=images, metadata=extract_meta(soup), raw_text=text,
271
+ raw_html=html if store_raw_html else None, source=SourceInfo(source_url=url, fetched_at=fetched_at),
272
+ extra={"field_sources": {"title": "json_ld" if json_title else "css" if css_title else "open_graph", "description": description_source}},
273
+ )
274
+
275
+
276
+ def with_page(url: str, page: int) -> str:
277
+ parts = list(urlsplit(url))
278
+ query = parse_qs(parts[3], keep_blank_values=True)
279
+ query["page"] = [str(page)]
280
+ parts[3] = urlencode(query, doseq=True)
281
+ return urlunsplit(parts)