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/users.py ADDED
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ from bs4 import BeautifulSoup, Tag
7
+
8
+ from ..models import PortfolioItem, Review, SourceInfo, UserProfile, UserSummary
9
+ from .common import (
10
+ absolute_url,
11
+ clean_text,
12
+ extract_images,
13
+ extract_links,
14
+ extract_meta,
15
+ first_attr,
16
+ first_text,
17
+ parse_decimal,
18
+ parse_int,
19
+ multiline_text_of,
20
+ parse_money,
21
+ text_of,
22
+ username_from_url,
23
+ utc_now,
24
+ )
25
+ from .projects import parse_project_list
26
+
27
+
28
+ def _title_fields(soup: BeautifulSoup) -> tuple[str | None, int | None, str | None, str | None]:
29
+ title = clean_text(soup.title.string if soup.title else None) or ""
30
+ name_match = re.search(r"(?:Заказчик|Фрилансер)\s+(.+?)\s+ID:\s*(\d+)", title, re.IGNORECASE)
31
+ user_id = int(name_match.group(2)) if name_match else None
32
+ name = clean_text(name_match.group(1)) if name_match else None
33
+ role = None
34
+ if title.casefold().startswith("заказчик"):
35
+ role = "customer"
36
+ elif title.casefold().startswith("фрилансер"):
37
+ role = "freelancer"
38
+
39
+ location = None
40
+ location_match = re.search(r"FL\.ru,\s*(.+?)(?:\s*\(|$)", title, re.IGNORECASE)
41
+ if location_match:
42
+ location = clean_text(location_match.group(1))
43
+ return name, user_id, role, location
44
+
45
+
46
+ def _extract_counts(text: str) -> tuple[int | None, int | None, int | None]:
47
+ all_match = re.search(r"Все\s*\((\d+)\)", text, re.IGNORECASE)
48
+ vacancies = re.search(r"Вакансии\s*\((\d+)\)", text, re.IGNORECASE)
49
+ contests = re.search(r"Конкурсы\s*\((\d+)\)", text, re.IGNORECASE)
50
+ return (
51
+ int(all_match.group(1)) if all_match else None,
52
+ int(vacancies.group(1)) if vacancies else None,
53
+ int(contests.group(1)) if contests else None,
54
+ )
55
+
56
+
57
+ def _extract_reviews(root: Tag, url: str) -> list[Review]:
58
+ reviews: list[Review] = []
59
+ candidates = root.select('[class*="opinion"], [class*="review"]')
60
+ for node in candidates:
61
+ text = multiline_text_of(node)
62
+ if not text or len(text) < 15:
63
+ continue
64
+ author_anchor = node.find("a", href=re.compile(r"/users/[^/?#]+/?"))
65
+ author = None
66
+ if author_anchor:
67
+ author_url = absolute_url(url, str(author_anchor.get("href")))
68
+ author = UserSummary(
69
+ username=username_from_url(author_url or ""),
70
+ name=text_of(author_anchor),
71
+ url=author_url,
72
+ )
73
+ sentiment = "negative" if re.search(r"отрицат|минус", text, re.IGNORECASE) else None
74
+ if sentiment is None and re.search(r"положит|плюс", text, re.IGNORECASE):
75
+ sentiment = "positive"
76
+ reviews.append(Review(author=author, text=text, sentiment=sentiment))
77
+ return reviews
78
+
79
+
80
+ def _extract_portfolio(root: Tag, url: str) -> list[PortfolioItem]:
81
+ items: list[PortfolioItem] = []
82
+ seen: set[str] = set()
83
+ for node in root.select('[class*="portfolio"] a[href], a[href*="/portfolio/"]'):
84
+ href = absolute_url(url, str(node.get("href")))
85
+ if not href or href in seen:
86
+ continue
87
+ seen.add(href)
88
+ card = node.find_parent(["article", "li", "div"]) or node
89
+ image = card.select_one("img[src], img[data-src]") if isinstance(card, Tag) else None
90
+ image_url = (
91
+ absolute_url(url, str(image.get("src") or image.get("data-src"))) if image else None
92
+ )
93
+ card_text = multiline_text_of(card) if isinstance(card, Tag) else text_of(node)
94
+ items.append(
95
+ PortfolioItem(
96
+ title=text_of(node),
97
+ url=href,
98
+ description=card_text,
99
+ image_url=image_url,
100
+ price=parse_money(card_text),
101
+ )
102
+ )
103
+ return items
104
+
105
+
106
+ def parse_user_profile(html: str, url: str, *, store_raw_html: bool = False, fetched_at=None) -> UserProfile:
107
+ fetched_at = fetched_at or utc_now()
108
+ soup = BeautifulSoup(html, "lxml")
109
+ root = soup.select_one("main") or soup.body or soup
110
+ raw_text = multiline_text_of(root) or ""
111
+ name_from_title, user_id, role, location = _title_fields(soup)
112
+ name = first_text(
113
+ root,
114
+ (
115
+ "h1",
116
+ '[itemprop="name"]',
117
+ ".user-name",
118
+ '[class*="profile"] h2',
119
+ ),
120
+ ) or name_from_title
121
+ username = username_from_url(url)
122
+
123
+ registered_match = re.search(r"На сайте\s+([^\n(]+)", raw_text, re.IGNORECASE)
124
+ last_seen_match = re.search(r"\((?:заходил|заходила)\s+([^)]+)\)", raw_text, re.IGNORECASE)
125
+ rating_match = re.search(r"Рейтинг\s+([\d\s.,]+)", raw_text, re.IGNORECASE)
126
+ deals_match = re.search(r"Безопасные сделки\s+(\d+)", raw_text, re.IGNORECASE)
127
+ reviews_match = re.search(r"Отзывы\s*\+?\s*(\d+)\s*-\s*(\d+)", raw_text, re.IGNORECASE)
128
+ if not reviews_match:
129
+ reviews_match = re.search(r"\+\s*(\d+)\s*-\s*(\d+)", raw_text)
130
+ projects_count, vacancies_count, contests_count = _extract_counts(raw_text)
131
+
132
+ avatar = first_attr(
133
+ root,
134
+ (
135
+ '[itemprop="image"]',
136
+ 'img[class*="avatar"]',
137
+ 'img[class*="userpic"]',
138
+ ),
139
+ "src",
140
+ )
141
+ about = first_text(
142
+ root,
143
+ (
144
+ '[itemprop="description"]',
145
+ '[class*="about"]',
146
+ '[class*="user-info"]',
147
+ ),
148
+ )
149
+ skill_nodes = root.select(
150
+ '[class*="skill"] a, [class*="specialization"] a, a[href*="/freelancers/"]'
151
+ )
152
+ skills = list(dict.fromkeys(text for node in skill_nodes if (text := text_of(node))))
153
+
154
+ project_page = parse_project_list(html, url, page=1, store_raw_html=False)
155
+ links = extract_links(root, url)
156
+ images = extract_images(root, url)
157
+ verified = bool(
158
+ root.select_one('[class*="verified"], [title*="верифиц" i]')
159
+ or re.search(r"верифицирован", raw_text, re.IGNORECASE)
160
+ )
161
+
162
+ return UserProfile(
163
+ username=username,
164
+ user_id=user_id,
165
+ name=name,
166
+ url=url,
167
+ avatar_url=absolute_url(url, avatar),
168
+ role=role,
169
+ location=location,
170
+ rating=parse_decimal(rating_match.group(1)) if rating_match else None,
171
+ reviews_positive=int(reviews_match.group(1)) if reviews_match else None,
172
+ reviews_negative=int(reviews_match.group(2)) if reviews_match else None,
173
+ safe_deals=int(deals_match.group(1)) if deals_match else None,
174
+ verified=verified,
175
+ registered_raw=clean_text(registered_match.group(1)) if registered_match else None,
176
+ last_seen_raw=clean_text(last_seen_match.group(1)) if last_seen_match else None,
177
+ about=about,
178
+ skills=skills,
179
+ specializations=skills,
180
+ projects_count=projects_count,
181
+ vacancies_count=vacancies_count,
182
+ contests_count=contests_count,
183
+ reviews=_extract_reviews(root, url),
184
+ projects=project_page.items,
185
+ portfolio=_extract_portfolio(root, url),
186
+ links=links,
187
+ images=images,
188
+ metadata=extract_meta(soup),
189
+ raw_text=raw_text,
190
+ raw_html=html if store_raw_html else None,
191
+ source=SourceInfo(source_url=url, fetched_at=fetched_at),
192
+ extra={"field_sources": {"name": "css_or_title", "profile": "user_url_pattern"}},
193
+ )
flru/proxy.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ from dataclasses import dataclass
6
+ from time import monotonic
7
+
8
+ from .config import ProxyConfig
9
+ from .security import redact_url
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class ProxyState:
14
+ url: str | None
15
+ failures: int = 0
16
+ successes: int = 0
17
+ cooldown_until: float = 0.0
18
+ last_error: str | None = None
19
+
20
+ @property
21
+ def available(self) -> bool:
22
+ return monotonic() >= self.cooldown_until
23
+
24
+ @property
25
+ def safe_url(self) -> str | None:
26
+ return redact_url(self.url)
27
+
28
+
29
+ class ProxyPool:
30
+ """Health-aware proxy selector. A ``None`` URL represents direct access."""
31
+
32
+ def __init__(self, config: ProxyConfig) -> None:
33
+ states = [ProxyState(url=url) for url in config.urls]
34
+ if config.direct_fallback or not states:
35
+ states.append(ProxyState(url=None))
36
+ self._states = states
37
+ self._config = config
38
+ self._index = 0
39
+ self._lock = asyncio.Lock()
40
+
41
+ async def acquire(self) -> ProxyState:
42
+ while True:
43
+ async with self._lock:
44
+ available = [state for state in self._states if state.available]
45
+ if available:
46
+ if self._config.strategy == "random":
47
+ return random.choice(available)
48
+ for _ in range(len(self._states)):
49
+ state = self._states[self._index % len(self._states)]
50
+ self._index += 1
51
+ if state.available:
52
+ return state
53
+ return available[0]
54
+ wait = max(
55
+ 0.01,
56
+ min(state.cooldown_until for state in self._states) - monotonic(),
57
+ )
58
+ await asyncio.sleep(wait)
59
+
60
+ async def success(self, state: ProxyState) -> None:
61
+ async with self._lock:
62
+ state.successes += 1
63
+ state.failures = max(0, state.failures - 1)
64
+ state.last_error = None
65
+
66
+ async def failure(self, state: ProxyState, error: Exception) -> None:
67
+ async with self._lock:
68
+ state.last_error = type(error).__name__
69
+ if state.url is None:
70
+ return
71
+ state.failures += 1
72
+ if state.failures >= self._config.max_failures:
73
+ state.cooldown_until = monotonic() + self._config.cooldown
74
+ state.failures = 0
75
+
76
+ async def snapshot(self) -> list[ProxyState]:
77
+ async with self._lock:
78
+ return [
79
+ ProxyState(
80
+ url=state.safe_url,
81
+ failures=state.failures,
82
+ successes=state.successes,
83
+ cooldown_until=state.cooldown_until,
84
+ last_error=state.last_error,
85
+ )
86
+ for state in self._states
87
+ ]
flru/py.typed ADDED
File without changes
flru/resilience.py ADDED
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from time import monotonic
8
+
9
+ from .config import CircuitBreakerConfig, RateLimitConfig
10
+ from .exceptions import CircuitOpenError
11
+
12
+
13
+ class AsyncRateLimiter:
14
+ """Token bucket, concurrency cap, minimum interval, and shared cooldown."""
15
+
16
+ def __init__(self, config: RateLimitConfig) -> None:
17
+ self._rate = config.requests_per_second
18
+ self._capacity = max(1, config.burst)
19
+ self._tokens = float(self._capacity)
20
+ self._updated_at = monotonic()
21
+ self._min_interval = config.min_interval
22
+ self._jitter = config.interval_jitter
23
+ self._last_start = 0.0
24
+ self._paused_until = 0.0
25
+ self._lock = asyncio.Lock()
26
+ self._concurrency = asyncio.Semaphore(config.max_concurrency)
27
+
28
+ async def __aenter__(self) -> AsyncRateLimiter:
29
+ await self._concurrency.acquire()
30
+ try:
31
+ await self.acquire()
32
+ except BaseException:
33
+ self._concurrency.release()
34
+ raise
35
+ return self
36
+
37
+ async def __aexit__(self, *_: object) -> None:
38
+ self._concurrency.release()
39
+
40
+ async def pause_for(self, seconds: float) -> None:
41
+ async with self._lock:
42
+ self._paused_until = max(self._paused_until, monotonic() + max(0.0, seconds))
43
+
44
+ async def acquire(self) -> None:
45
+ while True:
46
+ wait = 0.0
47
+ async with self._lock:
48
+ now = monotonic()
49
+ pause_wait = self._paused_until - now
50
+ if pause_wait > 0:
51
+ wait = pause_wait
52
+ else:
53
+ elapsed = now - self._updated_at
54
+ self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
55
+ self._updated_at = now
56
+ interval = self._min_interval + random.uniform(0, self._jitter)
57
+ interval_wait = max(0.0, self._last_start + interval - now)
58
+ token_wait = 0.0 if self._tokens >= 1 else (1 - self._tokens) / self._rate
59
+ wait = max(interval_wait, token_wait)
60
+ if wait <= 0:
61
+ self._tokens -= 1
62
+ self._last_start = now
63
+ return
64
+ await asyncio.sleep(wait)
65
+
66
+
67
+ class CircuitState(StrEnum):
68
+ CLOSED = "closed"
69
+ OPEN = "open"
70
+ HALF_OPEN = "half_open"
71
+
72
+
73
+ class CircuitBreaker:
74
+ def __init__(self, config: CircuitBreakerConfig) -> None:
75
+ self._config = config
76
+ self._state = CircuitState.CLOSED
77
+ self._failures = 0
78
+ self._opened_at = 0.0
79
+ self._half_open_calls = 0
80
+ self._lock = asyncio.Lock()
81
+
82
+ @property
83
+ def state(self) -> CircuitState:
84
+ return self._state
85
+
86
+ async def before_call(self) -> None:
87
+ if not self._config.enabled:
88
+ return
89
+ async with self._lock:
90
+ if self._state is CircuitState.OPEN:
91
+ if monotonic() - self._opened_at >= self._config.recovery_timeout:
92
+ self._state = CircuitState.HALF_OPEN
93
+ self._half_open_calls = 0
94
+ else:
95
+ raise CircuitOpenError("Circuit breaker is open")
96
+ if self._state is CircuitState.HALF_OPEN:
97
+ if self._half_open_calls >= self._config.half_open_max_calls:
98
+ raise CircuitOpenError("Circuit breaker is testing recovery")
99
+ self._half_open_calls += 1
100
+
101
+ async def record_success(self) -> None:
102
+ if not self._config.enabled:
103
+ return
104
+ async with self._lock:
105
+ self._state = CircuitState.CLOSED
106
+ self._failures = 0
107
+ self._half_open_calls = 0
108
+
109
+ async def record_failure(self) -> None:
110
+ if not self._config.enabled:
111
+ return
112
+ async with self._lock:
113
+ self._failures += 1
114
+ if self._state is CircuitState.HALF_OPEN or self._failures >= self._config.failure_threshold:
115
+ self._state = CircuitState.OPEN
116
+ self._opened_at = monotonic()
117
+ self._half_open_calls = 0
118
+
119
+
120
+ class CircuitBreakerRegistry:
121
+ def __init__(self, config: CircuitBreakerConfig) -> None:
122
+ self._config = config
123
+ self._breakers: dict[str, CircuitBreaker] = {}
124
+ self._lock = asyncio.Lock()
125
+
126
+ async def get(self, key: str) -> CircuitBreaker:
127
+ async with self._lock:
128
+ breaker = self._breakers.get(key)
129
+ if breaker is None:
130
+ breaker = CircuitBreaker(self._config)
131
+ self._breakers[key] = breaker
132
+ return breaker
133
+
134
+
135
+ @dataclass(slots=True)
136
+ class RetryBudget:
137
+ total_timeout: float
138
+ max_total_delay: float
139
+ started_at: float = field(init=False)
140
+ total_delay: float = field(init=False, default=0.0)
141
+
142
+ def __post_init__(self) -> None:
143
+ self.started_at = monotonic()
144
+ self.total_delay = 0.0
145
+
146
+ @property
147
+ def elapsed(self) -> float:
148
+ return monotonic() - self.started_at
149
+
150
+ def allows(self, delay: float = 0.0) -> bool:
151
+ return (
152
+ self.elapsed + delay <= self.total_timeout
153
+ and self.total_delay + delay <= self.max_total_delay
154
+ )
155
+
156
+ def consume_delay(self, delay: float) -> None:
157
+ self.total_delay += delay
flru/robots.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from time import monotonic
5
+ from urllib.parse import urljoin, urlsplit
6
+ from urllib.robotparser import RobotFileParser
7
+
8
+ import httpx
9
+
10
+
11
+ class RobotsPolicy:
12
+ def __init__(self, *, base_url: str, user_agent: str, timeout: httpx.Timeout, verify: bool, cache_ttl: float, fail_closed: bool) -> None:
13
+ parts = urlsplit(base_url)
14
+ self._robots_url = f"{parts.scheme}://{parts.netloc}/robots.txt"
15
+ self._user_agent = user_agent
16
+ self._timeout = timeout
17
+ self._verify = verify
18
+ self._cache_ttl = cache_ttl
19
+ self._fail_closed = fail_closed
20
+ self._parser: RobotFileParser | None = None
21
+ self._loaded_at = 0.0
22
+ self._lock = asyncio.Lock()
23
+
24
+ async def allowed(self, url: str) -> bool:
25
+ parser = await self._get_parser()
26
+ return not self._fail_closed if parser is None else parser.can_fetch(self._user_agent, urljoin(self._robots_url, url))
27
+
28
+ async def _get_parser(self) -> RobotFileParser | None:
29
+ if self._parser is not None and monotonic() - self._loaded_at < self._cache_ttl:
30
+ return self._parser
31
+ async with self._lock:
32
+ if self._parser is not None and monotonic() - self._loaded_at < self._cache_ttl:
33
+ return self._parser
34
+ try:
35
+ async with httpx.AsyncClient(timeout=self._timeout, verify=self._verify, follow_redirects=True) as client:
36
+ response = await client.get(self._robots_url, headers={"User-Agent": self._user_agent})
37
+ response.raise_for_status()
38
+ parser = RobotFileParser()
39
+ parser.set_url(self._robots_url)
40
+ parser.parse(response.text.splitlines())
41
+ self._parser, self._loaded_at = parser, monotonic()
42
+ return parser
43
+ except (httpx.HTTPError, ValueError):
44
+ self._loaded_at = monotonic()
45
+ return None
flru/security.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
5
+
6
+ from .exceptions import SecurityError
7
+
8
+ _SECRET_QUERY_RE = re.compile(r"(?i)(token|key|password|secret|signature)=([^&]+)")
9
+
10
+
11
+ def is_allowed_host(host: str | None, allowed_hosts: frozenset[str], allow_subdomains: bool) -> bool:
12
+ if not host:
13
+ return False
14
+ host = host.casefold().rstrip(".")
15
+ return any(
16
+ host == allowed or (allow_subdomains and host.endswith(f".{allowed}"))
17
+ for allowed in allowed_hosts
18
+ )
19
+
20
+
21
+ def validate_url(url: str, allowed_hosts: frozenset[str], allow_subdomains: bool) -> str:
22
+ parts = urlsplit(url)
23
+ if parts.scheme not in {"http", "https"}:
24
+ raise SecurityError(f"Unsupported URL scheme: {parts.scheme!r}")
25
+ if not is_allowed_host(parts.hostname, allowed_hosts, allow_subdomains):
26
+ raise SecurityError(f"Host is not allowed: {parts.hostname!r}")
27
+ return url
28
+
29
+
30
+ def redact_url(url: str | None) -> str | None:
31
+ if url is None:
32
+ return None
33
+ parts = urlsplit(url)
34
+ hostname = parts.hostname or ""
35
+ port = f":{parts.port}" if parts.port else ""
36
+ userinfo = ""
37
+ if parts.username:
38
+ userinfo = f"{parts.username}:***@"
39
+ netloc = f"{userinfo}{hostname}{port}"
40
+ query = _SECRET_QUERY_RE.sub(lambda match: f"{match.group(1)}=***", parts.query)
41
+ return urlunsplit(SplitResult(parts.scheme, netloc, parts.path, query, parts.fragment))