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/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
|
+
)
|
flru/parsers/projects.py
ADDED
|
@@ -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)
|