Repository navigation
Expand file tree
/
Copy pathsource_common.py
More file actions
256 lines (223 loc) · 10.1 KB
/
Copy pathsource_common.py
File metadata and controls
256 lines (223 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
"""Общие помощники новых источников сканирования.
Единая точка извлечения ключей (раньше каждый source_*.py дублировал
40 строк), общая aiohttp-сессия с прокси и базовый цикл источника:
цикл -> извлечение -> очередь -> телеметрия.
"""
import asyncio
import queue
import re
import ssl
import threading
from typing import List, Optional
import aiohttp
from aiohttp import ClientTimeout, TCPConnector
from config import COMPILED_BASE_URL_PATTERNS, COMPILED_REGEX_PATTERNS, config
from database import Database
from scanner import (
ENTROPY_THRESHOLD, ScanResult, calculate_entropy, is_test_key,
key_needs_context_gate,
)
HTTP_TIMEOUT = ClientTimeout(total=25, connect=10)
# Файлы больше этого не качаем: ключи живут в тексте, а не в бинарях,
# и защищаемся от случайного гигабайтного дампа.
MAX_TEXT_BYTES = 512 * 1024
# Codeberg отдаёт 403 «old Chrome version» на UA ниже 130-й версии,
# поэтому версия должна быть свежей (проверено 2026-10-30).
BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
)
TEXT_SUFFIXES = (
".py", ".js", ".ts", ".env", ".json", ".yaml", ".yml", ".toml",
".ini", ".cfg", ".conf", ".txt", ".md", ".jsonl", ".csv", ".sh",
".java", ".go", ".rb", ".php", ".ipynb", ".log",
)
_PREFERRED_BASE_URL = re.compile(
r"(?:baseURL|base_url|endpoint|api_base|apiBase)\s*[\"']?\s*[:=]\s*[\"']"
r"(https?://[^\"'\s,}]+)",
re.IGNORECASE,
)
def make_connector(limit: int = 20) -> TCPConnector:
"""Коннектор с тем же набором настроек, что у остальных источников."""
return TCPConnector(
limit=limit,
ttl_dns_cache=300,
ssl=ssl.create_default_context(),
enable_cleanup_closed=False,
resolver=aiohttp.resolver.ThreadedResolver(),
)
def extract_base_url(context: str, platform: str) -> tuple:
"""Base URL из контекста; (url, is_relay)."""
candidates = []
for match in _PREFERRED_BASE_URL.finditer(context):
candidates.append((0, match.start(), match.group(1)))
for pattern in COMPILED_BASE_URL_PATTERNS:
for match in pattern.finditer(context):
url = (match.group(1) if match.lastindex else match.group(0)).strip()
url = url.rstrip('/"\'')
candidates.append((1, match.start(), url))
for _, _, url in sorted(candidates):
if not url.startswith(("http://", "https://")):
continue
is_relay = platform == "openai" and "api.openai.com" not in url.lower()
return url, is_relay
return config.default_base_urls.get(platform, ""), False
def extract_key_results(
content: str,
source_url: str,
db: Optional[Database] = None,
patterns: Optional[dict] = None,
) -> List[ScanResult]:
"""Найти ключи в произвольном тексте.
Повторяет конвейер source_paster: формат -> тестовая заглушка ->
дедуп по БД -> энтропия -> контекстный гейт (aws/azure) -> base_url.
"""
results: List[ScanResult] = []
for platform, pattern in (patterns or COMPILED_REGEX_PATTERNS).items():
if platform == "azure":
continue
for match in pattern.finditer(content):
api_key = match.group(0)
if is_test_key(api_key):
continue
if db is not None and db.key_exists(api_key):
continue
key_body = api_key.split("-", 2)[-1] if api_key.startswith("sk-") else api_key
if calculate_entropy(key_body) < ENTROPY_THRESHOLD:
continue
context = content[max(0, match.start() - 1000):min(len(content), match.end() + 1000)]
if key_needs_context_gate(platform, context):
continue
base_url, is_relay = extract_base_url(context, platform)
results.append(ScanResult(
platform="relay" if is_relay else platform,
api_key=api_key,
base_url=base_url,
source_url=source_url,
is_relay=is_relay,
context=context,
))
return results
class BaseSourceScanner:
"""Каркас источника: сессия, дедуп, эмиссия в очередь, телеметрия."""
name = "source"
poll_interval = 120.0
def __init__(self, result_queue: queue.Queue, db: Database,
stop_event: threading.Event, dashboard=None):
self.result_queue = result_queue
self.db = db
self.stop_event = stop_event
self.dashboard = dashboard
self._session: Optional[aiohttp.ClientSession] = None
self.stats = {"items": 0, "keys_found": 0, "errors": 0}
# --- логирование/телеметрия ---
def _log(self, message: str, level: str = "INFO") -> None:
if self.dashboard:
self.dashboard.add_log(f"[{self.name}] {message}", level)
def _telemetry(self, status: str, phase: str, message: str = "", **extra) -> None:
try:
self.db.safe_upsert_source_progress(
self.name, status=status, phase=phase, message=message, **extra)
except Exception:
pass
# --- сеть ---
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
connector=make_connector(), timeout=HTTP_TIMEOUT,
headers={"User-Agent": BROWSER_UA},
)
return self._session
async def _fetch_text(self, url: str, session=None, **kwargs) -> Optional[str]:
session = session or await self._get_session()
proxy = getattr(config, "proxy_url", None) or None
try:
async with session.get(url, proxy=proxy, **kwargs) as resp:
if resp.status != 200:
return None
raw = await resp.content.read(MAX_TEXT_BYTES)
return raw.decode("utf-8", errors="replace")
except (aiohttp.ClientError, asyncio.TimeoutError):
return None
async def _fetch_json(self, url: str, session=None, **kwargs):
session = session or await self._get_session()
proxy = getattr(config, "proxy_url", None) or None
try:
async with session.get(url, proxy=proxy, **kwargs) as resp:
if resp.status != 200:
return None
return await resp.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
return None
# --- дедуп/эмиссия ---
# None — элемент помечается навсегда (иммутабельные: paste, gist, id).
# Для источников с изменяемым содержимым (huggingface: Space/dataset
# дописывают файлы) окно задаёт, через сколько часов перепроверять.
rescan_hours = None
def _item_seen(self, item_id: str) -> bool:
try:
return self.db.is_source_item_scanned(
self.name, item_id, max_age_hours=self.rescan_hours)
except Exception:
return False
def _mark_item(self, item_id: str) -> None:
try:
self.db.mark_source_item_scanned(self.name, item_id)
except Exception:
pass
def _emit(self, results: List[ScanResult]) -> int:
sent = 0
for result in results:
try:
self.result_queue.put(result, timeout=5)
sent += 1
except queue.Full:
break
self.stats["keys_found"] += sent
return sent
# --- цикл ---
async def _cycle(self, session: aiohttp.ClientSession) -> None: # pragma: no cover
raise NotImplementedError
async def _main(self) -> None:
self._telemetry("running", "startup")
try:
session = await self._get_session()
while not self.stop_event.is_set():
try:
await self._cycle(session)
self._telemetry("running", "cycle", "")
except Exception as exc: # источник не должен ронять процесс
self.stats["errors"] += 1
self._log(f"цикл упал: {type(exc).__name__}", "ERROR")
self._telemetry("error", "cycle", type(exc).__name__,
errors_increment=1)
if self._sleep_with_heartbeat():
break
finally:
if self._session and not self._session.closed:
await self._session.close()
self._telemetry("stopped", "stopped")
def _sleep_with_heartbeat(self) -> bool:
"""Пауза между циклами с обновлением heartbeat.
Раньше поток молчал весь poll_interval (у SearchScanner — 30 минут),
и сторож считал источник зависшим. Возвращает True, если пора выходить.
"""
remaining = float(self.poll_interval)
while remaining > 0:
step = min(60.0, remaining)
if self.stop_event.wait(step):
return True
remaining -= step
try:
self._telemetry("running", "waiting")
except Exception:
pass
return False
def run(self) -> None:
asyncio.run(self._main())
def start_source_thread(scanner: BaseSourceScanner) -> threading.Thread:
thread = threading.Thread(
target=scanner.run, name=f"{scanner.name.capitalize()}Scanner",
daemon=True)
thread.start()
return thread