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.
@@ -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,5 @@
1
+ from .base import BaseCookieStorage
2
+ from .memory import InMemoryCookieStorage
3
+ from .redis_storage import RedisCookieStorage
4
+
5
+ __all__ = ["BaseCookieStorage", "InMemoryCookieStorage", "RedisCookieStorage"]
@@ -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,15 @@
1
+ from .parser import (
2
+ css_all_attr,
3
+ css_all_text,
4
+ css_attr,
5
+ css_text,
6
+ parse_html,
7
+ )
8
+
9
+ __all__ = [
10
+ "parse_html",
11
+ "css_text",
12
+ "css_all_text",
13
+ "css_attr",
14
+ "css_all_attr",
15
+ ]
@@ -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,6 @@
1
+ from selectolax.lexbor import LexborHTMLParser
2
+
3
+
4
+ def get_html_parser(html: str) -> LexborHTMLParser:
5
+ """Создает парсер LexborHTMLParser для быстрого поиска по дереву элементов."""
6
+ return LexborHTMLParser(html)
@@ -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,3 @@
1
+ from .schema_mapper import map_state_to_model
2
+
3
+ __all__ = ["map_state_to_model"]
@@ -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,4 @@
1
+ from .health import ProxyState
2
+ from .manager import ProxyManager
3
+
4
+ __all__ = ["ProxyManager", "ProxyState"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.