oniscrape 0.1.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.
- fast_scraper/__init__.py +37 -0
- fast_scraper/client.py +175 -0
- fast_scraper/config.py +49 -0
- fast_scraper/cookies/__init__.py +5 -0
- fast_scraper/cookies/base.py +20 -0
- fast_scraper/cookies/memory.py +28 -0
- fast_scraper/cookies/redis_storage.py +38 -0
- fast_scraper/dom/__init__.py +15 -0
- fast_scraper/dom/parser.py +52 -0
- fast_scraper/extractors/__init__.py +14 -0
- fast_scraper/extractors/base.py +6 -0
- fast_scraper/extractors/generic.py +50 -0
- fast_scraper/extractors/meta.py +53 -0
- fast_scraper/extractors/nextjs.py +100 -0
- fast_scraper/extractors/nuxt.py +29 -0
- fast_scraper/pipeline/__init__.py +3 -0
- fast_scraper/pipeline/schema_mapper.py +55 -0
- fast_scraper/proxy/__init__.py +4 -0
- fast_scraper/proxy/health.py +38 -0
- fast_scraper/proxy/manager.py +69 -0
- fast_scraper/py.typed +1 -0
- oniscrape/__init__.py +49 -0
- oniscrape/py.typed +1 -0
- oniscrape-0.1.0.dist-info/METADATA +261 -0
- oniscrape-0.1.0.dist-info/RECORD +27 -0
- oniscrape-0.1.0.dist-info/WHEEL +4 -0
- oniscrape-0.1.0.dist-info/licenses/LICENSE +21 -0
fast_scraper/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .client import ScraperClient, ScraperRequestError
|
|
2
|
+
from .config import ProxyConfig, RetryConfig, ScraperConfig
|
|
3
|
+
from .cookies.base import BaseCookieStorage
|
|
4
|
+
from .cookies.memory import InMemoryCookieStorage
|
|
5
|
+
from .cookies.redis_storage import RedisCookieStorage
|
|
6
|
+
from .dom.parser import css_all_attr, css_all_text, css_attr, css_text, parse_html
|
|
7
|
+
from .extractors.generic import extract_json_ld, extract_json_scripts
|
|
8
|
+
from .extractors.meta import extract_meta_relay, extract_meta_tokens
|
|
9
|
+
from .extractors.nextjs import extract_next_data, extract_rsc_flight
|
|
10
|
+
from .extractors.nuxt import extract_nuxt_data
|
|
11
|
+
from .pipeline.schema_mapper import map_state_to_model
|
|
12
|
+
from .proxy.manager import ProxyManager
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ScraperClient",
|
|
16
|
+
"ScraperRequestError",
|
|
17
|
+
"ScraperConfig",
|
|
18
|
+
"ProxyConfig",
|
|
19
|
+
"RetryConfig",
|
|
20
|
+
"ProxyManager",
|
|
21
|
+
"BaseCookieStorage",
|
|
22
|
+
"InMemoryCookieStorage",
|
|
23
|
+
"RedisCookieStorage",
|
|
24
|
+
"parse_html",
|
|
25
|
+
"css_text",
|
|
26
|
+
"css_all_text",
|
|
27
|
+
"css_attr",
|
|
28
|
+
"css_all_attr",
|
|
29
|
+
"extract_next_data",
|
|
30
|
+
"extract_rsc_flight",
|
|
31
|
+
"extract_meta_relay",
|
|
32
|
+
"extract_meta_tokens",
|
|
33
|
+
"extract_nuxt_data",
|
|
34
|
+
"extract_json_ld",
|
|
35
|
+
"extract_json_scripts",
|
|
36
|
+
"map_state_to_model",
|
|
37
|
+
]
|
fast_scraper/client.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
from typing import Any, TypeVar
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from curl_cffi.requests import AsyncSession, Response
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from tenacity import (
|
|
9
|
+
AsyncRetrying,
|
|
10
|
+
retry_if_exception_type,
|
|
11
|
+
stop_after_attempt,
|
|
12
|
+
wait_exponential,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .config import ScraperConfig
|
|
16
|
+
from .cookies.base import BaseCookieStorage
|
|
17
|
+
from .cookies.memory import InMemoryCookieStorage
|
|
18
|
+
from .proxy.manager import ProxyManager
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T", bound=BaseModel)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ScraperRequestError(Exception):
|
|
24
|
+
"""Исключение сетевого сбоя или блокировки антифродом."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, status_code: int | None = None, url: str | None = None):
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.url = url
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScraperClient:
|
|
33
|
+
"""Асинхронный клиент для парсинга с поддержкой curl_cffi, TLS-фингерпринтов,
|
|
34
|
+
изоляции прокси-сессий и автоматических ретраев.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
config: ScraperConfig | None = None,
|
|
40
|
+
proxy_manager: ProxyManager | None = None,
|
|
41
|
+
cookie_storage: BaseCookieStorage | None = None,
|
|
42
|
+
):
|
|
43
|
+
self.config = config or ScraperConfig()
|
|
44
|
+
self.proxy_manager = proxy_manager or (
|
|
45
|
+
ProxyManager(self.config.proxy) if self.config.proxy.urls else None
|
|
46
|
+
)
|
|
47
|
+
self.cookie_storage = cookie_storage or InMemoryCookieStorage()
|
|
48
|
+
|
|
49
|
+
async def __aenter__(self) -> "ScraperClient":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
@asynccontextmanager
|
|
56
|
+
async def create_session(
|
|
57
|
+
self,
|
|
58
|
+
proxy: str | None = None,
|
|
59
|
+
impersonate: str | None = None,
|
|
60
|
+
custom_headers: dict[str, str] | None = None,
|
|
61
|
+
) -> AsyncIterator[AsyncSession]:
|
|
62
|
+
"""Создает изолированную сессию под конкретный прокси с гарантированным
|
|
63
|
+
закрытием сокетов libcurl по завершении блока.
|
|
64
|
+
"""
|
|
65
|
+
target_impersonate = impersonate or self.config.default_impersonate
|
|
66
|
+
|
|
67
|
+
headers = self.config.headers.copy()
|
|
68
|
+
if custom_headers:
|
|
69
|
+
headers.update(custom_headers)
|
|
70
|
+
|
|
71
|
+
session = AsyncSession(
|
|
72
|
+
impersonate=target_impersonate,
|
|
73
|
+
proxy=proxy,
|
|
74
|
+
headers=headers if headers else None,
|
|
75
|
+
verify=self.config.verify_ssl,
|
|
76
|
+
timeout=self.config.timeout_seconds,
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
yield session
|
|
80
|
+
finally:
|
|
81
|
+
await session.close()
|
|
82
|
+
|
|
83
|
+
async def request(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
url: str,
|
|
87
|
+
impersonate: str | None = None,
|
|
88
|
+
proxy: str | None = None,
|
|
89
|
+
headers: dict[str, str] | None = None,
|
|
90
|
+
params: dict[str, Any] | None = None,
|
|
91
|
+
data: Any = None,
|
|
92
|
+
json: Any = None,
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> Response:
|
|
95
|
+
"""Выполняет HTTP-запрос с автоматическим выбором прокси из пула и ретраями."""
|
|
96
|
+
retry_cfg = self.config.retry
|
|
97
|
+
domain = urlparse(url).netloc
|
|
98
|
+
|
|
99
|
+
retrying = AsyncRetrying(
|
|
100
|
+
stop=stop_after_attempt(retry_cfg.max_attempts),
|
|
101
|
+
wait=wait_exponential(
|
|
102
|
+
multiplier=retry_cfg.min_backoff_seconds,
|
|
103
|
+
max=retry_cfg.max_backoff_seconds,
|
|
104
|
+
),
|
|
105
|
+
retry=retry_if_exception_type((ScraperRequestError, Exception)),
|
|
106
|
+
reraise=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async for attempt in retrying:
|
|
110
|
+
with attempt:
|
|
111
|
+
# Выбираем прокси для текущей попытки
|
|
112
|
+
current_proxy = proxy
|
|
113
|
+
if current_proxy is None and self.proxy_manager:
|
|
114
|
+
current_proxy = self.proxy_manager.get_proxy()
|
|
115
|
+
|
|
116
|
+
# Получаем сохраненные куки для домена
|
|
117
|
+
stored_cookies = await self.cookie_storage.get_cookies(domain)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
async with self.create_session(
|
|
121
|
+
proxy=current_proxy,
|
|
122
|
+
impersonate=impersonate,
|
|
123
|
+
custom_headers=headers,
|
|
124
|
+
) as session:
|
|
125
|
+
# Устанавливаем куки в сессию
|
|
126
|
+
if stored_cookies:
|
|
127
|
+
session.cookies.update(stored_cookies)
|
|
128
|
+
|
|
129
|
+
response = await session.request(
|
|
130
|
+
method=method, # type: ignore[arg-type]
|
|
131
|
+
url=url,
|
|
132
|
+
params=params,
|
|
133
|
+
data=data,
|
|
134
|
+
json=json,
|
|
135
|
+
**kwargs,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Сохраняем новые куки из ответа
|
|
139
|
+
if response.cookies:
|
|
140
|
+
new_cookies = dict(response.cookies)
|
|
141
|
+
if new_cookies:
|
|
142
|
+
await self.cookie_storage.set_cookies(domain, new_cookies)
|
|
143
|
+
|
|
144
|
+
# Проверяем антифрод / ошибки статуса
|
|
145
|
+
if response.status_code in retry_cfg.retry_status_codes:
|
|
146
|
+
if self.proxy_manager and current_proxy:
|
|
147
|
+
self.proxy_manager.report_fail(current_proxy)
|
|
148
|
+
raise ScraperRequestError(
|
|
149
|
+
f"Received retryable status code {response.status_code} for {url}",
|
|
150
|
+
status_code=response.status_code,
|
|
151
|
+
url=url,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Фиксируем успех прокси
|
|
155
|
+
if self.proxy_manager and current_proxy:
|
|
156
|
+
self.proxy_manager.report_success(current_proxy)
|
|
157
|
+
|
|
158
|
+
return response
|
|
159
|
+
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
if self.proxy_manager and current_proxy:
|
|
162
|
+
self.proxy_manager.report_fail(current_proxy)
|
|
163
|
+
if isinstance(exc, ScraperRequestError):
|
|
164
|
+
raise
|
|
165
|
+
raise ScraperRequestError(
|
|
166
|
+
f"Network error during {method} {url}: {exc}", url=url
|
|
167
|
+
) from exc
|
|
168
|
+
|
|
169
|
+
raise ScraperRequestError(f"Max attempts exceeded for {url}")
|
|
170
|
+
|
|
171
|
+
async def get(self, url: str, **kwargs: Any) -> Response:
|
|
172
|
+
return await self.request("GET", url, **kwargs)
|
|
173
|
+
|
|
174
|
+
async def post(self, url: str, **kwargs: Any) -> Response:
|
|
175
|
+
return await self.request("POST", url, **kwargs)
|
fast_scraper/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProxyConfig(BaseModel):
|
|
7
|
+
model_config = ConfigDict(frozen=True)
|
|
8
|
+
|
|
9
|
+
urls: list[str] = Field(
|
|
10
|
+
default_factory=list, description="Список URL прокси (socks5://..., http://...)"
|
|
11
|
+
)
|
|
12
|
+
cooldown_seconds: float = Field(
|
|
13
|
+
default=120.0, ge=0.0, description="Время кулдауна заблокированного прокси"
|
|
14
|
+
)
|
|
15
|
+
max_fails_before_cooldown: int = Field(
|
|
16
|
+
default=2, ge=1, description="Количество сбоев до отправки в кулдаун"
|
|
17
|
+
)
|
|
18
|
+
strategy: Literal["round_robin", "random", "least_failed"] = Field(
|
|
19
|
+
default="round_robin", description="Стратегия выбора прокси"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RetryConfig(BaseModel):
|
|
24
|
+
model_config = ConfigDict(frozen=True)
|
|
25
|
+
|
|
26
|
+
max_attempts: int = Field(default=3, ge=1, description="Максимальное количество попыток")
|
|
27
|
+
min_backoff_seconds: float = Field(
|
|
28
|
+
default=0.5, ge=0.0, description="Минимальная пауза между попытками"
|
|
29
|
+
)
|
|
30
|
+
max_backoff_seconds: float = Field(default=5.0, ge=0.0, description="Максимальная пауза")
|
|
31
|
+
retry_status_codes: set[int] = Field(
|
|
32
|
+
default_factory=lambda: {403, 429, 500, 502, 503, 504},
|
|
33
|
+
description="HTTP-статусы, требующие повторного запроса",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ScraperConfig(BaseModel):
|
|
38
|
+
model_config = ConfigDict(frozen=True)
|
|
39
|
+
|
|
40
|
+
default_impersonate: str = Field(
|
|
41
|
+
default="chrome124", description="Дефолтный профиль TLS-фингерпринта в curl_cffi"
|
|
42
|
+
)
|
|
43
|
+
timeout_seconds: float = Field(default=30.0, ge=1.0, description="Таймаут сетевых запросов")
|
|
44
|
+
verify_ssl: bool = Field(default=True, description="Проверка SSL-сертификатов")
|
|
45
|
+
proxy: ProxyConfig = Field(default_factory=ProxyConfig, description="Настройки пула прокси")
|
|
46
|
+
retry: RetryConfig = Field(default_factory=RetryConfig, description="Настройки ретраев")
|
|
47
|
+
headers: dict[str, str] = Field(
|
|
48
|
+
default_factory=dict, description="Кастомные дефолтные заголовки"
|
|
49
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaseCookieStorage(ABC):
|
|
5
|
+
"""Абстрактное хранилище куки для сессий скрапера."""
|
|
6
|
+
|
|
7
|
+
@abstractmethod
|
|
8
|
+
async def get_cookies(self, domain: str) -> dict[str, str]:
|
|
9
|
+
"""Получить словарь куки для указанного домена."""
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def set_cookies(self, domain: str, cookies: dict[str, str]) -> None:
|
|
14
|
+
"""Сохранить или обновить куки для домена."""
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def clear(self, domain: str | None = None) -> None:
|
|
19
|
+
"""Очистить куки для домена или все."""
|
|
20
|
+
...
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from .base import BaseCookieStorage
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InMemoryCookieStorage(BaseCookieStorage):
|
|
7
|
+
"""Потокобезопасное хранилище куки в оперативной памяти."""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self._cookies: dict[str, dict[str, str]] = {}
|
|
11
|
+
self._lock = asyncio.Lock()
|
|
12
|
+
|
|
13
|
+
async def get_cookies(self, domain: str) -> dict[str, str]:
|
|
14
|
+
async with self._lock:
|
|
15
|
+
return self._cookies.get(domain, {}).copy()
|
|
16
|
+
|
|
17
|
+
async def set_cookies(self, domain: str, cookies: dict[str, str]) -> None:
|
|
18
|
+
async with self._lock:
|
|
19
|
+
if domain not in self._cookies:
|
|
20
|
+
self._cookies[domain] = {}
|
|
21
|
+
self._cookies[domain].update(cookies)
|
|
22
|
+
|
|
23
|
+
async def clear(self, domain: str | None = None) -> None:
|
|
24
|
+
async with self._lock:
|
|
25
|
+
if domain:
|
|
26
|
+
self._cookies.pop(domain, None)
|
|
27
|
+
else:
|
|
28
|
+
self._cookies.clear()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import orjson
|
|
4
|
+
|
|
5
|
+
from .base import BaseCookieStorage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedisCookieStorage(BaseCookieStorage):
|
|
9
|
+
"""Распределенное асинхронное хранилище куки в Redis."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, redis_client: Any, prefix: str = "fast_scraper:cookies:"):
|
|
12
|
+
self.redis = redis_client
|
|
13
|
+
self.prefix = prefix
|
|
14
|
+
|
|
15
|
+
def _key(self, domain: str) -> str:
|
|
16
|
+
return f"{self.prefix}{domain}"
|
|
17
|
+
|
|
18
|
+
async def get_cookies(self, domain: str) -> dict[str, str]:
|
|
19
|
+
raw = await self.redis.get(self._key(domain))
|
|
20
|
+
if not raw:
|
|
21
|
+
return {}
|
|
22
|
+
try:
|
|
23
|
+
return orjson.loads(raw)
|
|
24
|
+
except Exception:
|
|
25
|
+
return {}
|
|
26
|
+
|
|
27
|
+
async def set_cookies(self, domain: str, cookies: dict[str, str]) -> None:
|
|
28
|
+
current = await self.get_cookies(domain)
|
|
29
|
+
current.update(cookies)
|
|
30
|
+
await self.redis.set(self._key(domain), orjson.dumps(current))
|
|
31
|
+
|
|
32
|
+
async def clear(self, domain: str | None = None) -> None:
|
|
33
|
+
if domain:
|
|
34
|
+
await self.redis.delete(self._key(domain))
|
|
35
|
+
else:
|
|
36
|
+
# Поиск всех ключей с префиксом через SCAN
|
|
37
|
+
async for key in self.redis.scan_iter(match=f"{self.prefix}*"):
|
|
38
|
+
await self.redis.delete(key)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from selectolax.lexbor import LexborHTMLParser, LexborNode
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def parse_html(html: str) -> LexborHTMLParser:
|
|
5
|
+
"""Создает парсер дерева DOM на базе высокопроизводительного C-движка Lexbor."""
|
|
6
|
+
return LexborHTMLParser(html)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def css_text(
|
|
10
|
+
context: LexborHTMLParser | LexborNode, selector: str, default: str = "", strip: bool = True
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Извлекает текстовое содержимое первого найденного по селектору элемента."""
|
|
13
|
+
node = context.css_first(selector)
|
|
14
|
+
if not node or not node.text():
|
|
15
|
+
return default
|
|
16
|
+
text = node.text()
|
|
17
|
+
return text.strip() if strip else text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def css_all_text(
|
|
21
|
+
context: LexborHTMLParser | LexborNode, selector: str, strip: bool = True
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
"""Извлекает список строк из всех найденных по селектору элементов."""
|
|
24
|
+
nodes = context.css(selector)
|
|
25
|
+
result: list[str] = []
|
|
26
|
+
for node in nodes:
|
|
27
|
+
t = node.text()
|
|
28
|
+
if t:
|
|
29
|
+
result.append(t.strip() if strip else t)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def css_attr(
|
|
34
|
+
context: LexborHTMLParser | LexborNode, selector: str, attr: str, default: str = ""
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Извлекает значение атрибута первого найденного элемента."""
|
|
37
|
+
node = context.css_first(selector)
|
|
38
|
+
if not node:
|
|
39
|
+
return default
|
|
40
|
+
val = node.attributes.get(attr)
|
|
41
|
+
return val if val is not None else default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def css_all_attr(context: LexborHTMLParser | LexborNode, selector: str, attr: str) -> list[str]:
|
|
45
|
+
"""Извлекает список значений атрибута для всех найденных элементов."""
|
|
46
|
+
nodes = context.css(selector)
|
|
47
|
+
result: list[str] = []
|
|
48
|
+
for node in nodes:
|
|
49
|
+
val = node.attributes.get(attr)
|
|
50
|
+
if val is not None:
|
|
51
|
+
result.append(val)
|
|
52
|
+
return result
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .generic import extract_json_ld, extract_json_scripts
|
|
2
|
+
from .meta import extract_meta_relay, extract_meta_tokens
|
|
3
|
+
from .nextjs import extract_next_data, extract_rsc_flight
|
|
4
|
+
from .nuxt import extract_nuxt_data
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"extract_next_data",
|
|
8
|
+
"extract_rsc_flight",
|
|
9
|
+
"extract_meta_relay",
|
|
10
|
+
"extract_meta_tokens",
|
|
11
|
+
"extract_nuxt_data",
|
|
12
|
+
"extract_json_ld",
|
|
13
|
+
"extract_json_scripts",
|
|
14
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import orjson
|
|
5
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_json_ld(html: str) -> list[dict[str, Any]]:
|
|
9
|
+
"""Извлекает структурированную микроразметку Schema.org (application/ld+json)."""
|
|
10
|
+
parser = LexborHTMLParser(html)
|
|
11
|
+
results: list[dict[str, Any]] = []
|
|
12
|
+
|
|
13
|
+
for script in parser.css('script[type="application/ld+json"]'):
|
|
14
|
+
text = script.text()
|
|
15
|
+
if not text:
|
|
16
|
+
continue
|
|
17
|
+
try:
|
|
18
|
+
parsed = orjson.loads(text.strip())
|
|
19
|
+
if isinstance(parsed, list):
|
|
20
|
+
results.extend(parsed)
|
|
21
|
+
elif isinstance(parsed, dict):
|
|
22
|
+
results.append(parsed)
|
|
23
|
+
except Exception:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
return results
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_json_scripts(html: str, id_pattern: str | None = None) -> list[dict[str, Any]]:
|
|
30
|
+
"""Извлекает содержимое всех тегов <script type="application/json">,
|
|
31
|
+
опционально фильтруя по регулярному выражению для id.
|
|
32
|
+
"""
|
|
33
|
+
parser = LexborHTMLParser(html)
|
|
34
|
+
results: list[dict[str, Any]] = []
|
|
35
|
+
|
|
36
|
+
for script in parser.css('script[type="application/json"]'):
|
|
37
|
+
script_id = script.attributes.get("id") or ""
|
|
38
|
+
if id_pattern and not re.search(id_pattern, script_id):
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
text = script.text()
|
|
42
|
+
if not text:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
parsed = orjson.loads(text.strip())
|
|
46
|
+
results.append(parsed)
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
return results
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import orjson
|
|
5
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_meta_relay(html: str) -> list[dict[str, Any]]:
|
|
9
|
+
"""Извлекает все preloaded GraphQL-ответы из Relay Stream Cache
|
|
10
|
+
в Threads / Instagram / Facebook.
|
|
11
|
+
"""
|
|
12
|
+
parser = LexborHTMLParser(html)
|
|
13
|
+
results: list[dict[str, Any]] = []
|
|
14
|
+
|
|
15
|
+
for script in parser.css('script[type="application/json"][data-sjs]'):
|
|
16
|
+
text = script.text()
|
|
17
|
+
if not text:
|
|
18
|
+
continue
|
|
19
|
+
try:
|
|
20
|
+
parsed = orjson.loads(text)
|
|
21
|
+
if isinstance(parsed, dict) and "require" in parsed:
|
|
22
|
+
results.append(parsed)
|
|
23
|
+
except Exception:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
return results
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_meta_tokens(html: str) -> dict[str, str]:
|
|
30
|
+
"""Извлекает токены CSRF и идентификаторы приложения из HTML:
|
|
31
|
+
- `lsd`: CSRF-токен для POST-запросов к /api/graphql
|
|
32
|
+
- `app_id`: Идентификатор приложения (например, для x-ig-app-id)
|
|
33
|
+
- `fb_dtsg`: Маркер безопасности Meta
|
|
34
|
+
"""
|
|
35
|
+
lsd_match = re.search(r'\["LSD",\[\],{"token":"([^"]+)"\}', html)
|
|
36
|
+
if not lsd_match:
|
|
37
|
+
lsd_match = re.search(r'"LSD",\[\],\{"token":"([^"]+)"\}', html)
|
|
38
|
+
if not lsd_match:
|
|
39
|
+
lsd_match = re.search(r'name="lsd"\s+value="([^"]+)"', html)
|
|
40
|
+
|
|
41
|
+
app_id_match = re.search(r'\["SiteData",\[\],{"app_id":"([^"]+)"\}', html)
|
|
42
|
+
if not app_id_match:
|
|
43
|
+
app_id_match = re.search(r'"app_id":"(\d+)"', html)
|
|
44
|
+
|
|
45
|
+
dtsg_match = re.search(r'"DTSGInitialData",\[\],{"token":"([^"]+)"\}', html)
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"lsd": lsd_match.group(1) if lsd_match else "",
|
|
49
|
+
"app_id": app_id_match.group(1)
|
|
50
|
+
if app_id_match
|
|
51
|
+
else "238260118697367", # default threads web app id
|
|
52
|
+
"fb_dtsg": dtsg_match.group(1) if dtsg_match else "",
|
|
53
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import orjson
|
|
5
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_next_data(html: str) -> dict[str, Any]:
|
|
9
|
+
"""Извлекает и парсит JSON-состояние __NEXT_DATA__ из Next.js страницы.
|
|
10
|
+
|
|
11
|
+
Raises:
|
|
12
|
+
ValueError: Если тег __NEXT_DATA__ отсутствует или поврежден.
|
|
13
|
+
"""
|
|
14
|
+
parser = LexborHTMLParser(html)
|
|
15
|
+
script_node = parser.css_first("script#__NEXT_DATA__")
|
|
16
|
+
if not script_node or not script_node.text():
|
|
17
|
+
raise ValueError("Next.js state (__NEXT_DATA__) not found in HTML")
|
|
18
|
+
|
|
19
|
+
text = script_node.text().strip()
|
|
20
|
+
return orjson.loads(text)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_flight_line(line: str) -> tuple[str, str, Any] | None:
|
|
24
|
+
"""Разбирает одну строку React Flight wire-протокола вида:
|
|
25
|
+
- '0:[\"$\",\"$L1\",null,{...}]'
|
|
26
|
+
- '1:I[\"module.js\",[\"default\"],\"\"]'
|
|
27
|
+
- '2:\"some string\"'
|
|
28
|
+
- '3:HL[\"...\",...]'
|
|
29
|
+
"""
|
|
30
|
+
line = line.strip()
|
|
31
|
+
if not line or ":" not in line:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
colon_idx = line.find(":")
|
|
35
|
+
row_id = line[:colon_idx].strip()
|
|
36
|
+
remainder = line[colon_idx + 1 :].strip()
|
|
37
|
+
|
|
38
|
+
if not remainder:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
# Проверяем, есть ли префикс тега типа I (Import), M (Module), H (Hint)
|
|
42
|
+
tag = "J" # default JSON payload
|
|
43
|
+
first_char = remainder[0]
|
|
44
|
+
|
|
45
|
+
if first_char.isalpha() and len(remainder) > 1 and remainder[1] in ':[{"':
|
|
46
|
+
tag = first_char
|
|
47
|
+
payload_str = remainder[1:]
|
|
48
|
+
else:
|
|
49
|
+
payload_str = remainder
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
payload = orjson.loads(payload_str)
|
|
53
|
+
return row_id, tag, payload
|
|
54
|
+
except Exception:
|
|
55
|
+
# Если это чистая строка или сложный сегмент
|
|
56
|
+
return row_id, tag, payload_str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def extract_rsc_flight(html: str) -> dict[str, Any]:
|
|
60
|
+
"""Извлекает и декодирует потоковые чанки React Server Components (RSC Flight)
|
|
61
|
+
из тегов `self.__next_f.push` в Next.js App Router (React 19).
|
|
62
|
+
|
|
63
|
+
Возвращает словарь вида `{'rowId_tag': payload}`.
|
|
64
|
+
"""
|
|
65
|
+
# Ищем все вызовы self.__next_f.push([..., "..."])
|
|
66
|
+
matches = re.findall(
|
|
67
|
+
r'self\.__next_f\.push\(\[\s*\d+\s*,\s*(".*?(?<!\\)")\s*\]\)',
|
|
68
|
+
html,
|
|
69
|
+
re.DOTALL,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if not matches:
|
|
73
|
+
# Альтернативный паттерн без обрамляющих кавычек внутри
|
|
74
|
+
matches = re.findall(
|
|
75
|
+
r'self\.__next_f\.push\(\[.*?,\s*"(.*?)"\]\)',
|
|
76
|
+
html,
|
|
77
|
+
re.DOTALL,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
full_flight_text = ""
|
|
81
|
+
for match in matches:
|
|
82
|
+
try:
|
|
83
|
+
# Декодируем экранированную JS-строку
|
|
84
|
+
decoded = orjson.loads(match if match.startswith('"') else f'"{match}"')
|
|
85
|
+
full_flight_text += decoded
|
|
86
|
+
except Exception:
|
|
87
|
+
try:
|
|
88
|
+
decoded = match.encode("utf-8").decode("unicode_escape")
|
|
89
|
+
full_flight_text += decoded
|
|
90
|
+
except Exception:
|
|
91
|
+
full_flight_text += match
|
|
92
|
+
|
|
93
|
+
state_tree: dict[str, Any] = {}
|
|
94
|
+
for line in full_flight_text.splitlines():
|
|
95
|
+
parsed = parse_flight_line(line)
|
|
96
|
+
if parsed:
|
|
97
|
+
row_id, tag, payload = parsed
|
|
98
|
+
state_tree[f"{row_id}_{tag}"] = payload
|
|
99
|
+
|
|
100
|
+
return state_tree
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import orjson
|
|
5
|
+
from selectolax.lexbor import LexborHTMLParser
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_nuxt_data(html: str) -> dict[str, Any] | list[Any]:
|
|
9
|
+
"""Извлекает состояние Nuxt 3 (__NUXT_DATA__) или Nuxt 2 (window.__NUXT__).
|
|
10
|
+
|
|
11
|
+
Raises:
|
|
12
|
+
ValueError: Если данные Nuxt не найдены.
|
|
13
|
+
"""
|
|
14
|
+
parser = LexborHTMLParser(html)
|
|
15
|
+
|
|
16
|
+
# 1. Nuxt 3 JSON-массив данных
|
|
17
|
+
script_node = parser.css_first("script#__NUXT_DATA__")
|
|
18
|
+
if script_node and script_node.text():
|
|
19
|
+
return orjson.loads(script_node.text().strip())
|
|
20
|
+
|
|
21
|
+
# 2. Nuxt 2 window.__NUXT__ JS-объект
|
|
22
|
+
match = re.search(r"window\.__NUXT__\s*=\s*(\{.*?\});", html, re.DOTALL)
|
|
23
|
+
if match:
|
|
24
|
+
try:
|
|
25
|
+
return orjson.loads(match.group(1))
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
raise ValueError("Nuxt state (__NUXT_DATA__ / window.__NUXT__) not found in HTML")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Any, TypeVar
|
|
2
|
+
|
|
3
|
+
import jmespath
|
|
4
|
+
from pydantic import BaseModel, TypeAdapter
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T", bound=BaseModel)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def map_state_to_model(
|
|
10
|
+
state: dict[str, Any] | list[Any],
|
|
11
|
+
query: str,
|
|
12
|
+
model_cls: type[T],
|
|
13
|
+
strict: bool = False,
|
|
14
|
+
default: Any = None,
|
|
15
|
+
) -> list[T] | T | None:
|
|
16
|
+
"""Выполняет поиск по JMESPath-запросу в структуре состояния и валидирует результат в Pydantic v2 модель(и).
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
state: Исходный JSON-словарь или массив данных состояния.
|
|
20
|
+
query: JMESPath-выражение (например, "props.pageProps.items[*].{id: id, name: title}").
|
|
21
|
+
model_cls: Pydantic v2 класс схемы данных.
|
|
22
|
+
strict: Если True, выбрасывает ValueError при пустом результате поиска.
|
|
23
|
+
default: Значение по умолчанию, если ничего не найдено в non-strict режиме.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Экземпляр модели T, список list[T], или default.
|
|
27
|
+
"""
|
|
28
|
+
extracted = jmespath.search(query, state)
|
|
29
|
+
|
|
30
|
+
# Проверка на пустой результат
|
|
31
|
+
if extracted is None or (isinstance(extracted, list) and len(extracted) == 0):
|
|
32
|
+
if strict:
|
|
33
|
+
raise ValueError(f"JMESPath query '{query}' returned empty result in strict mode")
|
|
34
|
+
|
|
35
|
+
# Если в запросе была проекция списка или default указан как список
|
|
36
|
+
is_list_query = "[*" in query or query.endswith("]") or query.endswith("}")
|
|
37
|
+
if default is None and is_list_query:
|
|
38
|
+
return []
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
# Если результат - список словарей
|
|
42
|
+
if isinstance(extracted, list):
|
|
43
|
+
adapter = TypeAdapter(list[model_cls])
|
|
44
|
+
return adapter.validate_python(extracted)
|
|
45
|
+
|
|
46
|
+
# Если результат - одиночный объект
|
|
47
|
+
if isinstance(extracted, dict):
|
|
48
|
+
return model_cls.model_validate(extracted)
|
|
49
|
+
|
|
50
|
+
# Примитивное значение не мапится напрямую в модель
|
|
51
|
+
if strict:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Expected dict or list from query '{query}', got {type(extracted).__name__}"
|
|
54
|
+
)
|
|
55
|
+
return default
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ProxyState:
|
|
7
|
+
url: str
|
|
8
|
+
fail_count: int = 0
|
|
9
|
+
success_count: int = 0
|
|
10
|
+
last_fail_time: float = 0.0
|
|
11
|
+
last_success_time: float = 0.0
|
|
12
|
+
is_in_cooldown: bool = False
|
|
13
|
+
cooldown_until: float = 0.0
|
|
14
|
+
|
|
15
|
+
def mark_success(self) -> None:
|
|
16
|
+
self.success_count += 1
|
|
17
|
+
self.fail_count = 0
|
|
18
|
+
self.last_success_time = time.monotonic()
|
|
19
|
+
self.is_in_cooldown = False
|
|
20
|
+
self.cooldown_until = 0.0
|
|
21
|
+
|
|
22
|
+
def mark_fail(self, max_fails: int, cooldown_seconds: float) -> None:
|
|
23
|
+
self.fail_count += 1
|
|
24
|
+
self.last_fail_time = time.monotonic()
|
|
25
|
+
if self.fail_count >= max_fails:
|
|
26
|
+
self.is_in_cooldown = True
|
|
27
|
+
self.cooldown_until = time.monotonic() + cooldown_seconds
|
|
28
|
+
|
|
29
|
+
def is_available(self) -> bool:
|
|
30
|
+
if not self.is_in_cooldown:
|
|
31
|
+
return True
|
|
32
|
+
if time.monotonic() >= self.cooldown_until:
|
|
33
|
+
# Кулдаун истек - восстанавливаем прокси
|
|
34
|
+
self.is_in_cooldown = False
|
|
35
|
+
self.fail_count = 0
|
|
36
|
+
self.cooldown_until = 0.0
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from ..config import ProxyConfig
|
|
6
|
+
from .health import ProxyState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProxyManager:
|
|
10
|
+
"""Потокобезопасный менеджер пула прокси с поддержкой стратегий и кулдауна."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: ProxyConfig):
|
|
13
|
+
self.config = config
|
|
14
|
+
self._lock = threading.Lock()
|
|
15
|
+
self._states: dict[str, ProxyState] = {url: ProxyState(url=url) for url in config.urls}
|
|
16
|
+
self._index = 0
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def total_count(self) -> int:
|
|
20
|
+
return len(self._states)
|
|
21
|
+
|
|
22
|
+
def add_proxy(self, url: str) -> None:
|
|
23
|
+
with self._lock:
|
|
24
|
+
if url not in self._states:
|
|
25
|
+
self._states[url] = ProxyState(url=url)
|
|
26
|
+
|
|
27
|
+
def remove_proxy(self, url: str) -> None:
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._states.pop(url, None)
|
|
30
|
+
|
|
31
|
+
def get_proxy(
|
|
32
|
+
self, strategy: Literal["round_robin", "random", "least_failed"] | None = None
|
|
33
|
+
) -> str | None:
|
|
34
|
+
"""Возвращает живой прокси по заданной стратегии или None, если пул пуст."""
|
|
35
|
+
with self._lock:
|
|
36
|
+
if not self._states:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
available = [state for state in self._states.values() if state.is_available()]
|
|
40
|
+
if not available:
|
|
41
|
+
# Если все в кулдауне, берем тот, у которого быстрее всего закончится кулдаун
|
|
42
|
+
available = sorted(self._states.values(), key=lambda s: s.cooldown_until)
|
|
43
|
+
|
|
44
|
+
strat = strategy or self.config.strategy
|
|
45
|
+
|
|
46
|
+
if strat == "random":
|
|
47
|
+
return random.choice(available).url
|
|
48
|
+
|
|
49
|
+
if strat == "least_failed":
|
|
50
|
+
# Сортируем по числу ошибок
|
|
51
|
+
sorted_by_fails = sorted(available, key=lambda s: (s.fail_count, -s.success_count))
|
|
52
|
+
return sorted_by_fails[0].url
|
|
53
|
+
|
|
54
|
+
# Round-Robin по умолчанию
|
|
55
|
+
self._index = (self._index + 1) % len(available)
|
|
56
|
+
return available[self._index].url
|
|
57
|
+
|
|
58
|
+
def report_success(self, url: str) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
if url in self._states:
|
|
61
|
+
self._states[url].mark_success()
|
|
62
|
+
|
|
63
|
+
def report_fail(self, url: str) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
if url in self._states:
|
|
66
|
+
self._states[url].mark_fail(
|
|
67
|
+
max_fails=self.config.max_fails_before_cooldown,
|
|
68
|
+
cooldown_seconds=self.config.cooldown_seconds,
|
|
69
|
+
)
|
fast_scraper/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561
|
oniscrape/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from fast_scraper import (
|
|
2
|
+
BaseCookieStorage,
|
|
3
|
+
InMemoryCookieStorage,
|
|
4
|
+
ProxyConfig,
|
|
5
|
+
ProxyManager,
|
|
6
|
+
RedisCookieStorage,
|
|
7
|
+
RetryConfig,
|
|
8
|
+
ScraperClient,
|
|
9
|
+
ScraperConfig,
|
|
10
|
+
ScraperRequestError,
|
|
11
|
+
css_all_attr,
|
|
12
|
+
css_all_text,
|
|
13
|
+
css_attr,
|
|
14
|
+
css_text,
|
|
15
|
+
extract_json_ld,
|
|
16
|
+
extract_json_scripts,
|
|
17
|
+
extract_meta_relay,
|
|
18
|
+
extract_meta_tokens,
|
|
19
|
+
extract_next_data,
|
|
20
|
+
extract_rsc_flight,
|
|
21
|
+
extract_nuxt_data,
|
|
22
|
+
map_state_to_model,
|
|
23
|
+
parse_html,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ScraperClient",
|
|
28
|
+
"ScraperRequestError",
|
|
29
|
+
"ScraperConfig",
|
|
30
|
+
"ProxyConfig",
|
|
31
|
+
"RetryConfig",
|
|
32
|
+
"ProxyManager",
|
|
33
|
+
"BaseCookieStorage",
|
|
34
|
+
"InMemoryCookieStorage",
|
|
35
|
+
"RedisCookieStorage",
|
|
36
|
+
"parse_html",
|
|
37
|
+
"css_text",
|
|
38
|
+
"css_all_text",
|
|
39
|
+
"css_attr",
|
|
40
|
+
"css_all_attr",
|
|
41
|
+
"extract_next_data",
|
|
42
|
+
"extract_rsc_flight",
|
|
43
|
+
"extract_meta_relay",
|
|
44
|
+
"extract_meta_tokens",
|
|
45
|
+
"extract_nuxt_data",
|
|
46
|
+
"extract_json_ld",
|
|
47
|
+
"extract_json_scripts",
|
|
48
|
+
"map_state_to_model",
|
|
49
|
+
]
|
oniscrape/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oniscrape
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-performance async scraping engine powered by curl_cffi, selectolax, orjson, and Pydantic v2. Extracts Next.js SSR, RSC Flight streams, and Meta Relay states without headless browsers.
|
|
5
|
+
Author: FastScraper Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: anti-bot,crawler,curl-cffi,ja3,nextjs,pydantic,react-server-components,rsc,scraper,selectolax,tls-fingerprint
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: curl-cffi>=0.7.4
|
|
22
|
+
Requires-Dist: jmespath>=1.0.1
|
|
23
|
+
Requires-Dist: orjson>=3.10.0
|
|
24
|
+
Requires-Dist: pydantic>=2.7.0
|
|
25
|
+
Requires-Dist: selectolax>=0.3.21
|
|
26
|
+
Requires-Dist: tenacity>=8.3.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pyright>=1.1.350; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
32
|
+
Provides-Extra: redis
|
|
33
|
+
Requires-Dist: redis>=5.0.0; extra == 'redis'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# fast-scraper 🚀
|
|
37
|
+
|
|
38
|
+
> **Высокопроизводительный асинхронный скрапинг-движок для Python на базе `curl_cffi`, `selectolax` (Lexbor), `orjson` и `Pydantic v2`.**
|
|
39
|
+
|
|
40
|
+
Специализированный инструмент для сверхбыстрого парсинга современных веб-приложений (Next.js, React Server Components, Meta Threads/Instagram, Nuxt) и статических сайтов **без запуска тяжелых headless-браузеров**.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## ⚡ Почему не Playwright / Selenium?
|
|
45
|
+
|
|
46
|
+
| Параметр | Headless Браузер (Playwright / Puppeteer) | `fast-scraper` (No-JS / State Engine) |
|
|
47
|
+
| :--- | :--- | :--- |
|
|
48
|
+
| **Потребление RAM** | ~300 - 600 МБ на поток/вкладку | **~15 - 30 МБ на процесс** |
|
|
49
|
+
| **Время ответа** | 3.0 - 8.0 секунд (рендеринг DOM + JS) | **0.1 - 0.4 секунды** (чистый сетевой I/O) |
|
|
50
|
+
| **Устойчивость к редизайнам** | Низкая (CSS-классы React меняются при каждом билде) | **Высокая** (структура данных в State/API стабильна годами) |
|
|
51
|
+
| **Обход TLS-проверок** | Зависит от stealth-патчей Chromium | **Нативный JA3/JA4 / HTTP2 спуфинг через `curl_cffi`** |
|
|
52
|
+
| **Утечки IP при смене прокси** | Частая проблема из-за общего пула сокетов | **Гарантированная изоляция сокетов на уровне сессий** |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 📦 Установка
|
|
57
|
+
|
|
58
|
+
### Базовая установка:
|
|
59
|
+
```bash
|
|
60
|
+
pip install fast-scraper
|
|
61
|
+
```
|
|
62
|
+
или через `uv`:
|
|
63
|
+
```bash
|
|
64
|
+
uv add fast-scraper
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### С поддержкой Redis (для распределенного хранения куки):
|
|
68
|
+
```bash
|
|
69
|
+
pip install fast-scraper[redis]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Локальная разработка:
|
|
73
|
+
```bash
|
|
74
|
+
git clone https://github.com/your-org/fast-scraper.git
|
|
75
|
+
cd fast-scraper
|
|
76
|
+
uv pip install -e .[dev]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🛠️ Быстрый старт и примеры
|
|
82
|
+
|
|
83
|
+
### 1. Парсинг Next.js SSR (__NEXT_DATA__) напрямую в Pydantic v2
|
|
84
|
+
|
|
85
|
+
Вместо того чтобы искать элементы на странице, `fast-scraper` мгновенно забирает сырое JSON-состояние гидратации, вшитое сервером в страницу:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import asyncio
|
|
89
|
+
from pydantic import BaseModel, Field
|
|
90
|
+
from fast_scraper import ScraperClient, extract_next_data, map_state_to_model
|
|
91
|
+
|
|
92
|
+
class CryptoMetric(BaseModel):
|
|
93
|
+
num_cryptos: int = Field(alias="numCryptocurrencies")
|
|
94
|
+
num_markets: int = Field(alias="numMarkets")
|
|
95
|
+
active_exchanges: int = Field(alias="activeExchanges")
|
|
96
|
+
|
|
97
|
+
async def main():
|
|
98
|
+
async with ScraperClient() as client:
|
|
99
|
+
response = await client.get("https://coinmarketcap.com/")
|
|
100
|
+
|
|
101
|
+
# 1. Мгновенно достаем JSON-дерево гидратации (без выполнения JS)
|
|
102
|
+
state = extract_next_data(response.text)
|
|
103
|
+
|
|
104
|
+
# 2. Извлекаем нужные ветки по JMESPath и валидируем через Pydantic v2
|
|
105
|
+
metrics = map_state_to_model(
|
|
106
|
+
state=state,
|
|
107
|
+
query="props.dehydratedState.queries[?queryKey[0]=='global-metric'].state.data | [0]",
|
|
108
|
+
model_cls=CryptoMetric,
|
|
109
|
+
strict=False,
|
|
110
|
+
)
|
|
111
|
+
print(metrics)
|
|
112
|
+
|
|
113
|
+
asyncio.run(main())
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### 2. Парсинг React Server Components (RSC Flight stream) в Next.js 14 / 15 / 19
|
|
119
|
+
|
|
120
|
+
Next.js App Router не использует `__NEXT_DATA__`, а отдает потоковые чанки `self.__next_f.push` по протоколу React Flight (`id:tag:payload`). `fast-scraper` декодирует этот протокол "из коробки":
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import asyncio
|
|
124
|
+
from fast_scraper import ScraperClient, extract_rsc_flight
|
|
125
|
+
|
|
126
|
+
async def main():
|
|
127
|
+
async with ScraperClient() as client:
|
|
128
|
+
response = await client.get("https://nextjs.org/")
|
|
129
|
+
|
|
130
|
+
# Декодирует все потоковые слоты wire-протокола
|
|
131
|
+
flight_tree = extract_rsc_flight(response.text)
|
|
132
|
+
|
|
133
|
+
# flight_tree содержит разобранные слоты вида {'0_J': [...], '1_I': [...]}
|
|
134
|
+
print(f"Декодировано слотов RSC: {len(flight_tree)}")
|
|
135
|
+
|
|
136
|
+
asyncio.run(main())
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
### 3. Парсинг закрытых данных Threads / Instagram (Meta Relay Cache)
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import asyncio
|
|
145
|
+
from fast_scraper import ScraperClient, extract_meta_relay, extract_meta_tokens
|
|
146
|
+
|
|
147
|
+
async def main():
|
|
148
|
+
async with ScraperClient() as client:
|
|
149
|
+
response = await client.get("https://www.threads.net/@zuck")
|
|
150
|
+
|
|
151
|
+
# 1. Извлечение токенов для последующих прямых POST-запросов к /api/graphql
|
|
152
|
+
tokens = extract_meta_tokens(response.text)
|
|
153
|
+
print("LSD Token:", tokens["lsd"])
|
|
154
|
+
print("App ID:", tokens["app_id"])
|
|
155
|
+
|
|
156
|
+
# 2. Извлечение префетч-кэша GraphQL (RelayPrefetchedStreamCache)
|
|
157
|
+
relay_data = extract_meta_relay(response.text)
|
|
158
|
+
print(f"Найдено блоков Relay Cache: {len(relay_data)}")
|
|
159
|
+
|
|
160
|
+
asyncio.run(main())
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
### 4. C-уровень DOM-парсинга через Selectolax Lexbor
|
|
166
|
+
|
|
167
|
+
Для сайтов с чистым HTML используется движок Lexbor на чистом Си (в 20 раз быстрее BeautifulSoup и потребляет минимум RAM):
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from fast_scraper import parse_html, css_text, css_all_text, css_all_attr
|
|
171
|
+
|
|
172
|
+
tree = parse_html(html_content)
|
|
173
|
+
|
|
174
|
+
# Быстрое извлечение первого найденного текста с авто-strip
|
|
175
|
+
title = css_text(tree, "h1.product-title", default="Без названия")
|
|
176
|
+
|
|
177
|
+
# Извлечение всех элементов
|
|
178
|
+
items = css_all_text(tree, "ul.features li")
|
|
179
|
+
|
|
180
|
+
# Извлечение атрибутов
|
|
181
|
+
image_urls = css_all_attr(tree, "div.gallery img", "src")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
### 5. Пул прокси с авто-кулдауном и предотвращением утечек сокетов
|
|
187
|
+
|
|
188
|
+
`fast-scraper` изолирует `AsyncSession` под каждый конкретный запрос/прокси, что гарантирует закрытие Keep-Alive сокетов `libcurl` и предотвращает утечки реального IP при ротации:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from fast_scraper import ScraperClient, ScraperConfig, ProxyConfig
|
|
192
|
+
|
|
193
|
+
config = ScraperConfig(
|
|
194
|
+
default_impersonate="chrome124",
|
|
195
|
+
timeout_seconds=15.0,
|
|
196
|
+
proxy=ProxyConfig(
|
|
197
|
+
urls=[
|
|
198
|
+
"socks5://127.0.0.1:40000", # Например, локальный Cloudflare WARP
|
|
199
|
+
"socks5://127.0.0.1:40001",
|
|
200
|
+
"http://user:pass@proxy3.net:8080",
|
|
201
|
+
],
|
|
202
|
+
strategy="least_failed", # "round_robin" | "random" | "least_failed"
|
|
203
|
+
cooldown_seconds=120.0, # Время отстоя прокси при получении 403/429
|
|
204
|
+
max_fails_before_cooldown=2,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async with ScraperClient(config=config) as client:
|
|
209
|
+
response = await client.get("https://httpbin.org/ip")
|
|
210
|
+
print(response.json())
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 🏛️ Архитектура пакета
|
|
216
|
+
|
|
217
|
+
```text
|
|
218
|
+
src/fast_scraper/
|
|
219
|
+
├── __init__.py # Публичный API
|
|
220
|
+
├── client.py # ScraperClient с изоляцией сессий и ретраями (tenacity)
|
|
221
|
+
├── config.py # Pydantic v2 конфигурация (ScraperConfig, ProxyConfig, RetryConfig)
|
|
222
|
+
├── py.typed # Маркер типизации PEP 561
|
|
223
|
+
├── extractors/ # Движки извлечения состояний гидратации
|
|
224
|
+
│ ├── nextjs.py # __NEXT_DATA__ и React Server Components (RSC Flight)
|
|
225
|
+
│ ├── meta.py # Threads / Instagram Relay Cache и токены (lsd, app_id)
|
|
226
|
+
│ ├── nuxt.py # Nuxt 2 / 3 (__NUXT_DATA__)
|
|
227
|
+
│ └── generic.py # Schema.org JSON-LD и <script type="application/json">
|
|
228
|
+
├── dom/ # C-уровень DOM-парсинга
|
|
229
|
+
│ └── parser.py # Обертки над Selectolax Lexbor
|
|
230
|
+
├── proxy/ # Ротация и мониторинг прокси
|
|
231
|
+
│ ├── manager.py # ProxyManager (Round-Robin, Random, Least-Failed)
|
|
232
|
+
│ └── health.py # Отслеживание сбоев и таймеры кулдауна
|
|
233
|
+
├── cookies/ # Управление сессиями и куки
|
|
234
|
+
│ ├── memory.py # InMemoryCookieStorage
|
|
235
|
+
│ └── redis_storage.py # RedisCookieStorage
|
|
236
|
+
└── pipeline/ # Трансформация данных
|
|
237
|
+
└── schema_mapper.py # JMESPath -> Pydantic v2 TypeAdapter
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 🧪 Тестирование и верификация
|
|
243
|
+
|
|
244
|
+
Все тесты и проверки запускаются одной командой:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
# Запуск тестов
|
|
248
|
+
uv run --with pytest --with pytest-asyncio pytest -v
|
|
249
|
+
|
|
250
|
+
# Проверка типов
|
|
251
|
+
uv run --with pyright --with pytest pyright src tests
|
|
252
|
+
|
|
253
|
+
# Проверка линтером
|
|
254
|
+
uv run --with ruff ruff check src tests
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## 📄 Лицензия
|
|
260
|
+
|
|
261
|
+
Проект распространяется под свободной лицензией **MIT**. Подробнее см. в файле [LICENSE](LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
fast_scraper/__init__.py,sha256=pawATDdVYl1OnCSXEd3rkR5UWibNT4lCTPmIUvejl3E,1214
|
|
2
|
+
fast_scraper/client.py,sha256=txo3A8Hp_gWJ-0CgJ3HrxXVHzWl01KG_-Ume8sgUlqY,6920
|
|
3
|
+
fast_scraper/config.py,sha256=GC_30wIkyOL1QaQNd9ebPqzdauuUQNwyxrv88-jd9NY,2288
|
|
4
|
+
fast_scraper/py.typed,sha256=8PjyZ1aVoQpRVvt71muvuq5qE-jTFZkK-GLHkhdebmc,26
|
|
5
|
+
fast_scraper/cookies/__init__.py,sha256=V6YkMZ7cn8o4v82BzUeGsWKmn4kFmG-DSH6tFAFmqvg,204
|
|
6
|
+
fast_scraper/cookies/base.py,sha256=X7VWiw-kxpnCe7yr8hCoSzJOd-vNuEnTG5EcFO_AxIU,730
|
|
7
|
+
fast_scraper/cookies/memory.py,sha256=S7QvnPw1mZ4TnQXU-mrhMwbFiRNeQo8UTDbJEPIlMrE,949
|
|
8
|
+
fast_scraper/cookies/redis_storage.py,sha256=eNrQUt8mnhGH9RaX8yMqNkpX-wYENAc7y84BThALg-Y,1297
|
|
9
|
+
fast_scraper/dom/__init__.py,sha256=cwglj7RerHDZLMZ44FvoyOizHWQ-51Z9rY0SFJBcur4,209
|
|
10
|
+
fast_scraper/dom/parser.py,sha256=Y3hMM2Cfk18qaiko90BJhHB_6VvfGhRgF2WJRT8tabo,1976
|
|
11
|
+
fast_scraper/extractors/__init__.py,sha256=QfOTRsrzcHjD8KCXF5VP4nneeQIU3M6VRV1GLdS7Y9E,406
|
|
12
|
+
fast_scraper/extractors/base.py,sha256=g5vr5cT52YkDVAxNRwvv7uFcxDO042aj2p3Ta8F9kZc,265
|
|
13
|
+
fast_scraper/extractors/generic.py,sha256=kS9jHrwhtHDkEmRMc4YHXRFDfyRHgI7qxwY_s_j-B4Y,1603
|
|
14
|
+
fast_scraper/extractors/meta.py,sha256=DgZgQi9Dq5vBcjf-eW9IHKsz1o49MrjN1e84DvNCYEs,1913
|
|
15
|
+
fast_scraper/extractors/nextjs.py,sha256=OvE3i0_unEhtSyyxWujtn9kMBsR5czI4UsH178Zz2Xg,3386
|
|
16
|
+
fast_scraper/extractors/nuxt.py,sha256=3NXUBfnAYcngJ39agzp34U-I6donqKCqaSuEPGb8vWc,925
|
|
17
|
+
fast_scraper/pipeline/__init__.py,sha256=hATJPesLcIVZnwEaxEZog_4VQUuD8_TXRPYICloTjGg,80
|
|
18
|
+
fast_scraper/pipeline/schema_mapper.py,sha256=NKlISbgQDB9QSUxr9_K3ubxeQginGCq01IiyBiuD0LQ,2360
|
|
19
|
+
fast_scraper/proxy/__init__.py,sha256=vOspUXm6h6p5xPgF7saupWv291Cs-d8jTsq4QKlRgEQ,107
|
|
20
|
+
fast_scraper/proxy/health.py,sha256=Rek0wJFcNkoQwSiNzN8D2ZAd3nho0-qr9y9fhKk1rDI,1181
|
|
21
|
+
fast_scraper/proxy/manager.py,sha256=YWphLyojAcBWHitHiiADb_QyOTHWNJOXR0ZrWPlTLyk,2621
|
|
22
|
+
oniscrape/__init__.py,sha256=kaaSs-jDcwkcHnPEsv46evIYmMshvT1w4AX5bgJ7gQw,1006
|
|
23
|
+
oniscrape/py.typed,sha256=8PjyZ1aVoQpRVvt71muvuq5qE-jTFZkK-GLHkhdebmc,26
|
|
24
|
+
oniscrape-0.1.0.dist-info/METADATA,sha256=eAeRNvvXX88gniZZ_Q2dGxmg6ZmN0ehqnkEe5NiH7b4,11132
|
|
25
|
+
oniscrape-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
26
|
+
oniscrape-0.1.0.dist-info/licenses/LICENSE,sha256=3OYHIaaKX4UZJLdLHtwjLjDPFz0KH-kmRtEVi_AITWs,1076
|
|
27
|
+
oniscrape-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FastScraper Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|