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.
- package/.env.example +1 -0
- package/CONTRIBUTING.md +128 -0
- package/README.md +249 -0
- package/app.py +25 -0
- package/backend/__init__.py +3 -0
- package/backend/__main__.py +3 -0
- package/backend/account_store.py +448 -0
- package/backend/chatgpt_workspace_api.py +104 -0
- package/backend/dto.py +106 -0
- package/backend/file_logger.py +82 -0
- package/backend/jobs.py +77 -0
- package/backend/mail/__init__.py +98 -0
- package/backend/mail/base.py +86 -0
- package/backend/mail/boomlify.py +178 -0
- package/backend/mail/imap.py +221 -0
- package/backend/mail/trickads.py +121 -0
- package/backend/openai_web_auth.py +1402 -0
- package/backend/rpc_protocol.py +78 -0
- package/backend/rpc_server.py +233 -0
- package/backend/slot_orchestrator.py +400 -0
- package/backend/ui_facade.py +368 -0
- package/bin/izteamslots.sh +16 -0
- package/package.json +30 -0
- package/requirements.txt +2 -0
- package/scripts/setup.sh +82 -0
- package/ui/package.json +19 -0
- package/ui/src/main.ts +4 -0
- package/ui/src/menus/format.ts +163 -0
- package/ui/src/menus/mainMenus.ts +221 -0
- package/ui/src/menus/types.ts +75 -0
- package/ui/src/screens/MainScreen.ts +1175 -0
- package/ui/src/transport/stdioClient.ts +162 -0
- package/ui/tsconfig.json +13 -0
|
@@ -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
|