izteamslots 1.1.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.
@@ -0,0 +1,1402 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ import signal
10
+ import string
11
+ import subprocess
12
+ import threading
13
+ import time
14
+ import urllib.parse
15
+ from datetime import datetime, timedelta, timezone
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
17
+ from pathlib import Path
18
+ from typing import Any, Callable
19
+
20
+ import requests
21
+ from selenium.webdriver.common.by import By
22
+ from selenium.webdriver.common.keys import Keys
23
+ from selenium.webdriver.support import expected_conditions as EC
24
+ from selenium.webdriver.support.ui import WebDriverWait
25
+ from seleniumbase import Driver as create_driver
26
+
27
+ from . import PROJECT_ROOT as _PROJECT_ROOT
28
+ from .mail import Mailbox, MailError, MailProvider
29
+
30
+ LOGIN_URL = "https://chatgpt.com/auth/login_with"
31
+
32
+ _FIRST_NAMES = [
33
+ "Alex", "Jordan", "Taylor", "Morgan", "Casey", "Riley", "Jamie",
34
+ "Avery", "Quinn", "Blake", "Drew", "Sage", "River", "Skyler",
35
+ ]
36
+ _LAST_NAMES = [
37
+ "Smith", "Johnson", "Williams", "Brown", "Jones", "Davis",
38
+ "Miller", "Wilson", "Moore", "Taylor", "Anderson", "Thomas",
39
+ ]
40
+
41
+
42
+ def _human_delay(lo: float = 0.3, hi: float = 0.8) -> None:
43
+ """Случайная пауза, имитирующая человека."""
44
+ time.sleep(random.uniform(lo, hi))
45
+
46
+
47
+ def _human_type(driver: Any, selector: str, text: str) -> None:
48
+ """Посимвольный ввод текста с человеческими задержками."""
49
+ elem = WebDriverWait(driver, 30).until(
50
+ EC.presence_of_element_located((By.CSS_SELECTOR, selector))
51
+ )
52
+ try:
53
+ elem.clear()
54
+ except Exception:
55
+ pass
56
+ for ch in text:
57
+ elem.send_keys(ch)
58
+ time.sleep(random.uniform(0.04, 0.12))
59
+ _human_delay(0.2, 0.5)
60
+
61
+
62
+ class Locator:
63
+ def __init__(self, driver: Any, selector: str) -> None:
64
+ self._driver = driver
65
+ self._selector = selector
66
+
67
+ def count(self) -> int:
68
+ return len(self._driver.find_elements(By.CSS_SELECTOR, self._selector))
69
+
70
+ def fill(self, value: str) -> None:
71
+ elem = WebDriverWait(self._driver, 30).until(
72
+ EC.presence_of_element_located((By.CSS_SELECTOR, self._selector))
73
+ )
74
+ try:
75
+ elem.clear()
76
+ except Exception:
77
+ pass
78
+ elem.send_keys(value)
79
+
80
+ def click(self) -> None:
81
+ elem = WebDriverWait(self._driver, 30).until(
82
+ EC.element_to_be_clickable((By.CSS_SELECTOR, self._selector))
83
+ )
84
+ try:
85
+ elem.click()
86
+ except Exception:
87
+ self._driver.execute_script("arguments[0].click();", elem)
88
+
89
+ def get_attribute(self, name: str) -> str | None:
90
+ elems = self._driver.find_elements(By.CSS_SELECTOR, self._selector)
91
+ if not elems:
92
+ return None
93
+ return elems[0].get_attribute(name)
94
+
95
+
96
+ class Keyboard:
97
+ _KEY_MAP = {
98
+ "ArrowUp": Keys.ARROW_UP,
99
+ "ArrowDown": Keys.ARROW_DOWN,
100
+ "ArrowLeft": Keys.ARROW_LEFT,
101
+ "ArrowRight": Keys.ARROW_RIGHT,
102
+ "Enter": Keys.ENTER,
103
+ "Tab": Keys.TAB,
104
+ "Escape": Keys.ESCAPE,
105
+ "Backspace": Keys.BACKSPACE,
106
+ }
107
+
108
+ def __init__(self, driver: Any) -> None:
109
+ self._driver = driver
110
+
111
+ def press(self, key: str) -> None:
112
+ active = self._driver.switch_to.active_element
113
+ active.send_keys(self._KEY_MAP.get(key, key))
114
+
115
+
116
+ class BrowserContext:
117
+ def __init__(self, driver: Any, profile_dir: Path | None = None) -> None:
118
+ self.driver = driver
119
+ self._closed = False
120
+ self._profile_dir = profile_dir
121
+ self.page = Page(driver, self)
122
+
123
+ @property
124
+ def pages(self) -> list["Page"]:
125
+ if self._closed:
126
+ return []
127
+ try:
128
+ _ = self.driver.current_url
129
+ return [self.page]
130
+ except Exception:
131
+ return []
132
+
133
+ def close(self) -> None:
134
+ if self._closed:
135
+ return
136
+ try:
137
+ self.driver.quit()
138
+ except Exception:
139
+ if self._profile_dir:
140
+ _kill_chrome_for_profile(self._profile_dir)
141
+ finally:
142
+ self._closed = True
143
+
144
+
145
+ class Page:
146
+ def __init__(self, driver: Any, context: BrowserContext) -> None:
147
+ self._driver = driver
148
+ self.context = context
149
+ self.keyboard = Keyboard(driver)
150
+
151
+ @property
152
+ def url(self) -> str:
153
+ try:
154
+ return self._driver.current_url
155
+ except Exception:
156
+ return ""
157
+
158
+ def goto(self, url: str, wait_until: str | None = None, timeout: int | None = None) -> None:
159
+ self._driver.get(url)
160
+ if wait_until in {"domcontentloaded", "load"}:
161
+ t = (timeout or 30000) / 1000
162
+ WebDriverWait(self._driver, t).until(
163
+ lambda d: d.execute_script("return document.readyState") in {"interactive", "complete"}
164
+ )
165
+
166
+ def locator(self, selector: str) -> Locator:
167
+ return Locator(self._driver, selector)
168
+
169
+ def wait_for_selector(self, selector: str, timeout: int = 30000) -> None:
170
+ WebDriverWait(self._driver, timeout / 1000).until(
171
+ EC.presence_of_element_located((By.CSS_SELECTOR, selector))
172
+ )
173
+
174
+ def wait_for_url(self, predicate: Callable[[str], bool], timeout: int = 30000) -> None:
175
+ deadline = time.monotonic() + timeout / 1000
176
+ while time.monotonic() < deadline:
177
+ cur = self.url
178
+ if predicate(cur):
179
+ return
180
+ time.sleep(0.2)
181
+ raise TimeoutError(f"URL не достигнут за {timeout}мс")
182
+
183
+ def evaluate(self, script: str, args: Any = None) -> Any:
184
+ result = self._driver.execute_async_script(
185
+ """
186
+ const done = arguments[arguments.length - 1];
187
+ const source = arguments[0];
188
+ const payload = arguments[1];
189
+ (async () => {
190
+ try {
191
+ const fn = eval(source);
192
+ const out = (typeof fn === 'function') ? await fn(payload) : fn;
193
+ done({ ok: true, out });
194
+ } catch (e) {
195
+ done({ ok: false, err: String(e && e.stack ? e.stack : e) });
196
+ }
197
+ })();
198
+ """,
199
+ script,
200
+ args,
201
+ )
202
+ if not result or not result.get("ok"):
203
+ err = result.get("err") if isinstance(result, dict) else "evaluate failed"
204
+ raise RuntimeError(str(err))
205
+ return result.get("out")
206
+
207
+
208
+ _open_contexts: list[BrowserContext] = []
209
+
210
+ _DEBUG_DIR = _PROJECT_ROOT / "logs" / "debug"
211
+
212
+
213
+ def _save_debug_html(
214
+ page: Page,
215
+ label: str,
216
+ log: Callable[[str], Any] | None = None,
217
+ ) -> Path | None:
218
+ """Сохранить HTML страницы и скриншот для отладки."""
219
+ try:
220
+ _DEBUG_DIR.mkdir(parents=True, exist_ok=True)
221
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
222
+ safe_label = re.sub(r'[^\w\-.]', '_', label)[:80]
223
+ base = f"{ts}-{safe_label}"
224
+
225
+ # HTML
226
+ html = page._driver.page_source
227
+ html_path = _DEBUG_DIR / f"{base}.html"
228
+ html_path.write_text(html, encoding="utf-8")
229
+
230
+ # Скриншот (png)
231
+ try:
232
+ png_path = _DEBUG_DIR / f"{base}.png"
233
+ page._driver.save_screenshot(str(png_path))
234
+ except Exception:
235
+ png_path = None
236
+
237
+ if log:
238
+ files = str(html_path.name)
239
+ if png_path:
240
+ files += f", {png_path.name}"
241
+ log(f"[debug] Сохранено: {files}")
242
+ return html_path
243
+ except Exception as e:
244
+ if log:
245
+ log(f"[debug] Не удалось сохранить: {e}")
246
+ return None
247
+
248
+
249
+ def extract_code_from_subject(subject: str) -> str | None:
250
+ match = re.search(r"\b(\d{6})\b", subject)
251
+ return match.group(1) if match else None
252
+
253
+
254
+ def poll_for_code(
255
+ mail_client: MailProvider,
256
+ mailbox: Mailbox,
257
+ existing_ids: set[str],
258
+ timeout: int = 60,
259
+ interval: int = 5,
260
+ log: Callable[[str], Any] | None = None,
261
+ ) -> str:
262
+ _log = log or print
263
+ elapsed = 0
264
+ while elapsed < timeout:
265
+ inbox = mail_client.inbox(mailbox)
266
+ for msg in inbox.messages:
267
+ if msg.id in existing_ids:
268
+ continue
269
+ code = extract_code_from_subject(msg.subject)
270
+ if code:
271
+ _log(f"Получен код: {code} (из: {msg.subject})")
272
+ return code
273
+ _log(f"Ожидание кода... ({elapsed}/{timeout} сек)")
274
+ time.sleep(interval)
275
+ elapsed += interval
276
+ raise TimeoutError(f"Код не получен за {timeout} секунд")
277
+
278
+
279
+ def make_openai_password(mail_password: str, min_len: int = 13) -> str:
280
+ pwd = mail_password
281
+ if len(pwd) < min_len:
282
+ pad = string.ascii_letters + string.digits
283
+ pwd += "".join(random.choices(pad, k=min_len - len(pwd)))
284
+ return pwd
285
+
286
+
287
+ def _random_name() -> str:
288
+ return f"{random.choice(_FIRST_NAMES)} {random.choice(_LAST_NAMES)}"
289
+
290
+
291
+ def _random_birthday() -> tuple[str, str, str]:
292
+ year = random.randint(1990, 2004)
293
+ month = random.randint(1, 12)
294
+ day = random.randint(1, 28)
295
+ return str(day).zfill(2), str(month).zfill(2), str(year)
296
+
297
+
298
+ def _check_birthday_values(page: Page) -> dict[str, str]:
299
+ return page.evaluate(
300
+ """() => {
301
+ const d = document.querySelector('[data-type="day"][role="spinbutton"]');
302
+ const m = document.querySelector('[data-type="month"][role="spinbutton"]');
303
+ const y = document.querySelector('[data-type="year"][role="spinbutton"]');
304
+ return {
305
+ day: d ? d.textContent || d.innerText || '' : '',
306
+ month: m ? m.textContent || m.innerText || '' : '',
307
+ year: y ? y.textContent || y.innerText || '' : '',
308
+ };
309
+ }"""
310
+ )
311
+
312
+
313
+ def _birthday_ok(values: dict[str, str]) -> bool:
314
+ y = values.get("year", "")
315
+ m = values.get("month", "")
316
+ d = values.get("day", "")
317
+ return (
318
+ len(y) == 4 and y.isdigit()
319
+ and len(m) <= 2 and m.isdigit()
320
+ and len(d) <= 2 and d.isdigit()
321
+ )
322
+
323
+
324
+ def _fill_birthday(
325
+ page: Page, day: str, month: str, year: str, log: Callable[[str], Any],
326
+ ) -> None:
327
+ """Заполнить дату рождения на странице регистрации OpenAI."""
328
+ has_spinbuttons = page.locator('[role="spinbutton"]').count() > 0
329
+ if not has_spinbuttons:
330
+ log("[предупреждение] Spinbutton-элементы даты не найдены")
331
+ for sel_type, val in [("day", day), ("month", month), ("year", year)]:
332
+ for sel in (f'input[name="{sel_type}"]', f'input[placeholder*="{sel_type}"]'):
333
+ if page.locator(sel).count() > 0:
334
+ page.locator(sel).fill(val)
335
+ break
336
+ return
337
+
338
+ day_sel = '[data-type="day"][role="spinbutton"]'
339
+ month_sel = '[data-type="month"][role="spinbutton"]'
340
+ year_sel = '[data-type="year"][role="spinbutton"]'
341
+
342
+ # Стратегия 1: beforeinput events (надёжно работает в headless)
343
+ for sel, digits in [(day_sel, day), (month_sel, month), (year_sel, year)]:
344
+ if page.locator(sel).count() == 0:
345
+ continue
346
+ page.locator(sel).click()
347
+ _human_delay(0.15, 0.35)
348
+ for ch in digits:
349
+ page.evaluate(
350
+ """(args) => {
351
+ const el = document.querySelector(args.sel);
352
+ if (!el) return;
353
+ el.dispatchEvent(new InputEvent('beforeinput', {
354
+ cancelable: true, data: args.ch,
355
+ inputType: 'insertText', bubbles: true
356
+ }));
357
+ }""",
358
+ {"sel": sel, "ch": ch},
359
+ )
360
+ _human_delay(0.06, 0.15)
361
+ _human_delay(0.2, 0.5)
362
+
363
+ values = _check_birthday_values(page)
364
+ log(f"Дата (beforeinput): day={values.get('day')}, month={values.get('month')}, year={values.get('year')}")
365
+ if _birthday_ok(values):
366
+ return
367
+
368
+ # Стратегия 2: полная JS-имитация (focus + keydown/beforeinput/input/keyup)
369
+ log("beforeinput не сработал, пробую JS fallback...")
370
+ page.evaluate(
371
+ """(args) => {
372
+ function fillSpin(sel, value) {
373
+ const el = document.querySelector(sel);
374
+ if (!el) return;
375
+ el.focus();
376
+ for (const ch of String(value)) {
377
+ const opts = { key: ch, code: 'Digit' + ch, bubbles: true, cancelable: true };
378
+ el.dispatchEvent(new KeyboardEvent('keydown', opts));
379
+ el.dispatchEvent(new InputEvent('beforeinput', {
380
+ data: ch, inputType: 'insertText', bubbles: true, cancelable: true
381
+ }));
382
+ el.dispatchEvent(new InputEvent('input', {
383
+ data: ch, inputType: 'insertText', bubbles: true
384
+ }));
385
+ el.dispatchEvent(new KeyboardEvent('keyup', opts));
386
+ }
387
+ }
388
+ fillSpin('[data-type="day"][role="spinbutton"]', args.day);
389
+ fillSpin('[data-type="month"][role="spinbutton"]', args.month);
390
+ fillSpin('[data-type="year"][role="spinbutton"]', args.year);
391
+ }""",
392
+ {"day": day, "month": month, "year": year},
393
+ )
394
+ time.sleep(0.5)
395
+
396
+ values = _check_birthday_values(page)
397
+ log(f"Дата (JS fallback): day={values.get('day')}, month={values.get('month')}, year={values.get('year')}")
398
+
399
+
400
+ def _wait_fieldset_enabled(page: Page, log: Callable[[str], Any], timeout: int = 30) -> None:
401
+ """Ждём пока <fieldset disabled> на about-you станет enabled (Sentinel антибот)."""
402
+ deadline = time.time() + timeout
403
+ while time.time() < deadline:
404
+ disabled = page.evaluate(
405
+ """() => {
406
+ const fs = document.querySelector('fieldset');
407
+ return fs ? fs.disabled : false;
408
+ }"""
409
+ )
410
+ if not disabled:
411
+ return
412
+ time.sleep(0.5)
413
+
414
+ log("[предупреждение] fieldset остаётся disabled — снимаю принудительно")
415
+ page.evaluate(
416
+ """() => {
417
+ document.querySelectorAll('fieldset[disabled]').forEach(fs => {
418
+ fs.removeAttribute('disabled');
419
+ });
420
+ }"""
421
+ )
422
+ time.sleep(0.3)
423
+
424
+
425
+ def _submit_about_you_form(page: Page, log: Callable[[str], Any]) -> None:
426
+ """Отправить форму about-you. Если кнопка не кликается — submit через JS."""
427
+ try:
428
+ page.locator('button[type="submit"]').click()
429
+ except Exception:
430
+ log("[предупреждение] Кнопка submit не кликается, отправляю через JS")
431
+ page.evaluate(
432
+ """() => {
433
+ const form = document.querySelector('form[action="/about-you"]');
434
+ if (form) {
435
+ form.requestSubmit();
436
+ } else {
437
+ const btn = document.querySelector('button[type="submit"]');
438
+ if (btn) btn.click();
439
+ }
440
+ }"""
441
+ )
442
+
443
+
444
+ def _is_oops_error(page: Page) -> bool:
445
+ """Проверить, показывается ли страница 'Oops, an error occurred!'."""
446
+ try:
447
+ return page.locator('button[data-dd-action-name="Try again"]').count() > 0
448
+ except Exception:
449
+ return False
450
+
451
+
452
+ def _handle_oops_retry(
453
+ page: Page, log: Callable[[str], Any], max_retries: int = 3,
454
+ ) -> bool:
455
+ """Если на странице 'Oops' — нажать 'Try again' до max_retries раз. True = удалось уйти."""
456
+ for attempt in range(1, max_retries + 1):
457
+ if not _is_oops_error(page):
458
+ return True
459
+ log(f"[oops] Обнаружена ошибка 'Oops', нажимаю Try again ({attempt}/{max_retries})...")
460
+ _save_debug_html(page, f"oops-error-attempt-{attempt}", log)
461
+ try:
462
+ page.locator('button[data-dd-action-name="Try again"]').click()
463
+ except Exception:
464
+ pass
465
+ time.sleep(5)
466
+ return not _is_oops_error(page)
467
+
468
+
469
+ def extract_invite_link(body: str) -> str | None:
470
+ match = re.search(r'href="(https://chatgpt\.com/auth/login\?[^"]+)"', body)
471
+ if match:
472
+ return match.group(1)
473
+ match = re.search(r'(https://chatgpt\.com/auth/login\?[^\s<>"\']+)', body)
474
+ return match.group(1) if match else None
475
+
476
+
477
+ def poll_for_invite(
478
+ mail_client: MailProvider,
479
+ mailbox: Mailbox,
480
+ existing_ids: set[str],
481
+ timeout: int = 90,
482
+ interval: int = 5,
483
+ log: Callable[[str], Any] | None = None,
484
+ ) -> str:
485
+ _log = log or print
486
+ elapsed = 0
487
+ while elapsed < timeout:
488
+ inbox = mail_client.inbox(mailbox)
489
+ for msg in inbox.messages:
490
+ if msg.id in existing_ids:
491
+ continue
492
+ link = extract_invite_link(msg.body)
493
+ if link:
494
+ _log("Инвайт-ссылка получена")
495
+ return link
496
+ _log(f"Ожидание инвайта... ({elapsed}/{timeout} сек)")
497
+ time.sleep(interval)
498
+ elapsed += interval
499
+ raise TimeoutError(f"Инвайт не получен за {timeout} секунд")
500
+
501
+
502
+ def _kill_chrome_for_profile(profile_dir: Path) -> None:
503
+ """Kill any Chrome/uc_driver processes that hold this profile directory."""
504
+ resolved = str(profile_dir.resolve())
505
+ killed = False
506
+ try:
507
+ out = subprocess.check_output(["ps", "aux"], text=True, timeout=5)
508
+ except Exception:
509
+ out = ""
510
+ for line in out.splitlines():
511
+ if resolved not in line:
512
+ continue
513
+ parts = line.split()
514
+ if len(parts) < 2:
515
+ continue
516
+ try:
517
+ pid = int(parts[1])
518
+ if pid == os.getpid():
519
+ continue
520
+ os.kill(pid, signal.SIGTERM)
521
+ killed = True
522
+ except (ValueError, ProcessLookupError, PermissionError):
523
+ pass
524
+ if killed:
525
+ time.sleep(2)
526
+ # Remove stale Chrome lock files so the profile can be reused
527
+ for name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
528
+ lock = profile_dir / name
529
+ try:
530
+ lock.unlink(missing_ok=True)
531
+ except OSError:
532
+ pass
533
+
534
+
535
+ def _launch_page(profile_dir: Path, headless: bool = False) -> tuple[Page, BrowserContext]:
536
+ profile_dir.parent.mkdir(parents=True, exist_ok=True)
537
+ if profile_dir.exists():
538
+ try:
539
+ next(profile_dir.iterdir())
540
+ except StopIteration:
541
+ profile_dir.rmdir()
542
+ _kill_chrome_for_profile(profile_dir)
543
+ _MOBILE_UA = (
544
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
545
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) "
546
+ "Version/17.0 Mobile/15E148 Safari/604.1"
547
+ )
548
+ driver = create_driver(
549
+ browser="chrome",
550
+ headed=not headless,
551
+ uc=True,
552
+ headless2=headless,
553
+ user_data_dir=str(profile_dir.resolve()),
554
+ locale_code="en",
555
+ mobile=True,
556
+ d_width=393,
557
+ d_height=852,
558
+ d_p_r=3,
559
+ )
560
+ driver.set_script_timeout(60)
561
+
562
+ # UC mode применяет setDeviceMetricsOverride внутри driver.get(),
563
+ # но User-Agent и touch нужно выставить отдельно — CDP-команды
564
+ # для UA и touch сохраняются между навигациями.
565
+ try:
566
+ driver.execute_cdp_cmd("Emulation.setUserAgentOverride", {
567
+ "userAgent": _MOBILE_UA,
568
+ })
569
+ driver.execute_cdp_cmd("Emulation.setTouchEmulationEnabled", {
570
+ "enabled": True,
571
+ })
572
+ except Exception:
573
+ pass
574
+
575
+ # SeleniumBase UC mode перехватывает driver.get() и внутри вызывает driver.close()
576
+ # для антидетекта. Если в браузере только 1 таб — Chrome зависает на
577
+ # "failed to close window in 20 seconds". Гарантируем наличие 2+ табов.
578
+ try:
579
+ handles = list(driver.window_handles)
580
+ # Переключиться на основной (не chrome://) таб
581
+ if len(handles) > 1:
582
+ for h in handles:
583
+ driver.switch_to.window(h)
584
+ if not driver.current_url.startswith("chrome://"):
585
+ break
586
+ # Если только 1 таб — открыть второй пустой для UC mode
587
+ if len(driver.window_handles) < 2:
588
+ driver.execute_script("window.open('about:blank')")
589
+ driver.switch_to.window(driver.window_handles[0])
590
+ except Exception:
591
+ pass
592
+
593
+ context = BrowserContext(driver, profile_dir=profile_dir)
594
+ _open_contexts.append(context)
595
+ return context.page, context
596
+
597
+
598
+ def _wait_for_any(
599
+ page: Page,
600
+ selectors: list[str],
601
+ url_contains: list[str] | None = None,
602
+ url_excludes: list[str] | None = None,
603
+ timeout: int = 30000,
604
+ ) -> str:
605
+ interval_ms = 500
606
+ elapsed = 0
607
+ while elapsed < timeout:
608
+ try:
609
+ current_url = page.url
610
+ for substr in (url_contains or []):
611
+ if substr in current_url and not any(exc in current_url for exc in (url_excludes or [])):
612
+ return "url"
613
+ for sel in selectors:
614
+ if page.locator(sel).count() > 0:
615
+ return sel
616
+ except Exception:
617
+ pass
618
+ time.sleep(interval_ms / 1000)
619
+ elapsed += interval_ms
620
+ raise TimeoutError(f"Таймаут {timeout}мс: ни один селектор/URL не сработал")
621
+
622
+
623
+ def close_browser(page: Page, log: Callable[[str], Any] | None = None) -> None:
624
+ _log = log or print
625
+ try:
626
+ context = page.context
627
+ context.close()
628
+ _open_contexts[:] = [c for c in _open_contexts if c != context]
629
+ _log("Браузер закрыт")
630
+ except Exception as e:
631
+ _log(f"Ошибка при закрытии браузера: {e}")
632
+
633
+
634
+ def open_browser(
635
+ profile_dir: Path,
636
+ url: str = "https://chatgpt.com/",
637
+ log: Callable[[str], Any] | None = None,
638
+ headless: bool = False,
639
+ ) -> tuple[Page, BrowserContext]:
640
+ _log = log or print
641
+ _log("Открываю браузер...")
642
+ _log(f"Chrome profile: {profile_dir}")
643
+ page, context = _launch_page(profile_dir, headless=headless)
644
+ driver = context.driver
645
+
646
+ _log(f"Открываю {url}...")
647
+ driver.get(url)
648
+
649
+ WebDriverWait(driver, 30).until(
650
+ lambda d: d.execute_script("return document.readyState") in {"interactive", "complete"}
651
+ )
652
+
653
+ # Oops fallback
654
+ if _is_oops_error(page):
655
+ _handle_oops_retry(page, _log)
656
+
657
+ _log(f"Браузер открыт: {page.url}")
658
+ return page, context
659
+
660
+
661
+ def wait_for_browser_close(context: BrowserContext, log: Callable[[str], Any] | None = None) -> None:
662
+ _log = log or print
663
+ while True:
664
+ pages = context.pages
665
+ if not pages:
666
+ break
667
+ time.sleep(1)
668
+ context.close()
669
+ _open_contexts[:] = [c for c in _open_contexts if c != context]
670
+ _log("Браузер закрыт")
671
+
672
+
673
+ def _decode_jwt_payload(token: str) -> dict | None:
674
+ try:
675
+ parts = token.split(".")
676
+ if len(parts) < 2:
677
+ return None
678
+ payload = parts[1]
679
+ payload += "=" * (4 - len(payload) % 4)
680
+ decoded = base64.urlsafe_b64decode(payload)
681
+ return json.loads(decoded)
682
+ except Exception:
683
+ return None
684
+
685
+
686
+ def _start_callback_server(state: str) -> tuple[HTTPServer, str, dict[str, str | None]]:
687
+ holder: dict[str, str | None] = {"code": None, "error": None}
688
+
689
+ class Handler(BaseHTTPRequestHandler):
690
+ def do_GET(self) -> None: # noqa: N802
691
+ parsed = urllib.parse.urlparse(self.path)
692
+ if parsed.path != "/auth/callback":
693
+ self.send_response(404)
694
+ self.end_headers()
695
+ return
696
+
697
+ query = urllib.parse.parse_qs(parsed.query)
698
+ code = (query.get("code") or [""])[0]
699
+ recv_state = (query.get("state") or [""])[0]
700
+ error = (query.get("error") or [""])[0]
701
+
702
+ if error:
703
+ holder["error"] = f"OAuth error: {error}"
704
+ elif recv_state != state:
705
+ holder["error"] = "State mismatch"
706
+ elif code:
707
+ holder["code"] = code
708
+
709
+ self.send_response(200)
710
+ self.send_header("Content-Type", "text/html; charset=utf-8")
711
+ self.end_headers()
712
+ self.wfile.write("<html><body><h2>OK! Можно закрыть вкладку.</h2></body></html>".encode("utf-8"))
713
+
714
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
715
+ return
716
+
717
+ server: HTTPServer | None = None
718
+ port = 1455
719
+ for candidate in (1455, 1456):
720
+ try:
721
+ server = HTTPServer(("127.0.0.1", candidate), Handler)
722
+ port = candidate
723
+ break
724
+ except OSError:
725
+ continue
726
+
727
+ if server is None:
728
+ raise RuntimeError("Не удалось поднять callback-сервер на портах 1455/1456")
729
+
730
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
731
+ thread.start()
732
+ return server, f"http://localhost:{port}/auth/callback", holder
733
+
734
+
735
+ def _wait_for_callback(holder: dict[str, str | None], timeout: int = 120) -> str:
736
+ deadline = time.time() + timeout
737
+ while time.time() < deadline:
738
+ if holder.get("error"):
739
+ raise RuntimeError(holder["error"] or "OAuth callback error")
740
+ if holder.get("code"):
741
+ return holder["code"] or ""
742
+ time.sleep(0.2)
743
+ raise TimeoutError("Не удалось получить authorization code")
744
+
745
+
746
+ def _handle_consent_and_wait(page: Page, log: Callable[[str], Any], callback_wait_seconds: int = 30) -> None:
747
+ deadline = time.time() + callback_wait_seconds
748
+ while time.time() < deadline:
749
+ url = page.url
750
+ if "localhost" in url:
751
+ return
752
+ if "consent" in url and page.locator('button[type="submit"]').count() > 0:
753
+ log("Страница consent — нажимаю 'Продолжить'...")
754
+ page.locator('button[type="submit"]').click()
755
+ time.sleep(1)
756
+
757
+
758
+ def _prepare_oauth_authorize_url() -> tuple[str, HTTPServer, dict[str, str | None], str, str]:
759
+ code_verifier = base64.urlsafe_b64encode(os.urandom(96)).rstrip(b"=").decode()
760
+ code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
761
+ state = os.urandom(16).hex()
762
+
763
+ server, redirect_uri, holder = _start_callback_server(state)
764
+ params = urllib.parse.urlencode({
765
+ "client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
766
+ "response_type": "code",
767
+ "redirect_uri": redirect_uri,
768
+ "scope": "openid email profile offline_access",
769
+ "state": state,
770
+ "code_challenge": code_challenge,
771
+ "code_challenge_method": "S256",
772
+ "prompt": "login",
773
+ "id_token_add_organizations": "true",
774
+ "codex_cli_simplified_flow": "true",
775
+ })
776
+ authorize_url = f"https://auth.openai.com/oauth/authorize?{params}"
777
+ return authorize_url, server, holder, redirect_uri, code_verifier
778
+
779
+
780
+ def _exchange_oauth_code(auth_code: str, redirect_uri: str, code_verifier: str) -> dict[str, Any]:
781
+ token_data = {
782
+ "grant_type": "authorization_code",
783
+ "client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
784
+ "code": auth_code,
785
+ "redirect_uri": redirect_uri,
786
+ "code_verifier": code_verifier,
787
+ }
788
+ resp = requests.post(
789
+ "https://auth.openai.com/oauth/token",
790
+ data=token_data,
791
+ headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
792
+ timeout=30,
793
+ )
794
+ if resp.status_code != 200:
795
+ raise RuntimeError(f"Token exchange failed [{resp.status_code}]: {resp.text[:200]}")
796
+
797
+ tokens = resp.json()
798
+ access_token = tokens.get("access_token", "")
799
+ id_token = tokens.get("id_token", "")
800
+ refresh_token = tokens.get("refresh_token", "")
801
+ expires_in = tokens.get("expires_in", 0)
802
+
803
+ session_result: dict[str, Any] = {
804
+ "access_token": access_token,
805
+ "id_token": id_token,
806
+ "refresh_token": refresh_token,
807
+ }
808
+
809
+ jwt_data = _decode_jwt_payload(id_token or access_token)
810
+ if jwt_data:
811
+ auth_info = jwt_data.get("https://api.openai.com/auth", {})
812
+ session_result["account_id"] = auth_info.get("chatgpt_account_id")
813
+ profile = jwt_data.get("https://api.openai.com/profile", {})
814
+ session_result["email"] = profile.get("email") or jwt_data.get("email")
815
+ exp = jwt_data.get("exp")
816
+ if exp:
817
+ session_result["expired"] = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat()
818
+ elif expires_in:
819
+ session_result["expired"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).isoformat()
820
+
821
+ return session_result
822
+
823
+
824
+ def _has_chatgpt_web_session(page: Page) -> bool:
825
+ result = page.evaluate(
826
+ """async () => {
827
+ try {
828
+ const resp = await fetch("https://chatgpt.com/api/auth/session", {
829
+ credentials: "include",
830
+ cache: "no-store",
831
+ });
832
+ const text = await resp.text();
833
+ return { status: resp.status, text };
834
+ } catch (e) {
835
+ return { status: 0, text: String(e) };
836
+ }
837
+ }"""
838
+ )
839
+
840
+ if not isinstance(result, dict):
841
+ return False
842
+ if result.get("status") != 200:
843
+ return False
844
+
845
+ text = str(result.get("text", ""))
846
+ return "\"accessToken\"" in text or "\"user\"" in text
847
+
848
+
849
+ def _bootstrap_chatgpt_session(
850
+ page: Page,
851
+ log: Callable[[str], Any],
852
+ *,
853
+ timeout_seconds: int = 45,
854
+ interactive: bool = False,
855
+ ) -> None:
856
+ last_error = ""
857
+ auth_prompt_logged = False
858
+ consent_logged = False
859
+ workspace_logged = False
860
+
861
+ if interactive:
862
+ try:
863
+ log("Открываю chatgpt.com для web-сессии...")
864
+ page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=30000)
865
+ except Exception as e:
866
+ last_error = str(e)
867
+
868
+ deadline = time.time() + timeout_seconds
869
+ while time.time() < deadline:
870
+ try:
871
+ current_url = page.url
872
+ if "localhost" in current_url:
873
+ time.sleep(1)
874
+ continue
875
+ if _has_chatgpt_web_session(page):
876
+ log("Web-сессия chatgpt.com активна")
877
+ time.sleep(3)
878
+ return
879
+ if "/auth/" not in current_url and page.locator('button[name="workspace_id"]').count() > 0:
880
+ if not workspace_logged:
881
+ log("Открыт выбор workspace. Финальную web-сессию подтвержу после выбора workspace.")
882
+ workspace_logged = True
883
+ time.sleep(3)
884
+ return
885
+ if ("/auth/" in current_url or "auth.openai.com" in current_url) and not auth_prompt_logged:
886
+ log("Нужен второй вход для web-сессии chatgpt.com. Завершите его вручную в этом же окне, я подожду.")
887
+ auth_prompt_logged = True
888
+ if "consent" in current_url and page.locator('button[type="submit"]').count() > 0:
889
+ if not consent_logged:
890
+ log("Обнаружен consent для web-сессии, можно подтвердить его в браузере.")
891
+ consent_logged = True
892
+ except Exception as e:
893
+ last_error = str(e)
894
+ time.sleep(1)
895
+
896
+ if last_error:
897
+ log(f"[предупреждение] Не удалось подтвердить web-сессию: {last_error}")
898
+ else:
899
+ log("[предупреждение] Не удалось подтвердить web-сессию chatgpt.com")
900
+ return
901
+
902
+ for url in (LOGIN_URL, "https://chatgpt.com/"):
903
+ try:
904
+ log(f"Закрепляю web-сессию: {url}")
905
+ page.goto(url, wait_until="domcontentloaded", timeout=30000)
906
+ except Exception as e:
907
+ last_error = str(e)
908
+ continue
909
+
910
+ deadline = time.time() + timeout_seconds
911
+ while time.time() < deadline:
912
+ try:
913
+ current_url = page.url
914
+ if "localhost" in current_url:
915
+ time.sleep(1)
916
+ continue
917
+ if _has_chatgpt_web_session(page):
918
+ log("Web-сессия chatgpt.com активна")
919
+ time.sleep(3)
920
+ return
921
+ if "/auth/" not in current_url and page.locator('button[name="workspace_id"]').count() > 0:
922
+ if not workspace_logged:
923
+ log("Открыт выбор workspace. Финальную web-сессию подтвержу после выбора workspace.")
924
+ workspace_logged = True
925
+ time.sleep(3)
926
+ return
927
+ except Exception as e:
928
+ last_error = str(e)
929
+ time.sleep(1)
930
+
931
+ if last_error:
932
+ log(f"[предупреждение] Не удалось подтвердить web-сессию: {last_error}")
933
+ else:
934
+ log("[предупреждение] Не удалось подтвердить web-сессию chatgpt.com")
935
+
936
+
937
+ def ensure_chatgpt_web_session(
938
+ page: Page,
939
+ log: Callable[[str], Any],
940
+ *,
941
+ timeout_seconds: int = 45,
942
+ open_home: bool = False,
943
+ ) -> bool:
944
+ last_error = ""
945
+ if open_home:
946
+ try:
947
+ log("Проверяю финальную web-сессию на chatgpt.com...")
948
+ page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=30000)
949
+ except Exception as e:
950
+ last_error = str(e)
951
+
952
+ deadline = time.time() + timeout_seconds
953
+ while time.time() < deadline:
954
+ try:
955
+ current_url = page.url
956
+ if "localhost" in current_url:
957
+ time.sleep(1)
958
+ continue
959
+ if _has_chatgpt_web_session(page):
960
+ log("Финальная web-сессия chatgpt.com подтверждена")
961
+ time.sleep(2)
962
+ return True
963
+ except Exception as e:
964
+ last_error = str(e)
965
+ time.sleep(1)
966
+
967
+ if last_error:
968
+ log(f"[предупреждение] Финальная web-сессия не подтверждена: {last_error}")
969
+ else:
970
+ log("[предупреждение] Финальная web-сессия chatgpt.com не подтверждена")
971
+ return False
972
+
973
+
974
+ def oauth_login(
975
+ email: str,
976
+ password: str,
977
+ mail_client: MailProvider,
978
+ mailbox: Mailbox,
979
+ profile_dir: Path,
980
+ log: Callable[[str], Any] | None = None,
981
+ headless: bool = False,
982
+ ) -> tuple[Page, dict]:
983
+ _log = log or print
984
+
985
+ _log("Открываю браузер...")
986
+ page, _context = _launch_page(profile_dir, headless=headless)
987
+
988
+ existing_ids: set[str] = set()
989
+ try:
990
+ inbox = mail_client.inbox(mailbox)
991
+ existing_ids = {msg.id for msg in inbox.messages}
992
+ _log(f"В почте {len(existing_ids)} писем, игнорируем их")
993
+ except MailError as e:
994
+ _log(f"Почта недоступна, продолжаю без prefetch inbox: {e}")
995
+
996
+ authorize_url, server, holder, redirect_uri, code_verifier = _prepare_oauth_authorize_url()
997
+
998
+ try:
999
+ page.goto(authorize_url, wait_until="domcontentloaded", timeout=30000)
1000
+ _human_delay(1.5, 3.0)
1001
+
1002
+ # Oops fallback
1003
+ if _is_oops_error(page):
1004
+ if not _handle_oops_retry(page, _log):
1005
+ raise RuntimeError("Страница 'Oops' не исчезла после retry")
1006
+ _human_delay(1.5, 3.0)
1007
+
1008
+ if "log-in-or-create-account" in page.url and page.locator('a[href="/log-in"]').count() > 0:
1009
+ _human_delay(0.5, 1.2)
1010
+ page.locator('a[href="/log-in"]').click()
1011
+ _log("Нажато 'Войти'")
1012
+ _human_delay(1.0, 2.0)
1013
+
1014
+ wait_result = _wait_for_any(
1015
+ page,
1016
+ ['input[type="email"][name="email"]', 'input[name="code"]', 'input[name="password"]'],
1017
+ url_contains=["localhost"],
1018
+ timeout=30000,
1019
+ )
1020
+
1021
+ if wait_result == 'input[type="email"][name="email"]':
1022
+ _human_delay(0.5, 1.2)
1023
+ _human_type(page._driver, 'input[type="email"][name="email"]', email)
1024
+ _human_delay(0.3, 0.7)
1025
+ page.locator('button[type="submit"]').click()
1026
+ _log("Email введён, нажато Продолжить")
1027
+
1028
+ wait_result = _wait_for_any(
1029
+ page,
1030
+ ['input[name="code"]', 'input[name="password"]', 'button[value="passwordless_login_send_otp"]'],
1031
+ url_contains=["localhost"],
1032
+ timeout=30000,
1033
+ )
1034
+
1035
+ if wait_result == 'button[value="passwordless_login_send_otp"]':
1036
+ _human_delay(0.3, 0.8)
1037
+ page.locator('button[value="passwordless_login_send_otp"]').click()
1038
+ _log("Нажат вход через одноразовый код")
1039
+ _human_delay(1.0, 2.0)
1040
+ _wait_for_any(page, ['input[name="code"]'], timeout=30000)
1041
+ code = poll_for_code(mail_client, mailbox, existing_ids, log=_log)
1042
+ _human_delay(0.3, 0.8)
1043
+ _human_type(page._driver, 'input[name="code"]', code)
1044
+ _human_delay(0.2, 0.5)
1045
+ page.locator('button[name="intent"][value="validate"]').click()
1046
+ elif wait_result == 'input[name="password"]':
1047
+ if page.locator('button[value="passwordless_login_send_otp"]').count() > 0:
1048
+ _human_delay(0.3, 0.8)
1049
+ page.locator('button[value="passwordless_login_send_otp"]').click()
1050
+ _human_delay(1.0, 2.0)
1051
+ _wait_for_any(page, ['input[name="code"]'], timeout=30000)
1052
+ code = poll_for_code(mail_client, mailbox, existing_ids, log=_log)
1053
+ _human_delay(0.3, 0.8)
1054
+ _human_type(page._driver, 'input[name="code"]', code)
1055
+ _human_delay(0.2, 0.5)
1056
+ page.locator('button[name="intent"][value="validate"]').click()
1057
+ else:
1058
+ _human_delay(0.4, 1.0)
1059
+ _human_type(page._driver, 'input[name="password"]', password)
1060
+ _human_delay(0.3, 0.7)
1061
+ page.locator('button[type="submit"]').click()
1062
+ elif wait_result == 'input[name="code"]':
1063
+ code = poll_for_code(mail_client, mailbox, existing_ids, log=_log)
1064
+ _human_delay(0.3, 0.8)
1065
+ _human_type(page._driver, 'input[name="code"]', code)
1066
+ _human_delay(0.2, 0.5)
1067
+ page.locator('button[name="intent"][value="validate"]').click()
1068
+
1069
+ _handle_consent_and_wait(page, _log, callback_wait_seconds=45)
1070
+ auth_code = _wait_for_callback(holder, timeout=120)
1071
+ _log("Authorization code получен")
1072
+ session_result = _exchange_oauth_code(auth_code, redirect_uri, code_verifier)
1073
+
1074
+ _bootstrap_chatgpt_session(page, _log)
1075
+ return page, session_result
1076
+ except Exception:
1077
+ try:
1078
+ _save_debug_html(page, f"oauth-error-{email}", _log)
1079
+ except Exception:
1080
+ pass
1081
+ try:
1082
+ _context.close()
1083
+ except Exception:
1084
+ pass
1085
+ _open_contexts[:] = [c for c in _open_contexts if c != _context]
1086
+ raise
1087
+ finally:
1088
+ server.shutdown()
1089
+ server.server_close()
1090
+
1091
+
1092
+ def oauth_login_manual(
1093
+ profile_dir: Path,
1094
+ log: Callable[[str], Any] | None = None,
1095
+ *,
1096
+ expected_email: str | None = None,
1097
+ timeout_seconds: int = 600,
1098
+ ) -> tuple[Page, dict]:
1099
+ _log = log or print
1100
+
1101
+ _log("Открываю браузер для ручного логина...")
1102
+ page, _context = _launch_page(profile_dir)
1103
+ authorize_url, server, holder, redirect_uri, code_verifier = _prepare_oauth_authorize_url()
1104
+
1105
+ try:
1106
+ page.goto(authorize_url, wait_until="domcontentloaded", timeout=30000)
1107
+ if expected_email:
1108
+ _log(f"В браузере завершите вход вручную для {expected_email}.")
1109
+ else:
1110
+ _log("В браузере завершите вход вручную.")
1111
+ _log("Можно ввести email, пароль или код подтверждения вручную прямо в открытом окне.")
1112
+ _log("После успешного входа дождусь OAuth callback и сохраню сессию автоматически.")
1113
+
1114
+ _handle_consent_and_wait(page, _log, callback_wait_seconds=timeout_seconds)
1115
+ auth_code = _wait_for_callback(holder, timeout=timeout_seconds)
1116
+ _log("Authorization code получен")
1117
+
1118
+ session_result = _exchange_oauth_code(auth_code, redirect_uri, code_verifier)
1119
+ _bootstrap_chatgpt_session(
1120
+ page,
1121
+ _log,
1122
+ timeout_seconds=min(timeout_seconds, 300),
1123
+ interactive=True,
1124
+ )
1125
+ return page, session_result
1126
+ except Exception:
1127
+ try:
1128
+ _context.close()
1129
+ except Exception:
1130
+ pass
1131
+ _open_contexts[:] = [c for c in _open_contexts if c != _context]
1132
+ raise
1133
+ finally:
1134
+ server.shutdown()
1135
+ server.server_close()
1136
+
1137
+
1138
+ def save_codex_file(folder: Path, session: dict, email: str) -> Path:
1139
+ filename = f"codex-{email}-Team.json"
1140
+ path = folder / filename
1141
+
1142
+ old: dict = {}
1143
+ if path.exists():
1144
+ try:
1145
+ old = json.loads(path.read_text())
1146
+ except Exception:
1147
+ pass
1148
+
1149
+ access = session.get("access_token", "")
1150
+ if not access:
1151
+ raise RuntimeError("Нет access_token — codex-файл не сохранён")
1152
+
1153
+ codex = {
1154
+ "id_token": session.get("id_token") or old.get("id_token", ""),
1155
+ "access_token": access,
1156
+ "refresh_token": session.get("refresh_token") or old.get("refresh_token", ""),
1157
+ "account_id": session.get("account_id") or old.get("account_id", ""),
1158
+ "last_refresh": datetime.now(timezone.utc).isoformat(),
1159
+ "email": email,
1160
+ "type": "codex",
1161
+ "expired": session.get("expired") or old.get("expired", ""),
1162
+ }
1163
+
1164
+ folder.mkdir(parents=True, exist_ok=True)
1165
+ path.write_text(json.dumps(codex, indent=2, ensure_ascii=False))
1166
+
1167
+ from . import PROJECT_ROOT
1168
+ codex_dir = PROJECT_ROOT / "codex"
1169
+ codex_dir.mkdir(parents=True, exist_ok=True)
1170
+ copy_path = codex_dir / filename
1171
+ copy_path.write_text(json.dumps(codex, indent=2, ensure_ascii=False))
1172
+
1173
+ return path
1174
+
1175
+
1176
+ def get_workspaces(page: Page, log: Callable[[str], Any] | None = None) -> list[dict]:
1177
+ _log = log or print
1178
+ try:
1179
+ page.wait_for_selector('button[name="workspace_id"]', timeout=10000)
1180
+ except Exception:
1181
+ _log("Workspace кнопки не найдены")
1182
+ return []
1183
+
1184
+ workspace_info = page.evaluate(
1185
+ """() => {
1186
+ const buttons = document.querySelectorAll('button[name="workspace_id"]');
1187
+ const result = [];
1188
+ buttons.forEach(btn => {
1189
+ const spans = btn.querySelectorAll('span');
1190
+ let name = '';
1191
+ for (const s of spans) {
1192
+ const text = s.textContent.trim();
1193
+ if (text && text.length > 1 && !text.match(/^[A-Z]{1,2}$/)) {
1194
+ name = text;
1195
+ break;
1196
+ }
1197
+ }
1198
+ if (!name) name = btn.textContent.trim();
1199
+ result.push({ workspace_id: btn.value, name: name });
1200
+ });
1201
+ return result;
1202
+ }"""
1203
+ )
1204
+
1205
+ if not workspace_info:
1206
+ _log("Workspace кнопки не найдены")
1207
+ return []
1208
+
1209
+ _log(f"Найдено {len(workspace_info)} workspace(s)")
1210
+ return workspace_info
1211
+
1212
+
1213
+ def select_workspace(page: Page, workspace_id: str, log: Callable[[str], Any] | None = None) -> None:
1214
+ _log = log or print
1215
+ page.locator(f'button[name="workspace_id"][value="{workspace_id}"]').click()
1216
+ _log(f"Выбран workspace: {workspace_id}, ожидаю редирект...")
1217
+ try:
1218
+ page.wait_for_url(lambda url: "chatgpt.com" in url, timeout=30000)
1219
+ except Exception:
1220
+ time.sleep(5)
1221
+
1222
+
1223
+ def browser_register(
1224
+ invite_url: str,
1225
+ email: str,
1226
+ openai_password: str,
1227
+ mail_client: MailProvider,
1228
+ mailbox: Mailbox,
1229
+ profile_dir: Path,
1230
+ log: Callable[[str], Any] | None = None,
1231
+ headless: bool = False,
1232
+ ) -> Page:
1233
+ _log = log or print
1234
+
1235
+ existing_ids: set[str] = set()
1236
+ try:
1237
+ inbox = mail_client.inbox(mailbox)
1238
+ existing_ids = {msg.id for msg in inbox.messages}
1239
+ except MailError as e:
1240
+ _log(f"Почта недоступна, продолжаю без prefetch inbox: {e}")
1241
+
1242
+ page, _context = _launch_page(profile_dir, headless=headless)
1243
+ try:
1244
+ page.goto(invite_url, wait_until="domcontentloaded")
1245
+ _human_delay(1.5, 3.0)
1246
+
1247
+ # Oops fallback — страница ошибки вместо формы
1248
+ if _is_oops_error(page):
1249
+ if not _handle_oops_retry(page, _log):
1250
+ raise RuntimeError("Страница 'Oops' не исчезла после retry")
1251
+ _human_delay(1.5, 3.0)
1252
+
1253
+ if page.locator('[data-testid="signup-button"]').count() > 0:
1254
+ _human_delay(0.5, 1.5)
1255
+ page.locator('[data-testid="signup-button"]').click()
1256
+ _human_delay(1.5, 3.0)
1257
+
1258
+ if "log-in-or-create-account" in page.url and page.locator('a[href="/create-account"]').count() > 0:
1259
+ _human_delay(0.4, 1.0)
1260
+ page.locator('a[href="/create-account"]').click()
1261
+ _human_delay(1.0, 2.0)
1262
+
1263
+ _wait_for_any(page, ['input[type="email"][name="email"]'], timeout=30000)
1264
+ _human_delay(0.5, 1.2)
1265
+ _human_type(page._driver, 'input[type="email"][name="email"]', email)
1266
+ _human_delay(0.3, 0.7)
1267
+ page.locator('button[type="submit"]').click()
1268
+
1269
+ pwd_selector = _wait_for_any(page, ['input[name="new-password"]', 'input[name="password"]'], timeout=30000)
1270
+ _human_delay(0.4, 1.0)
1271
+ _human_type(page._driver, pwd_selector, openai_password)
1272
+ _human_delay(0.3, 0.7)
1273
+ page.locator('button[type="submit"]').click()
1274
+
1275
+ wait_result = _wait_for_any(
1276
+ page,
1277
+ ['input[name="code"]', 'input[name="name"]'],
1278
+ url_contains=["chatgpt.com"],
1279
+ url_excludes=["/auth/"],
1280
+ timeout=60000,
1281
+ )
1282
+
1283
+ if wait_result == 'input[name="code"]':
1284
+ code = poll_for_code(mail_client, mailbox, existing_ids, log=_log)
1285
+ _human_delay(0.3, 0.8)
1286
+ _human_type(page._driver, 'input[name="code"]', code)
1287
+ _human_delay(0.2, 0.5)
1288
+ page.locator('button[name="intent"][value="validate"]').click()
1289
+ wait_result = _wait_for_any(
1290
+ page,
1291
+ ['input[name="name"]'],
1292
+ url_contains=["chatgpt.com"],
1293
+ url_excludes=["/auth/"],
1294
+ timeout=60000,
1295
+ )
1296
+
1297
+ if wait_result == "url":
1298
+ return page
1299
+
1300
+ day, month, year = _random_birthday()
1301
+ name = _random_name()
1302
+
1303
+ if wait_result == 'input[name="name"]' or page.locator('input[name="name"]').count() > 0:
1304
+ # Ждём пока fieldset станет enabled (Sentinel антибот)
1305
+ _wait_fieldset_enabled(page, _log, timeout=30)
1306
+
1307
+ _human_delay(0.6, 1.5)
1308
+ _human_type(page._driver, 'input[name="name"]', name)
1309
+ _log(f"Имя: {name}")
1310
+
1311
+ _human_delay(0.4, 0.9)
1312
+ _log(f"Дата рождения: {day}.{month}.{year}")
1313
+ _fill_birthday(page, day, month, year, _log)
1314
+
1315
+ # Пере-заполняем имя — дата-стратегии могли добавить мусор
1316
+ cur_name = page.locator('input[name="name"]').get_attribute("value") or ""
1317
+ if cur_name != name:
1318
+ _log(f"Имя испорчено: '{cur_name}', исправляю...")
1319
+ page.locator('input[name="name"]').fill(name)
1320
+
1321
+ _human_delay(0.5, 1.2)
1322
+ _submit_about_you_form(page, _log)
1323
+ _log("Форма регистрации отправлена")
1324
+
1325
+ # Ждём редирект с about-you, повторяем submit если застряли
1326
+ deadline = time.time() + 120
1327
+ submit_retried = False
1328
+ while time.time() < deadline:
1329
+ cur_url = page.url
1330
+ # Успех — ушли с auth
1331
+ if "chatgpt.com" in cur_url and "/auth/" not in cur_url:
1332
+ break
1333
+ if page.locator('button[name="workspace_id"]').count() > 0:
1334
+ break
1335
+
1336
+ # Oops — ошибка сервера, нажать Try again
1337
+ if _is_oops_error(page):
1338
+ _log("[oops] Ошибка на about-you, нажимаю Try again...")
1339
+ _handle_oops_retry(page, _log)
1340
+ time.sleep(3)
1341
+ # После retry форма может появиться снова — заполняем
1342
+ if page.locator('input[name="name"]').count() > 0:
1343
+ _wait_fieldset_enabled(page, _log, timeout=15)
1344
+ page.locator('input[name="name"]').fill(name)
1345
+ _fill_birthday(page, day, month, year, _log)
1346
+ page.locator('input[name="name"]').fill(name)
1347
+ _submit_about_you_form(page, _log)
1348
+ _log("Форма повторно заполнена после Oops")
1349
+ continue
1350
+
1351
+ # Застряли на about-you — ждём подольше перед retry
1352
+ if "about-you" in cur_url and not submit_retried:
1353
+ time.sleep(15)
1354
+ _save_debug_html(page, f"about-you-stuck-{email}", _log)
1355
+ diag = page.evaluate(
1356
+ """() => {
1357
+ const btns = [...document.querySelectorAll('button')].map(b => ({
1358
+ text: b.textContent.trim().slice(0, 50),
1359
+ type: b.type, disabled: b.disabled, name: b.name
1360
+ }));
1361
+ const inputs = [...document.querySelectorAll('input, select, textarea')].map(i => ({
1362
+ name: i.name, type: i.type, value: i.value, checked: i.checked
1363
+ }));
1364
+ const errors = [...document.querySelectorAll('[role="alert"], .error, [class*="error"], [class*="Error"]')]
1365
+ .map(e => e.textContent.trim().slice(0, 100));
1366
+ return { btns, inputs, errors, url: location.href };
1367
+ }"""
1368
+ )
1369
+ _log(f"[диагностика about-you] {json.dumps(diag, ensure_ascii=False, default=str)}")
1370
+
1371
+ # Повторная попытка: снять disabled, заполнить, submit
1372
+ try:
1373
+ _wait_fieldset_enabled(page, _log, timeout=15)
1374
+ if page.locator('input[name="name"]').count() > 0:
1375
+ _log("Повторная попытка заполнить форму...")
1376
+ page.locator('input[name="name"]').fill(name)
1377
+ _fill_birthday(page, day, month, year, _log)
1378
+ page.locator('input[name="name"]').fill(name)
1379
+ time.sleep(0.5)
1380
+ _submit_about_you_form(page, _log)
1381
+ _log("Повторный submit")
1382
+ except Exception as retry_err:
1383
+ _log(f"Повторная попытка не удалась: {retry_err}")
1384
+ submit_retried = True
1385
+
1386
+ time.sleep(1)
1387
+ else:
1388
+ raise TimeoutError("Таймаут 120000мс: ни один селектор/URL не сработал")
1389
+
1390
+ return page
1391
+ except Exception:
1392
+ try:
1393
+ _log(f"[диагностика] URL при ошибке: {page.url}")
1394
+ _save_debug_html(page, f"register-error-{email}", _log)
1395
+ except Exception:
1396
+ pass
1397
+ try:
1398
+ _context.close()
1399
+ except Exception:
1400
+ pass
1401
+ _open_contexts[:] = [c for c in _open_contexts if c != _context]
1402
+ raise