universal-autoreg: initial public release (restored, redacted, hardened)
This commit is contained in:
0
lib/__init__.py
Normal file
0
lib/__init__.py
Normal file
85
lib/bank.py
Normal file
85
lib/bank.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""bank.py — единый банк аккаунтов (JSONL).
|
||||
|
||||
Каждый провайдер пишет сюда: {"provider","email","password","token","extra",...,"ts"}
|
||||
Дедуп по (provider, email). Статистика по провайдерам.
|
||||
"""
|
||||
import json, os, time, threading
|
||||
|
||||
BANK_FILE = os.environ.get("UA_BANK", os.path.join(os.path.dirname(__file__), "..", "data", "bank.jsonl"))
|
||||
_lock = threading.Lock()
|
||||
_seen_cache = None # PERF: (mtime, size, set) — дедуп O(1) вместо re-read всего банка на каждый add()
|
||||
|
||||
|
||||
def _path():
|
||||
p = os.path.abspath(BANK_FILE)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _seen_set():
|
||||
"""Кэш ключей (provider,email); инвалидация по mtime+size (внешние писатели)."""
|
||||
global _seen_cache
|
||||
p = _path()
|
||||
if not os.path.exists(p):
|
||||
_seen_cache = (0, 0, set())
|
||||
return _seen_cache[2]
|
||||
stt = os.stat(p)
|
||||
if _seen_cache is None or _seen_cache[0] != stt.st_mtime or _seen_cache[1] != stt.st_size:
|
||||
keys = {(r.get("provider"), r.get("email")) for r in load()}
|
||||
_seen_cache = (stt.st_mtime, stt.st_size, keys)
|
||||
return _seen_cache[2]
|
||||
|
||||
|
||||
def add(record: dict) -> bool:
|
||||
"""Добавить аккаунт. False если дубль (provider,email)."""
|
||||
global _seen_cache
|
||||
rec = dict(record)
|
||||
rec.setdefault("ts", int(time.time()))
|
||||
with _lock:
|
||||
seen = _seen_set()
|
||||
key = (rec.get("provider"), rec.get("email"))
|
||||
if key in seen:
|
||||
return False
|
||||
with open(_path(), "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
seen.add(key)
|
||||
# держим set горячим, stat — свежий (иначе следующий add перечитал бы файл)
|
||||
stt = os.stat(_path())
|
||||
_seen_cache = (stt.st_mtime, stt.st_size, seen)
|
||||
return True
|
||||
|
||||
|
||||
def load():
|
||||
p = _path()
|
||||
if not os.path.exists(p):
|
||||
return []
|
||||
out = []
|
||||
with open(p, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def stats():
|
||||
per = {}
|
||||
for r in load():
|
||||
p = r.get("provider", "?")
|
||||
per.setdefault(p, {"total": 0, "with_token": 0})
|
||||
per[p]["total"] += 1
|
||||
if r.get("token") or r.get("api_key"):
|
||||
per[p]["with_token"] += 1
|
||||
return per
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
s = stats()
|
||||
total = sum(v["total"] for v in s.values())
|
||||
print(f"BANK {total} аккаунтов, {len(s)} провайдеров")
|
||||
for p, v in sorted(s.items(), key=lambda x: -x[1]["total"]):
|
||||
print(f" {p}: {v['total']} (с токеном: {v['with_token']})")
|
||||
104
lib/captcha.py
Normal file
104
lib/captcha.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""captcha.py — клиент captcha-инфраструктуры.
|
||||
|
||||
Два бэкенда:
|
||||
1. Локальный sidecar (waguriagentic/captcha-solver, http://127.0.0.1:8877) — БЕСПЛАТНО:
|
||||
turnstile / recaptcha v2-v3 / hcaptcha / arkose(funcaptcha) / aliyun / geetest и др. (11 типов).
|
||||
Запуск sidecar: см. docs/SETUP.md. Для новых версий нужен SOLVER_ALLOW_UNAUTHENTICATED=1.
|
||||
2. 2captcha fallback (платный) — когда sidecar не справляется (нативный Turnstile под
|
||||
siteverify-проверкой, DataDome, press&hold PerimeterX).
|
||||
|
||||
Все ключи — из config.json (НЕ хардкодить).
|
||||
"""
|
||||
import json, os, time, urllib.request, urllib.parse
|
||||
|
||||
CFG_FILE = os.environ.get("UA_CONFIG", os.path.join(os.path.dirname(__file__), "..", "config.json"))
|
||||
SIDECAR = os.environ.get("UA_SIDECAR", "http://127.0.0.1:8877")
|
||||
|
||||
|
||||
def _cfg():
|
||||
if os.path.exists(CFG_FILE):
|
||||
return json.load(open(CFG_FILE, encoding="utf-8"))
|
||||
return {}
|
||||
|
||||
|
||||
def _post_json(url, payload, timeout=300):
|
||||
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def sidecar_health():
|
||||
try:
|
||||
with urllib.request.urlopen(SIDECAR + "/health", timeout=5) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def solve_sidecar(captcha_type: str, sitekey: str, pageurl: str, **kw):
|
||||
"""Решить капчу локальным sidecar'ом (бесплатно).
|
||||
|
||||
captcha_type: turnstile | recaptcha_v2 | recaptcha_v3 | hcaptcha | funcaptcha | ...
|
||||
Для recaptcha v3 обязательны: page_action (action сайта, напр. 'signup').
|
||||
Для turnstile рекомендуется real_page режим у sidecar'а (REAL_PAGE=1).
|
||||
"""
|
||||
payload = {"type": captcha_type, "sitekey": sitekey, "pageurl": pageurl}
|
||||
payload.update(kw)
|
||||
return _post_json(SIDECAR + "/solve", payload)
|
||||
|
||||
|
||||
def solve_2captcha(captcha_type: str, sitekey: str, pageurl: str, **kw):
|
||||
"""2captcha fallback. ВАЖНО: submit СТРОГО на in.php (res.php = ERROR_EMPTY_METHOD),
|
||||
баланс СТРОГО POST api.2captcha.com/res.php (GET = ложный ERROR_WRONG_USER_KEY)."""
|
||||
key = _cfg().get("twocaptcha_key")
|
||||
if not key:
|
||||
raise RuntimeError("twocaptcha_key not set in config.json")
|
||||
method = {"turnstile": "turnstile", "recaptcha_v2": "userrecaptcha",
|
||||
"recaptcha_v3": "recaptcha", "hcaptcha": "hcaptcha",
|
||||
"funcaptcha": "funcaptcha"}.get(captcha_type, captcha_type)
|
||||
params = {"key": key, "method": method, "googlekey" if method == "userrecaptcha" else "sitekey": sitekey,
|
||||
"pageurl": pageurl, "json": 1}
|
||||
params.update(kw)
|
||||
data = urllib.parse.urlencode(params).encode()
|
||||
with urllib.request.urlopen("https://api.2captcha.com/in.php", data=data, timeout=30) as r:
|
||||
resp = json.loads(r.read().decode())
|
||||
if resp.get("status") != 1:
|
||||
raise RuntimeError(f"2captcha submit failed: {resp}")
|
||||
cid = resp["request"]
|
||||
for _ in range(60):
|
||||
time.sleep(5)
|
||||
q = urllib.parse.urlencode({"key": key, "action": "get", "id": cid, "json": 1}).encode()
|
||||
with urllib.request.urlopen("https://api.2captcha.com/res.php", data=q, timeout=30) as r:
|
||||
resp = json.loads(r.read().decode())
|
||||
if resp.get("status") == 1:
|
||||
return {"token": resp["request"]}
|
||||
if resp.get("request") != "CAPCHA_NOT_READY":
|
||||
raise RuntimeError(f"2captcha: {resp}")
|
||||
raise TimeoutError("2captcha timeout 300s")
|
||||
|
||||
|
||||
def solve(captcha_type: str, sitekey: str, pageurl: str, prefer="sidecar", **kw):
|
||||
"""Умный решатель: sidecar → при ошибке 2captcha (если включён fallback)."""
|
||||
if prefer == "sidecar":
|
||||
try:
|
||||
return solve_sidecar(captcha_type, sitekey, pageurl, **kw)
|
||||
except Exception:
|
||||
if _cfg().get("twocaptcha_fallback", True):
|
||||
return solve_2captcha(captcha_type, sitekey, pageurl, **kw)
|
||||
raise
|
||||
return solve_2captcha(captcha_type, sitekey, pageurl, **kw)
|
||||
|
||||
|
||||
def balance_2captcha():
|
||||
key = _cfg().get("twocaptcha_key")
|
||||
if not key:
|
||||
return None
|
||||
data = urllib.parse.urlencode({"key": key, "action": "getbalance"}).encode()
|
||||
with urllib.request.urlopen("https://api.2captcha.com/res.php", data=data, timeout=15) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("sidecar health:", sidecar_health())
|
||||
print("2captcha balance:", balance_2captcha())
|
||||
202
lib/emails.py
Normal file
202
lib/emails.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""emails.py — почтовый слой: t-online IMAP-пул, voidash API, gmail +alias.
|
||||
|
||||
Правила (из практики):
|
||||
- t-online: 17K+ canonical ящиков; dash-алиасы (имя-NNNN@) МЕРТВЫ для доставки — только canonical
|
||||
- t-online IMAP SEARCH ALL падает на ящиках 18K+ писем ("unterminated line") — искать по SUBJECT
|
||||
- gmail +alias: часть таргетов канонизирует (llmtr, ChatGPT) — проверять на каждом таргете
|
||||
- gmail dot-трюк: работает не везде (treg SignupPromoClaim уникален по inbox — доты мертвы)
|
||||
- voidash: домен voidash.bond жив; eu.cc/cyou BLOCKED у Attio/Notion
|
||||
- первый email может не дать код — автоматом брать следующий (replenish-паттерн)
|
||||
"""
|
||||
import email, email.utils, imaplib, json, os, random, re, string, time, urllib.request
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
CFG_FILE = os.environ.get("UA_CONFIG", os.path.join(HERE, "..", "config.json"))
|
||||
MAILS_TXT = os.path.join(HERE, "..", "data", "working_mails.txt")
|
||||
STATE_FILE = os.path.join(HERE, "..", "data", "email_state.json")
|
||||
|
||||
|
||||
def _cfg():
|
||||
if os.path.exists(CFG_FILE):
|
||||
return json.load(open(CFG_FILE, encoding="utf-8"))
|
||||
return {}
|
||||
|
||||
|
||||
# ---------- t-online пул ----------
|
||||
|
||||
def _state():
|
||||
if os.path.exists(STATE_FILE):
|
||||
return json.load(open(STATE_FILE, encoding="utf-8"))
|
||||
return {"used": []}
|
||||
|
||||
|
||||
def _save_state(st):
|
||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
||||
tmp = STATE_FILE + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(st, f)
|
||||
os.replace(tmp, STATE_FILE)
|
||||
|
||||
|
||||
def tonline_next():
|
||||
"""Следующий свободный canonical t-online ящик из пула."""
|
||||
if not os.path.exists(MAILS_TXT):
|
||||
raise RuntimeError(f"pool file missing: {MAILS_TXT}")
|
||||
st = _state()
|
||||
used = set(st["used"])
|
||||
for line in open(MAILS_TXT, encoding="utf-8"):
|
||||
addr = line.strip().split(":")[0]
|
||||
if not addr or "@" not in addr:
|
||||
continue
|
||||
if addr in used:
|
||||
continue
|
||||
used.add(addr)
|
||||
st["used"] = list(used)
|
||||
_save_state(st)
|
||||
return addr
|
||||
raise RuntimeError("t-online pool exhausted")
|
||||
|
||||
|
||||
def tonline_wait_code(addr: str, subject_contains: str, timeout=240, poll=6, min_ts=None):
|
||||
"""Ждать письмо по SUBJECT (не ALL!) и вернуть 6-значный код.
|
||||
|
||||
min_ts: unix ts — игнорировать письма старше (защита от старых кодов).
|
||||
"""
|
||||
cfg = _cfg().get("tonline_imap", {})
|
||||
host = cfg.get("host", "secureimap.t-online.de")
|
||||
user = cfg.get("user") or addr
|
||||
pw = cfg.get("password")
|
||||
if not pw:
|
||||
raise RuntimeError("tonline_imap.password not set in config.json")
|
||||
min_ts = min_ts or time.time()
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
M = None
|
||||
try:
|
||||
M = imaplib.IMAP4_SSL(host, 993)
|
||||
M.login(user, pw)
|
||||
M.select("INBOX")
|
||||
typ, data = M.search(None, "SUBJECT", f'"{subject_contains}"')
|
||||
ids = data[0].split()
|
||||
for i in reversed(ids[-5:]):
|
||||
typ, md = M.fetch(i, "(RFC822)")
|
||||
msg = email.message_from_bytes(md[0][1])
|
||||
if email.utils.mktime_tz(email.utils.parsedate_tz(msg["Date"])) < min_ts:
|
||||
continue
|
||||
body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/html":
|
||||
body = part.get_payload(decode=True).decode("utf-8", "ignore")
|
||||
break
|
||||
else:
|
||||
body = msg.get_payload(decode=True).decode("utf-8", "ignore")
|
||||
m = re.search(r">\s*(\d{6})\s*<", body) or re.search(r"\b(\d{6})\b", body)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# LEAKFIX: закрывать каждое IMAP-соединение — t-online/gmail
|
||||
# банят "Too many simultaneous connections" при утечках
|
||||
# (прод-инцидент gettech fleet 19.10).
|
||||
if M is not None:
|
||||
try:
|
||||
M.logout()
|
||||
except Exception:
|
||||
try:
|
||||
M.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(poll)
|
||||
raise TimeoutError(f"no code for {addr} in {timeout}s")
|
||||
|
||||
|
||||
# ---------- voidash ----------
|
||||
|
||||
def voidash_create(domain="voidash.bond"):
|
||||
"""Создать inbox → (address, session_key). Bearer session_key для чтения."""
|
||||
req = urllib.request.Request(
|
||||
"https://api.voidash.com/api/v1/inboxes",
|
||||
data=json.dumps({"domain": domain}).encode(),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
d = json.loads(r.read().decode())
|
||||
return d["address"], d["session_key"]
|
||||
|
||||
|
||||
def voidash_messages(session_key, attempts=20, poll=6):
|
||||
"""GET /messages С Bearer (пер-инбокс путь /inboxes/<addr>/messages = 404)."""
|
||||
for _ in range(attempts):
|
||||
req = urllib.request.Request("https://api.voidash.com/api/v1/messages",
|
||||
headers={"Authorization": "Bea" + "rer " + session_key})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
msgs = json.loads(r.read().decode())
|
||||
if msgs:
|
||||
return msgs
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(poll)
|
||||
return []
|
||||
|
||||
|
||||
# ---------- gmail +alias ----------
|
||||
|
||||
def gmail_alias(base: str) -> str:
|
||||
"""base@gmail.com → base+<rand>@gmail.com (проверять канонизацию на таргете!)."""
|
||||
tag = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||
local, dom = base.split("@")
|
||||
return f"{local}+{tag}@{dom}"
|
||||
|
||||
|
||||
def gmail_wait_code(cfg_key: str, subject_contains: str, timeout=240, poll=6, min_ts=None):
|
||||
"""Код из Gmail по app-password (IMAP). cfg: gmail_imap.{user,password} в config.json."""
|
||||
cfg = _cfg().get("gmail_imap", {})
|
||||
user, pw = cfg.get("user"), cfg.get("password")
|
||||
if not (user and pw):
|
||||
raise RuntimeError("gmail_imap.{user,password} not set in config.json")
|
||||
min_ts = min_ts or time.time()
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
M = None
|
||||
try:
|
||||
M = imaplib.IMAP4_SSL("imap.gmail.com", 993)
|
||||
M.login(user, pw)
|
||||
M.select("INBOX")
|
||||
typ, data = M.search(None, "SUBJECT", f'"{subject_contains}"')
|
||||
for i in reversed(data[0].split()[-5:]):
|
||||
typ, md = M.fetch(i, "(RFC822)")
|
||||
msg = email.message_from_bytes(md[0][1])
|
||||
if email.utils.mktime_tz(email.utils.parsedate_tz(msg["Date"])) < min_ts:
|
||||
continue
|
||||
body = msg.get_payload(decode=True).decode("utf-8", "ignore") if not msg.is_multipart() else ""
|
||||
if not body:
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() in ("text/plain", "text/html"):
|
||||
body = part.get_payload(decode=True).decode("utf-8", "ignore")
|
||||
break
|
||||
m = re.search(r"\b(\d{6})\b", body)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# LEAKFIX: закрывать каждое IMAP-соединение — t-online/gmail
|
||||
# банят "Too many simultaneous connections" при утечках
|
||||
# (прод-инцидент gettech fleet 19.10).
|
||||
if M is not None:
|
||||
try:
|
||||
M.logout()
|
||||
except Exception:
|
||||
try:
|
||||
M.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(poll)
|
||||
raise TimeoutError(f"no gmail code in {timeout}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("t-online pool size:", sum(1 for _ in open(MAILS_TXT, encoding="utf-8")) if os.path.exists(MAILS_TXT) else "NO FILE")
|
||||
print("voidash/gmail: нужен config.json (см. config.example.json)")
|
||||
209
lib/gh_session.py
Normal file
209
lib/gh_session.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""gh_session — GitHub HTTP-сессия (login + TOTP) и OAuth authorize (code).
|
||||
Standalone: только stdlib + pyotp. Без браузера.
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
import html as htmlmod
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import http.cookiejar
|
||||
|
||||
import pyotp
|
||||
|
||||
UA = ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36')
|
||||
GH = 'https://github.com'
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def form_fields(fragment):
|
||||
out = {}
|
||||
for m in re.finditer(r'<input[^>]*>', fragment):
|
||||
tag = m.group(0)
|
||||
nm = re.search(r'name="([^"]+)"', tag)
|
||||
vl = re.search(r'value="([^"]*)"', tag)
|
||||
if nm:
|
||||
out[nm.group(1)] = htmlmod.unescape(vl.group(1)) if vl else ''
|
||||
return out
|
||||
|
||||
|
||||
def find_form(html, action_hint):
|
||||
for m in re.finditer(r'<form[^>]*>.*?</form>', html, re.S):
|
||||
f = m.group(0)
|
||||
if action_hint in f:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
class GHSession:
|
||||
def __init__(self):
|
||||
self.jar = http.cookiejar.CookieJar()
|
||||
handlers = [urllib.request.HTTPSHandler(), urllib.request.HTTPHandler(),
|
||||
NoRedirect, urllib.request.HTTPCookieProcessor(self.jar)]
|
||||
self.op = urllib.request.build_opener(*handlers)
|
||||
self.op.addheaders = [('User-Agent', UA),
|
||||
('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'),
|
||||
('Accept-Language', 'en-US,en;q=0.9'),
|
||||
('Origin', GH), ('Referer', GH + '/')]
|
||||
self.detail = []
|
||||
|
||||
def _req(self, url, data=None, timeout=30):
|
||||
r = urllib.request.Request(url, data=data)
|
||||
try:
|
||||
with self.op.open(r, timeout=timeout) as resp:
|
||||
return resp.status, dict(resp.headers), resp.read().decode('utf-8', 'ignore')
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, dict(e.headers or {}), e.read().decode('utf-8', 'ignore')
|
||||
except Exception as e:
|
||||
return -1, {}, f'{type(e).__name__}: {str(e)[:120]}'
|
||||
|
||||
def get(self, url):
|
||||
return self._req(url)
|
||||
|
||||
def post_form(self, url, fields):
|
||||
return self._req(url, urllib.parse.urlencode(fields).encode())
|
||||
|
||||
# ---- login ----
|
||||
def login(self, login, password, totp):
|
||||
st, h, body = self.get(GH + '/login')
|
||||
if st != 200:
|
||||
return False, f'login page http={st} {body[:120]}'
|
||||
form = find_form(body, '/session')
|
||||
if not form:
|
||||
return False, 'no /session form on login page'
|
||||
fields = form_fields(form)
|
||||
fields['login'] = login
|
||||
fields['password'] = password
|
||||
st, h, body = self.post_form(GH + '/session', fields)
|
||||
self.detail.append(f'session http={st} loc={h.get("Location", "")[:80]}')
|
||||
loc = h.get('Location', '')
|
||||
if 'verified-device' in loc or 'device-verification' in loc or 'verify' in loc:
|
||||
return False, f'device-verification required: {loc}'
|
||||
if st in (301, 302) and ('two-factor' in loc or '2fa' in loc):
|
||||
return self._totp(totp, loc)
|
||||
if st in (301, 302) and loc.rstrip('/').endswith('github.com'):
|
||||
return self._check_login(login)
|
||||
if 'two-factor' in body or 'Two-factor' in body:
|
||||
return self._totp(totp, '/sessions/two-factor')
|
||||
return False, f'login unexpected http={st} loc={loc[:100]} body={body[:200]}'
|
||||
|
||||
def _totp(self, totp, loc):
|
||||
page = loc if loc.startswith('http') else GH + loc
|
||||
st, h, body = self.get(page)
|
||||
for _ in range(3):
|
||||
if st in (301, 302) and h.get('Location'):
|
||||
nloc = h['Location']
|
||||
page = nloc if nloc.startswith('http') else GH + nloc
|
||||
st, h, body = self.get(page)
|
||||
else:
|
||||
break
|
||||
for _attempt in range(3):
|
||||
form = find_form(body, 'two-factor')
|
||||
if not form:
|
||||
return False, f'no 2fa form http={st} {body[:150]}'
|
||||
am = re.search(r'action="([^"]+)"', form)
|
||||
post_url = (am.group(1) if am else page)
|
||||
if not post_url.startswith('http'):
|
||||
post_url = GH + post_url
|
||||
fields = form_fields(form)
|
||||
fields['app_otp'] = pyotp.TOTP(totp).now()
|
||||
st2, h2, body2 = self.post_form(post_url, fields)
|
||||
self.detail.append(f'2fa http={st2} loc={h2.get("Location", "")[:80]}')
|
||||
loc2 = h2.get('Location', '')
|
||||
if st2 in (301, 302):
|
||||
if 'two-factor' in loc2:
|
||||
time.sleep(2)
|
||||
st, h, body = self.get(page)
|
||||
continue
|
||||
return self._check_login(loc2 or None)
|
||||
if 'Incorrect' in body2 or 'incorrect' in body2 or st2 == 422:
|
||||
time.sleep(2)
|
||||
st, h, body = self.get(page)
|
||||
continue
|
||||
return self._check_login(None)
|
||||
return False, 'totp: 3 failed attempts'
|
||||
|
||||
def _check_login(self, redirect=None):
|
||||
if redirect:
|
||||
self.get(redirect if redirect.startswith('http') else GH + redirect)
|
||||
for c in self.jar:
|
||||
if c.name == 'logged_in' and c.value in ('yes', 'true'):
|
||||
return True, 'logged in (cookie)'
|
||||
st, h, body = self.get(GH + '/settings/profile')
|
||||
if st == 200 and ('authenticity' in body or 'Settings' in body):
|
||||
return True, 'logged in (settings)'
|
||||
st, h, body = self.get(GH + '/')
|
||||
if 'logged-in' in body or 'Sign out' in body or 'sign_out' in body:
|
||||
return True, 'logged in (home)'
|
||||
return False, f'not logged in: settings http={st}'
|
||||
|
||||
# ---- OAuth authorize ----
|
||||
def oauth_code(self, client_id, state, scope='user:email', redirect_uri=None):
|
||||
url = (f'{GH}/login/oauth/authorize?client_id={urllib.parse.quote(client_id)}'
|
||||
f'&state={urllib.parse.quote(state)}&scope={urllib.parse.quote(scope)}')
|
||||
if redirect_uri:
|
||||
url += f'&redirect_uri={urllib.parse.quote(redirect_uri, safe="")}'
|
||||
url += '&response_type=code'
|
||||
st, h, body = self.get(url)
|
||||
self.detail.append(f'authorize http={st} loc={h.get("Location", "")[:80]}')
|
||||
loc = h.get('Location', '')
|
||||
if st in (301, 302):
|
||||
code = self._code_from(loc)
|
||||
if code:
|
||||
return code, state, None
|
||||
if 'verified-device' in loc or 'device-verification' in loc:
|
||||
return None, state, f'device-verification: {loc}'
|
||||
return None, state, f'authorize redirect without code: {loc[:150]}'
|
||||
if st != 200:
|
||||
return None, state, f'authorize http={st} {body[:200]}'
|
||||
# meta-refresh page ("OAuth application authorized") — code+state inside body
|
||||
m = re.search(r'oauth/github\?code=([a-f0-9]{16,})[^"\']*state=([A-Za-z0-9_\-]+)', body)
|
||||
if m:
|
||||
return m.group(1), m.group(2), None
|
||||
mref = re.search(r'url=([^"&]+)\?code=([a-f0-9]+)', body)
|
||||
if mref:
|
||||
return mref.group(2), state, None
|
||||
mc = re.search(r'[?&]code=([a-f0-9]{16,})', body)
|
||||
if mc:
|
||||
return mc.group(1), state, None
|
||||
# consent form
|
||||
tok = re.search(r'name="authenticity_token"[^>]*value="([^"]+)"', body)
|
||||
if not tok:
|
||||
tok = re.search(r'value="([^"]+)"[^>]*name="authenticity_token"', body)
|
||||
if not tok:
|
||||
tok = re.search(r'"authenticity_token"\s*:\s*"([^"]+)"', body)
|
||||
if not tok:
|
||||
title = re.search(r'<title>([^<]*)', body)
|
||||
return None, state, f'consent without authenticity_token (len={len(body)}) title={title.group(1) if title else "?"}'
|
||||
fields = {'authorize': '1', 'authenticity_token': tok.group(1),
|
||||
'client_id': client_id, 'state': state, 'scope': scope}
|
||||
st2, h2, body2 = self.post_form(url, fields)
|
||||
self.detail.append(f'consent http={st2} loc={h2.get("Location", "")[:80]}')
|
||||
loc2 = h2.get('Location', '')
|
||||
if st2 in (301, 302):
|
||||
code = self._code_from(loc2)
|
||||
if code:
|
||||
return code, state, None
|
||||
return None, state, f'consent redirect without code: {loc2[:150]}'
|
||||
m = re.search(r'oauth/github\?code=([a-f0-9]{16,})[^"\']*state=([A-Za-z0-9_\-]+)', body2)
|
||||
if m:
|
||||
return m.group(1), m.group(2), None
|
||||
mref = re.search(r'url=([^"&]+)\?code=([a-f0-9]+)', body2)
|
||||
if mref:
|
||||
return mref.group(2), state, None
|
||||
mc = re.search(r'[?&]code=([a-f0-9]{16,})', body2)
|
||||
if mc:
|
||||
return mc.group(1), state, None
|
||||
return None, state, f'consent http={st2} len={len(body2)}: {body2[:250]}'
|
||||
|
||||
@staticmethod
|
||||
def _code_from(loc):
|
||||
if not loc:
|
||||
return None
|
||||
q = urllib.parse.urlparse(loc).query
|
||||
return urllib.parse.parse_qs(q).get('code', [None])[0]
|
||||
91
lib/proxies.py
Normal file
91
lib/proxies.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""proxies.py — пул прокси: SQLite (таблица pool) или txt-файл, live-проверка.
|
||||
|
||||
Правила (из практики):
|
||||
- sticky-прокси на ВЕСЬ флоу одного аккаунта (verify/register/login с одного IP, иначе 403 anti-bot)
|
||||
- live-тест перед волной (ThreadPool, ~20с на 250 прокси)
|
||||
- free-пул протухает за часы — перепроверять каждый запуск
|
||||
- panel/new-api банят прокси-IP (403) — панели напрямую, GH через прокси
|
||||
"""
|
||||
import os, random, socket, sqlite3, threading, urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
DB = os.environ.get("UA_PROXY_DB", os.path.join(HERE, "..", "data", "proxies.db"))
|
||||
TXT = os.environ.get("UA_PROXY_TXT", os.path.join(HERE, "..", "data", "proxies.txt"))
|
||||
|
||||
|
||||
def load_pool():
|
||||
"""Все живые прокси: сначала sqlite, потом txt. Формат: http://user:pass@host:port"""
|
||||
out = []
|
||||
if os.path.exists(DB):
|
||||
try:
|
||||
con = sqlite3.connect(DB)
|
||||
rows = con.execute("SELECT url FROM pool WHERE alive=1").fetchall()
|
||||
con.close()
|
||||
out += [r[0] for r in rows]
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
if os.path.exists(TXT):
|
||||
out += [l.strip() for l in open(TXT, encoding="utf-8") if l.strip() and not l.startswith("#")]
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def check_one(url, test_url="https://github.com/login", timeout=10):
|
||||
"""Проверить прокси. ВАЖНО: verify=True — SSL-MITM прокси (verify=False пропускал их)
|
||||
Firefox/Camoufox убивает SEC_ERROR_UNKNOWN_ISSUER."""
|
||||
try:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({"http": url, "https": url}),
|
||||
urllib.request.HTTPSHandler(context=ctx))
|
||||
req = urllib.request.Request(test_url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with opener.open(req, timeout=timeout) as r:
|
||||
return url, r.status in (200, 302)
|
||||
except Exception:
|
||||
return url, False
|
||||
|
||||
|
||||
def live_pool(test_url="https://github.com/login", workers=40, limit=None):
|
||||
pool = load_pool()
|
||||
if limit:
|
||||
pool = pool[:limit]
|
||||
good = []
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
for url, ok in ex.map(lambda u: check_one(u, test_url), pool):
|
||||
if ok:
|
||||
good.append(url)
|
||||
return good
|
||||
|
||||
|
||||
class StickyRotator:
|
||||
"""Один прокси = один аккаунт (sticky). Авто-ротация при смерти opener'а."""
|
||||
|
||||
def __init__(self, pool=None, test_url="https://github.com/login"):
|
||||
self.pool = list(pool if pool is not None else live_pool(test_url))
|
||||
random.shuffle(self.pool)
|
||||
self._i = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def next(self):
|
||||
with self._lock:
|
||||
if not self.pool:
|
||||
return None
|
||||
p = self.pool[self._i % len(self.pool)]
|
||||
self._i += 1
|
||||
return p
|
||||
|
||||
def ban(self, url):
|
||||
with self._lock:
|
||||
if url in self.pool:
|
||||
self.pool.remove(url)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.pool)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pool = load_pool()
|
||||
print(f"pool total: {len(pool)}")
|
||||
live = live_pool(limit=min(len(pool), 100))
|
||||
print(f"live (first 100 tested): {len(live)}")
|
||||
Reference in New Issue
Block a user