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