@staswhitemustache/ai-search-agent 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # runner_crawlee (docs moved)
2
+
3
+ Документация по раннерам лежит в `docs/`:
4
+ - `docs/INDEX.md`
5
+ - `docs/runners/CRAWLEE.md`
6
+ - `docs/runners/GOOGLE.md`
7
+ - `docs/runners/YANDEX_UNIFIED_SPEC.md`
8
+
9
+ Этот файл оставлен только как краткий указатель.
10
+
@@ -0,0 +1,26 @@
1
+ # AI Search Agent (npm)
2
+
3
+ Local operator agent for profile creation flow:
4
+
5
+ - polls backend control state;
6
+ - starts/stops `profile_worker`;
7
+ - opens headful browser on operator machine for manual login.
8
+
9
+ ## Run (after install)
10
+
11
+ ```bash
12
+ ai-search-agent --server https://parser.iseopro.ru --token <LOCAL_AGENT_TOKEN> --agent-id operator-pc-1 --install
13
+ ```
14
+
15
+ ## Environment alternatives
16
+
17
+ - `PROFILE_AGENT_SERVER`
18
+ - `PROFILE_AGENT_TOKEN`
19
+ - `PROFILE_WORKER_ID`
20
+ - `PROFILE_AGENT_POLL_MS`
21
+ - `PROFILE_AGENT_INSTALL=1`
22
+
23
+ ## Notes
24
+
25
+ - `--install` installs package dependencies if missing and always runs `playwright install chromium`.
26
+ - Agent token is generated by backend UI and can be revoked/rotated there.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@staswhitemustache/ai-search-agent",
3
+ "version": "1.0.0",
4
+ "description": "Crawlee+Playwright runner для MVP сравнения стеков",
5
+ "main": "runner.js",
6
+ "type": "module",
7
+ "files": [
8
+ "tools/profile_local_agent.js",
9
+ "tools/profile_worker.js",
10
+ "tools/yandex_profile_save.js",
11
+ "shared/account_utils.js",
12
+ "shared/profile_manager.js",
13
+ "shared/proxy_pool.js",
14
+ "shared/yandex_auth.js",
15
+ "shared/runner_common.js",
16
+ "shared/crawlee_storage.js",
17
+ "README_AGENT.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.0.0",
24
+ "npm": ">=10.0.0"
25
+ },
26
+ "bin": {
27
+ "ai-search-agent": "tools/profile_local_agent.js"
28
+ },
29
+ "scripts": {
30
+ "start": "node runner.js",
31
+ "google": "node runner_google.js",
32
+ "yandex": "node runner_yandex_profiles.js",
33
+ "profile-worker": "node tools/profile_worker.js",
34
+ "profile-worker-controller": "node tools/profile_worker_controller.js",
35
+ "profile-local-agent": "node tools/profile_local_agent.js",
36
+ "agent:pack-dry-run": "npm pack --dry-run",
37
+ "agent:publish-public": "npm publish --access public"
38
+ },
39
+ "dependencies": {
40
+ "@crawlee/browser-pool": "^3.15.3",
41
+ "crawlee": "^3.7.0",
42
+ "fingerprint-generator": "^2.1.80",
43
+ "fingerprint-injector": "^2.1.80",
44
+ "header-generator": "^2.1.80",
45
+ "ioredis": "^5.4.2",
46
+ "playwright": "^1.40.0"
47
+ }
48
+ }
package/runner.js ADDED
@@ -0,0 +1,2 @@
1
+ // Backward-compatible entrypoint (старое имя файла).
2
+ import './runner_google.js';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Утилиты для работы с аккаунтами Yandex и прокси.
3
+ * Централизованный модуль для избежания дублирования кода.
4
+ */
5
+ import { existsSync, readFileSync } from 'fs';
6
+
7
+ /**
8
+ * Маскирование логина для безопасного логирования.
9
+ * @param {string} login - Полный логин
10
+ * @returns {string|null} - Маскированный логин (первые 3 символа + ***)
11
+ */
12
+ export function maskLogin(login) {
13
+ const s = String(login || '');
14
+ if (!s) return null;
15
+ if (s.length <= 3) return `${s[0] || ''}***`;
16
+ return `${s.slice(0, 3)}***`;
17
+ }
18
+
19
+ /**
20
+ * Маскирование proxy URL для безопасного логирования.
21
+ * @param {string} proxyUrl - Полный proxy URL с креденшалами
22
+ * @returns {string} - Маскированный proxy (host:port)
23
+ */
24
+ export function maskProxy(proxyUrl) {
25
+ try {
26
+ const u = new URL(proxyUrl);
27
+ return `${u.hostname}:${u.port}`;
28
+ } catch {
29
+ return 'unknown';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Загрузка аккаунтов Yandex из файла.
35
+ * Формат: login:password:answer (одна строка = один аккаунт)
36
+ *
37
+ * @param {string} filePath - Путь к файлу с аккаунтами
38
+ * @returns {Array<{login: string, password: string, answer: string|null}>}
39
+ */
40
+ export function loadAccounts(filePath) {
41
+ const out = [];
42
+ if (!filePath || !existsSync(filePath)) return out;
43
+
44
+ const lines = readFileSync(filePath, 'utf-8')
45
+ .split('\n')
46
+ .map((l) => l.trim())
47
+ .filter((l) => l && !l.startsWith('#'));
48
+
49
+ for (const line of lines) {
50
+ const parts = line.split(':');
51
+ if (parts.length < 2) continue;
52
+ const login = (parts[0] || '').trim();
53
+ const password = (parts[1] || '').trim();
54
+ const answer = (parts[2] || '').trim();
55
+ if (!login || !password) continue;
56
+ out.push({ login, password, answer: answer || null });
57
+ }
58
+
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Загрузка прокси из файла.
64
+ * Формат: username:password@host:port (одна строка = один прокси)
65
+ *
66
+ * @param {string} filePath - Путь к файлу с прокси
67
+ * @returns {Array<string>} - Массив proxy URLs (http://user:pass@host:port)
68
+ */
69
+ /**
70
+ * Выбор User-Agent для десктопа (Windows/Chrome) на основе seed.
71
+ * Использует детерминированный хеш для стабильного выбора UA для каждого seed.
72
+ *
73
+ * @param {string} seed - Seed для выбора UA (например, login аккаунта)
74
+ * @returns {string} - User-Agent строка
75
+ */
76
+ export function pickDesktopUserAgent(seed) {
77
+ // Современные UA для Chrome 130-135 (конец 2025 - начало 2026)
78
+ const pool = [
79
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
80
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
81
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36',
82
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
83
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
84
+ ];
85
+
86
+ const str = String(seed || '');
87
+ let h = 0;
88
+ for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
89
+ return pool[h % pool.length];
90
+ }
91
+
92
+ /**
93
+ * Логика "прогрева" Yandex сессии — имитация естественного поведения пользователя.
94
+ *
95
+ * Важно: не используем прямой поиск в warmup, чтобы не триггерить антифрод.
96
+ * Поведение: перейти на пару сайтов → вернуться на главную Яндекса.
97
+ *
98
+ * @param {import('playwright').Page} page - Playwright page
99
+ * @param {boolean} verbose - Детальное логирование
100
+ */
101
+ export async function warmupYandexSession(page, verbose = false) {
102
+ const sites = ['https://vc.ru', 'https://habr.com'];
103
+
104
+ for (const url of sites) {
105
+ if (verbose) console.log(`[warmup] Переход на ${url}...`);
106
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
107
+ await page.waitForTimeout(600).catch(() => null);
108
+ try {
109
+ await page.evaluate(() => window.scrollTo(0, 200));
110
+ await page.waitForTimeout(250);
111
+ await page.evaluate(() => window.scrollTo(0, 0));
112
+ } catch {
113
+ // ignore
114
+ }
115
+ }
116
+
117
+ if (verbose) console.log('[warmup] Возврат на главную ya.ru...');
118
+ await page.goto('https://ya.ru/', { waitUntil: 'domcontentloaded', timeout: 30000 });
119
+ await page.waitForTimeout(800).catch(() => null);
120
+
121
+ const searchInput = await page.waitForSelector('input[name="text"], input#text', { timeout: 15000 }).catch(() => null);
122
+ if (!searchInput) {
123
+ if (verbose) console.log('[warmup] Поисковая строка не найдена, завершаем warmup');
124
+ return;
125
+ }
126
+
127
+ await searchInput.click().catch(() => null);
128
+ await page.waitForTimeout(200).catch(() => null);
129
+
130
+ if (verbose) console.log('[warmup] Прогрев завершён');
131
+ }
132
+
133
+ /**
134
+ * Универсальная функция для клика по элементу, если он видим.
135
+ *
136
+ * @param {import('playwright').Page} page - Playwright page
137
+ * @param {import('playwright').Locator} locator - Locator элемента
138
+ * @param {number} timeoutMs - Таймаут ожидания
139
+ * @returns {Promise<boolean>} - true если клик успешен, false если элемент не найден
140
+ */
141
+ export async function clickIfVisible(page, locator, timeoutMs = 1500) {
142
+ try {
143
+ await locator.first().waitFor({ state: 'visible', timeout: timeoutMs });
144
+ await locator.first().click({ timeout: timeoutMs });
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
@@ -0,0 +1,17 @@
1
+ import { mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { Configuration } from 'crawlee';
4
+
5
+ /**
6
+ * Делает уникальную директорию хранилища Crawlee для каждого run/engine.
7
+ * Это устраняет EBUSY/lock конфликты на Windows/OneDrive при параллельных запусках.
8
+ */
9
+ export function setupCrawleeStorage({ runDir, engine }) {
10
+ const storageDir = join(runDir, 'crawlee_storage', engine);
11
+ mkdirSync(storageDir, { recursive: true });
12
+
13
+ // Надёжнее, чем env: устанавливаем через Configuration.
14
+ Configuration.getGlobalConfig().set('storageDir', storageDir);
15
+ return storageDir;
16
+ }
17
+
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Profile Manager для Yandex аккаунтов.
3
+ *
4
+ * Управляет профилями (account + proxy + UA + storageState) для стабильного reuse сессий.
5
+ */
6
+ import { existsSync, readdirSync, readFileSync } from 'fs';
7
+ import { join } from 'path';
8
+
9
+ function hasStorageState(profile) {
10
+ if (!profile || typeof profile !== 'object') return false;
11
+ if (profile.storageState && typeof profile.storageState === 'object') return true;
12
+ return !!(profile.storageStatePath && existsSync(profile.storageStatePath));
13
+ }
14
+
15
+ /**
16
+ * Парсинг proxy URL в объект параметров для Playwright.
17
+ */
18
+ export function parseProxyUrl(proxyUrl) {
19
+ const u = new URL(proxyUrl);
20
+ return {
21
+ server: `${u.protocol}//${u.hostname}:${u.port}`,
22
+ username: decodeURIComponent(u.username || ''),
23
+ password: decodeURIComponent(u.password || ''),
24
+ host: u.hostname,
25
+ port: Number(u.port),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Загрузка профиля из файла.
31
+ */
32
+ export function loadProfile(profilePath) {
33
+ if (!existsSync(profilePath)) {
34
+ throw new Error(`profile_not_found: ${profilePath}`);
35
+ }
36
+
37
+ const profile = JSON.parse(readFileSync(profilePath, 'utf-8'));
38
+
39
+ if (!hasStorageState(profile)) {
40
+ throw new Error(`storage_state_not_found for profile: ${profilePath}`);
41
+ }
42
+
43
+ return profile;
44
+ }
45
+
46
+ /**
47
+ * Load profiles from base64-encoded JSON payload.
48
+ */
49
+ export function loadProfilesFromPayload(payloadB64) {
50
+ if (!payloadB64 || typeof payloadB64 !== 'string') return [];
51
+ let parsed;
52
+ try {
53
+ const raw = Buffer.from(payloadB64, 'base64').toString('utf-8');
54
+ parsed = JSON.parse(raw);
55
+ } catch (e) {
56
+ throw new Error(`invalid_profiles_payload: ${e?.message || e}`);
57
+ }
58
+ if (!Array.isArray(parsed)) {
59
+ throw new Error('invalid_profiles_payload: expected array');
60
+ }
61
+
62
+ const profiles = [];
63
+ const usedAccountIds = new Set();
64
+
65
+ for (const p of parsed) {
66
+ try {
67
+ if (!p || typeof p !== 'object') continue;
68
+ if (p.valid === false || p.isValid === false || p.disabled === true) continue;
69
+ if (p.status && p.status !== 'ok') continue;
70
+ if (!hasStorageState(p)) continue;
71
+ if (p.accountId && usedAccountIds.has(p.accountId)) continue;
72
+ if (p.accountId) usedAccountIds.add(p.accountId);
73
+ profiles.push(p);
74
+ } catch {
75
+ // skip bad rows
76
+ }
77
+ }
78
+
79
+ return profiles;
80
+ }
81
+
82
+ /**
83
+ * Загрузка всех профилей из директории.
84
+ */
85
+ export function loadAllProfiles(profileDir) {
86
+ if (!existsSync(profileDir)) {
87
+ return [];
88
+ }
89
+
90
+ const entries = readdirSync(profileDir, { withFileTypes: true });
91
+ const profiles = [];
92
+ const usedAccountIds = new Set();
93
+
94
+ function isValidProfileMeta(profile) {
95
+ if (!profile) return false;
96
+ if (profile.valid === false) return false;
97
+ if (profile.isValid === false) return false;
98
+ if (profile.disabled === true) return false;
99
+ if (profile.status && profile.status !== 'ok') return false;
100
+ return true;
101
+ }
102
+
103
+ function shouldSkipByResult(resultPath) {
104
+ if (!existsSync(resultPath)) return false;
105
+ try {
106
+ const result = JSON.parse(readFileSync(resultPath, 'utf-8'));
107
+ if (result?.ok === false) return true;
108
+ if (result?.status && !['ok', 'valid'].includes(result.status)) return true;
109
+ } catch {
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ function tryAddProfile(profilePath, logLabel, resultPath = null) {
116
+ try {
117
+ if (resultPath && shouldSkipByResult(resultPath)) {
118
+ console.warn(`[profile_manager] Skipped invalid profile: ${logLabel}`);
119
+ return;
120
+ }
121
+ const profile = loadProfile(profilePath);
122
+ if (!isValidProfileMeta(profile)) {
123
+ console.warn(`[profile_manager] Skipped invalid profile meta: ${logLabel}`);
124
+ return;
125
+ }
126
+ if (profile.accountId && usedAccountIds.has(profile.accountId)) {
127
+ console.warn(`[profile_manager] Skipped duplicate accountId: ${profile.accountId}`);
128
+ return;
129
+ }
130
+ if (profile.accountId) usedAccountIds.add(profile.accountId);
131
+ profiles.push(profile);
132
+ } catch (e) {
133
+ console.warn(`[profile_manager] Failed to load ${logLabel}: ${e?.message}`);
134
+ }
135
+ }
136
+
137
+ for (const entry of entries) {
138
+ if (entry.isFile() && entry.name.endsWith('_profile.json')) {
139
+ const profilePath = join(profileDir, entry.name);
140
+ tryAddProfile(profilePath, entry.name);
141
+ }
142
+ }
143
+
144
+ for (const entry of entries) {
145
+ if (!entry.isDirectory()) continue;
146
+ const profilePath = join(profileDir, entry.name, 'profile.json');
147
+ if (!existsSync(profilePath)) continue;
148
+ const resultPath = join(profileDir, entry.name, 'result.json');
149
+ tryAddProfile(profilePath, `${entry.name}/profile.json`, resultPath);
150
+ }
151
+
152
+ return profiles;
153
+ }
154
+
155
+ /**
156
+ * Создание Playwright браузера и контекста из профиля.
157
+ *
158
+ * КРИТИЧНО: все параметры должны совпадать с теми, что были при сохранении!
159
+ *
160
+ * NOTE: Proxy ДОЛЖЕН быть на уровне browser.launch(), а не context.newContext()!
161
+ */
162
+ export async function createBrowserAndContextFromProfile(
163
+ chromium,
164
+ profile,
165
+ headless = true,
166
+ proxyUrlOverride = null,
167
+ allowNoProxy = false
168
+ ) {
169
+ const proxyUrl = proxyUrlOverride || profile.proxyUrl;
170
+ if (!proxyUrl && !allowNoProxy) {
171
+ throw new Error('proxy_url_missing');
172
+ }
173
+
174
+ const launchOptions = { headless };
175
+ if (proxyUrl) {
176
+ const proxy = parseProxyUrl(proxyUrl);
177
+ launchOptions.proxy = {
178
+ server: proxy.server,
179
+ username: proxy.username,
180
+ password: proxy.password,
181
+ };
182
+ }
183
+
184
+ const browser = await chromium.launch(launchOptions);
185
+
186
+ const contextOptions = {
187
+ userAgent: profile.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
188
+ viewport: profile.viewport || { width: 1280, height: 900 },
189
+ locale: profile.locale || 'ru-RU',
190
+ timezoneId: profile.timezoneId || 'Europe/Moscow',
191
+ };
192
+ if (profile.storageState && typeof profile.storageState === 'object') {
193
+ contextOptions.storageState = profile.storageState;
194
+ } else if (profile.storageStatePath && existsSync(profile.storageStatePath)) {
195
+ contextOptions.storageState = profile.storageStatePath;
196
+ }
197
+ const context = await browser.newContext(contextOptions);
198
+
199
+ return { browser, context };
200
+ }
201
+
202
+ /**
203
+ * Round-robin выбор профиля из пула.
204
+ */
205
+ export class ProfilePool {
206
+ constructor(profiles) {
207
+ this.profiles = profiles;
208
+ this.index = 0;
209
+ }
210
+
211
+ next() {
212
+ if (!this.profiles.length) {
213
+ throw new Error('ProfilePool is empty');
214
+ }
215
+ const profile = this.profiles[this.index % this.profiles.length];
216
+ this.index++;
217
+ return profile;
218
+ }
219
+
220
+ size() {
221
+ return this.profiles.length;
222
+ }
223
+ }
@@ -0,0 +1,206 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+
3
+ function normalizeProvider(raw) {
4
+ if (!raw) return null;
5
+ const host = String(raw.host || '').trim();
6
+ const protocol = String(raw.protocol || 'http').trim() || 'http';
7
+ const portFrom = Number(raw.port_from ?? raw.portFrom);
8
+ const portTo = Number(raw.port_to ?? raw.portTo ?? raw.port_from ?? raw.portFrom);
9
+ if (!host || !Number.isFinite(portFrom) || !Number.isFinite(portTo)) return null;
10
+ const pf = Math.max(1, Math.min(65535, Math.floor(portFrom)));
11
+ const pt = Math.max(1, Math.min(65535, Math.floor(portTo)));
12
+ const port_from = Math.min(pf, pt);
13
+ const port_to = Math.max(pf, pt);
14
+ return {
15
+ id: raw.id != null ? Number(raw.id) : null,
16
+ host,
17
+ protocol,
18
+ port_from,
19
+ port_to,
20
+ username: raw.username ? String(raw.username) : null,
21
+ password: raw.password ? String(raw.password) : null,
22
+ status: raw.status ? String(raw.status) : 'active',
23
+ _cursor: port_from,
24
+ };
25
+ }
26
+
27
+ function parseProviderLine(line) {
28
+ const raw = String(line || '').trim();
29
+ if (!raw || raw.startsWith('#')) return null;
30
+ const re = /^(?:(?<user>[^:@\s]+):(?<pass>[^@\s]+)@)?(?<host>[^:\s]+):(?<port_from>\d{2,5})(?:-(?<port_to>\d{2,5}))?$/;
31
+ const m = raw.match(re);
32
+ if (!m?.groups) return null;
33
+ const host = m.groups.host;
34
+ const port_from = Number(m.groups.port_from);
35
+ const port_to = Number(m.groups.port_to ?? m.groups.port_from);
36
+ if (!host || !Number.isFinite(port_from) || !Number.isFinite(port_to)) return null;
37
+ return {
38
+ host,
39
+ port_from,
40
+ port_to,
41
+ username: m.groups.user || null,
42
+ password: m.groups.pass || null,
43
+ protocol: 'http',
44
+ status: 'active',
45
+ };
46
+ }
47
+
48
+ export function loadProxyProvidersFromFile(filePath) {
49
+ if (!filePath || !existsSync(filePath)) {
50
+ return [];
51
+ }
52
+ try {
53
+ const content = readFileSync(filePath, 'utf-8').trim();
54
+ if (!content) return [];
55
+ if (content.startsWith('[')) {
56
+ const arr = JSON.parse(content);
57
+ return (Array.isArray(arr) ? arr : [])
58
+ .map(normalizeProvider)
59
+ .filter(Boolean);
60
+ }
61
+ const lines = content.split('\n');
62
+ return lines.map(parseProviderLine).map(normalizeProvider).filter(Boolean);
63
+ } catch (e) {
64
+ return [];
65
+ }
66
+ }
67
+
68
+ export function loadProxyProvidersFromPayload(payloadB64) {
69
+ const rawPayload = String(payloadB64 || '').trim();
70
+ if (!rawPayload) return [];
71
+ let decoded = '';
72
+ try {
73
+ decoded = Buffer.from(rawPayload, 'base64').toString('utf-8');
74
+ } catch (e) {
75
+ throw new Error(`invalid base64 payload: ${e?.message || e}`);
76
+ }
77
+ let arr = null;
78
+ try {
79
+ arr = JSON.parse(decoded);
80
+ } catch (e) {
81
+ throw new Error(`invalid payload json: ${e?.message || e}`);
82
+ }
83
+ if (!Array.isArray(arr)) {
84
+ throw new Error('proxy payload must be a JSON array');
85
+ }
86
+ return arr.map(normalizeProvider).filter(Boolean);
87
+ }
88
+
89
+ export function buildProxyUrl(provider, port) {
90
+ const protocol = (provider.protocol || 'http').trim() || 'http';
91
+ const host = provider.host;
92
+ const username = provider.username ? encodeURIComponent(provider.username) : '';
93
+ const password = provider.password ? encodeURIComponent(provider.password) : '';
94
+ const auth = username || password ? `${username}:${password}@` : '';
95
+ return `${protocol}://${auth}${host}:${intPort(port)}`;
96
+ }
97
+
98
+ function intPort(port) {
99
+ const v = Number(port);
100
+ if (!Number.isFinite(v)) return 0;
101
+ return Math.max(1, Math.min(65535, Math.floor(v)));
102
+ }
103
+
104
+ export class ProxyAllocator {
105
+ constructor(providers, { leaseMs = 90000, randomize = false } = {}) {
106
+ this.providers = (providers || []).map(normalizeProvider).filter(Boolean);
107
+ this.leaseMs = Number(leaseMs) > 0 ? Number(leaseMs) : 90000;
108
+ this.busy = new Map();
109
+ this.index = 0;
110
+ this.randomize = Boolean(randomize);
111
+ }
112
+
113
+ size() {
114
+ return this.providers.length;
115
+ }
116
+
117
+ _cleanup() {
118
+ const now = Date.now();
119
+ for (const [key, exp] of this.busy.entries()) {
120
+ if (exp <= now) this.busy.delete(key);
121
+ }
122
+ }
123
+
124
+ _nextPort(provider) {
125
+ const from = provider.port_from;
126
+ const to = provider.port_to;
127
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return null;
128
+ if (provider._cursor == null || provider._cursor < from || provider._cursor > to) {
129
+ provider._cursor = from;
130
+ } else {
131
+ provider._cursor += 1;
132
+ if (provider._cursor > to) provider._cursor = from;
133
+ }
134
+ return provider._cursor;
135
+ }
136
+
137
+ _randomPort(provider) {
138
+ const from = provider.port_from;
139
+ const to = provider.port_to;
140
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return null;
141
+ const min = Math.min(from, to);
142
+ const max = Math.max(from, to);
143
+ const span = max - min + 1;
144
+ if (span <= 0) return null;
145
+ return min + Math.floor(Math.random() * span);
146
+ }
147
+
148
+ acquire() {
149
+ this._cleanup();
150
+ if (!this.providers.length) return null;
151
+ const total = this.providers.length;
152
+ if (this.randomize) {
153
+ const attempts = Math.max(20, total * 3);
154
+ for (let i = 0; i < attempts; i += 1) {
155
+ const provider = this.providers[Math.floor(Math.random() * total)];
156
+ const port = this._randomPort(provider);
157
+ if (!port) continue;
158
+ const key = `${provider.host}:${port}`;
159
+ if (!this.busy.has(key)) {
160
+ this.busy.set(key, Date.now() + this.leaseMs);
161
+ return { provider, port, proxyUrl: buildProxyUrl(provider, port) };
162
+ }
163
+ }
164
+ // Fallback to sequential if random selection couldn't find a free port
165
+ }
166
+ for (let i = 0; i < total; i += 1) {
167
+ const idx = (this.index + i) % total;
168
+ const provider = this.providers[idx];
169
+ const port = this._nextPort(provider);
170
+ if (!port) continue;
171
+ const key = `${provider.host}:${port}`;
172
+ if (!this.busy.has(key)) {
173
+ this.busy.set(key, Date.now() + this.leaseMs);
174
+ this.index = (idx + 1) % total;
175
+ return { provider, port, proxyUrl: buildProxyUrl(provider, port) };
176
+ }
177
+ }
178
+ // Fallback: all busy, return next port from current provider
179
+ const provider = this.providers[this.index % total];
180
+ const port = this._nextPort(provider);
181
+ if (!port) return null;
182
+ const key = `${provider.host}:${port}`;
183
+ this.busy.set(key, Date.now() + this.leaseMs);
184
+ this.index = (this.index + 1) % total;
185
+ return { provider, port, proxyUrl: buildProxyUrl(provider, port) };
186
+ }
187
+
188
+ release(provider, port) {
189
+ if (!provider || !port) return;
190
+ const key = `${provider.host}:${intPort(port)}`;
191
+ this.busy.delete(key);
192
+ }
193
+
194
+ releaseByUrl(url) {
195
+ try {
196
+ const u = new URL(url);
197
+ const host = u.hostname;
198
+ const port = intPort(u.port);
199
+ if (!host || !port) return;
200
+ const key = `${host}:${port}`;
201
+ this.busy.delete(key);
202
+ } catch {
203
+ // ignore
204
+ }
205
+ }
206
+ }