|
@@ -2,7 +2,9 @@
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import json
|
|
import json
|
|
|
|
|
+import math
|
|
|
import os
|
|
import os
|
|
|
|
|
+import random
|
|
|
import re
|
|
import re
|
|
|
import time
|
|
import time
|
|
|
import traceback
|
|
import traceback
|
|
@@ -38,6 +40,7 @@ POST_SMS_PAYPAL_ACTION_TEXTS = (
|
|
|
POST_SMS_STRIPE_ACTION_TEXTS = ("Subscribe", "Pay", "Continue", "订阅", "訂閱")
|
|
POST_SMS_STRIPE_ACTION_TEXTS = ("Subscribe", "Pay", "Continue", "订阅", "訂閱")
|
|
|
|
|
|
|
|
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
|
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
|
|
|
|
+DATADOME_COOKIE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "datadome_cookie.json")
|
|
|
|
|
|
|
|
_PAYMENT_COMPLETE_RE = re.compile(
|
|
_PAYMENT_COMPLETE_RE = re.compile(
|
|
|
r"(payment\s+(complete|successful)|purchase\s+complete|thanks?\s+for\s+(your\s+)?(payment|purchase|subscribing)|"
|
|
r"(payment\s+(complete|successful)|purchase\s+complete|thanks?\s+for\s+(your\s+)?(payment|purchase|subscribing)|"
|
|
@@ -87,25 +90,117 @@ class PayPalPaymentFailed(Exception):
|
|
|
"""PayPal/Stripe 明确返回"支付失败",触发清缓存+重开长链重试。"""
|
|
"""PayPal/Stripe 明确返回"支付失败",触发清缓存+重开长链重试。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class StripeNonFreeDetected(Exception):
|
|
|
|
|
+ """Stripe 页面检测到金额非 $0,保留用于未来扩展。"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
MAX_PAYPAL_RETRIES = 3
|
|
MAX_PAYPAL_RETRIES = 3
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _detect_payment_failure(page) -> str:
|
|
|
|
|
- """检测明确的"支付失败"信号。返回非空字符串表示已失败。"""
|
|
|
|
|
- url = getattr(page, "url", "") or ""
|
|
|
|
|
- if _PAYMENT_FAILED_URL_RE.search(url):
|
|
|
|
|
- return f"URL 标记 redirect_status=failed: {url}"
|
|
|
|
|
- snapshot = _payment_page_snapshot(page)
|
|
|
|
|
- text = snapshot.get("text", "") or ""
|
|
|
|
|
- alerts = snapshot.get("alerts", "") or ""
|
|
|
|
|
- blob = f"{text}\n{alerts}"
|
|
|
|
|
- m = _PAYMENT_FAILED_TEXT_RE.search(blob)
|
|
|
|
|
- if m:
|
|
|
|
|
- # 截上下文方便日志
|
|
|
|
|
- start = max(0, (m.start() if hasattr(m, "start") else 0) - 60)
|
|
|
|
|
- end = min(len(blob), (m.end() if hasattr(m, "end") else 0) + 200)
|
|
|
|
|
- return f"页面失败提示:{blob[start:end].strip()[:400]}"
|
|
|
|
|
- return ""
|
|
|
|
|
|
|
+def detect_stripe_amount(page, log: Callable[[str], None] = print) -> str:
|
|
|
|
|
+ """在 Stripe checkout 页面检测最终付款金额,返回金额文本(如 "$0.00"、"$20.00")。
|
|
|
|
|
+ 空字符串表示无法检测到。
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ amount = page.evaluate(r"""() => {
|
|
|
|
|
+ // Stripe checkout 页面的金额通常在多个位置出现
|
|
|
|
|
+ const selectors = [
|
|
|
|
|
+ '[data-testid="hosted-payment-submit-button"]',
|
|
|
|
|
+ '.SubmitButton-TextContainer',
|
|
|
|
|
+ '[class*="OrderTotal"]',
|
|
|
|
|
+ '[class*="total" i]',
|
|
|
|
|
+ '[data-testid*="total" i]',
|
|
|
|
|
+ '[class*="amount" i]',
|
|
|
|
|
+ '[data-testid*="amount" i]',
|
|
|
|
|
+ ];
|
|
|
|
|
+ for (const sel of selectors) {
|
|
|
|
|
+ const el = document.querySelector(sel);
|
|
|
|
|
+ if (!el) continue;
|
|
|
|
|
+ const text = (el.innerText || el.textContent || '').trim();
|
|
|
|
|
+ // 匹配 $0.00 / $0 / ¥0 / €0 等货币金额
|
|
|
|
|
+ const m = text.match(/[\$€£¥]\s*[\d,.]+/);
|
|
|
|
|
+ if (m) return m[0];
|
|
|
|
|
+ }
|
|
|
|
|
+ // 兜底:扫全页面找金额
|
|
|
|
|
+ const bodyText = (document.body && document.body.innerText || '');
|
|
|
|
|
+ // 找 "Total" / "Amount due" / "Due today" 后面跟的金额
|
|
|
|
|
+ const patterns = [
|
|
|
|
|
+ /(?:total|amount\s+due|due\s+today|order\s+total)[:\s]*?[\$€£¥]\s*([\d,.]+)/i,
|
|
|
|
|
+ /(?:pay|subscribe)[:\s]*?[\$€£¥]\s*([\d,.]+)/i,
|
|
|
|
|
+ ];
|
|
|
|
|
+ for (const re of patterns) {
|
|
|
|
|
+ const m = bodyText.match(re);
|
|
|
|
|
+ if (m) return '$' + m[1];
|
|
|
|
|
+ }
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }""")
|
|
|
|
|
+ if amount:
|
|
|
|
|
+ log(f"[stripe] 检测到付款金额: {amount}")
|
|
|
|
|
+ return amount or ""
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ log(f"[stripe] 金额检测失败: {exc!r}")
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def is_zero_amount(amount_str: str) -> bool:
|
|
|
|
|
+ """判断金额字符串是否为 0($0.00 / $0 / ¥0.00 等)。"""
|
|
|
|
|
+ if not amount_str:
|
|
|
|
|
+ return False
|
|
|
|
|
+ import re
|
|
|
|
|
+ m = re.search(r'[\d,.]+', amount_str)
|
|
|
|
|
+ if not m:
|
|
|
|
|
+ return False
|
|
|
|
|
+ num_str = m.group().replace(',', '')
|
|
|
|
|
+ try:
|
|
|
|
|
+ return float(num_str) == 0.0
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def decide_paypal_flow_for_amount(amount_str: str, *, trial_eligible: bool | None) -> dict:
|
|
|
|
|
+ """基于 Stripe 金额和试用资格,决定是否继续进入 PayPal 付款。"""
|
|
|
|
|
+ if amount_str and is_zero_amount(amount_str):
|
|
|
|
|
+ return {
|
|
|
|
|
+ "mode": "free_trial",
|
|
|
|
|
+ "continue_payment": True,
|
|
|
|
|
+ "is_free_trial": True,
|
|
|
|
|
+ }
|
|
|
|
|
+ if amount_str:
|
|
|
|
|
+ if trial_eligible is True:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "mode": "paid_retry",
|
|
|
|
|
+ "continue_payment": True,
|
|
|
|
|
+ "is_free_trial": False,
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ "mode": "manual_payment_required",
|
|
|
|
|
+ "continue_payment": False,
|
|
|
|
|
+ "is_free_trial": False,
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ "mode": "unknown",
|
|
|
|
|
+ "continue_payment": True,
|
|
|
|
|
+ "is_free_trial": False,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _set_trial_eligibility(ctx: "RunContext", eligible: bool):
|
|
|
|
|
+ prev = getattr(ctx, "_trial_eligible", None)
|
|
|
|
|
+ if prev is True and not eligible:
|
|
|
|
|
+ ctx.log("[stripe] 已认定支持试用,忽略后续不支持试用的覆盖结果")
|
|
|
|
|
+ return
|
|
|
|
|
+ if prev is eligible:
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ ctx._trial_eligible = eligible
|
|
|
|
|
+ ctx.log(f"[stripe] 试用资格已判定为: {'支持试用' if eligible else '不支持试用'}")
|
|
|
|
|
+
|
|
|
|
|
+ hook = getattr(ctx, "_on_trial_eligibility_detected", None)
|
|
|
|
|
+ if callable(hook):
|
|
|
|
|
+ try:
|
|
|
|
|
+ hook(eligible)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[stripe] 持久化试用资格失败: {exc!r}")
|
|
|
|
|
|
|
|
|
|
|
|
|
def _replace_browser_context(ctx, page):
|
|
def _replace_browser_context(ctx, page):
|
|
@@ -136,11 +231,15 @@ def _replace_browser_context(ctx, page):
|
|
|
# 上层调用者拿不到新 page;为不破坏接口,这里就让它在下次 goto 时报错走兜底
|
|
# 上层调用者拿不到新 page;为不破坏接口,这里就让它在下次 goto 时报错走兜底
|
|
|
# 兜底:用 _clear_browser_state 当作降级
|
|
# 兜底:用 _clear_browser_state 当作降级
|
|
|
try:
|
|
try:
|
|
|
|
|
+ from geo_fingerprint import detect_paypal_geo_fingerprint
|
|
|
|
|
+ geo = detect_paypal_geo_fingerprint(getattr(ctx, "paypal_proxy", ""), log=ctx.log)
|
|
|
new_ctx = browser.new_context(
|
|
new_ctx = browser.new_context(
|
|
|
- locale="en-US",
|
|
|
|
|
- timezone_id="America/New_York",
|
|
|
|
|
|
|
+ locale=geo.locale,
|
|
|
|
|
+ timezone_id=geo.timezone_id,
|
|
|
viewport={"width": 1280, "height": 900},
|
|
viewport={"width": 1280, "height": 900},
|
|
|
)
|
|
)
|
|
|
|
|
+ # 重建 context 后也注入 datadome cookie
|
|
|
|
|
+ _inject_datadome_cookie_at_startup(ctx, new_ctx)
|
|
|
new_page = new_ctx.new_page()
|
|
new_page = new_ctx.new_page()
|
|
|
new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
|
|
new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
|
|
|
new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
|
|
new_page.on("pageerror", lambda e: ctx.log(f"[browser-pageerror] {e}"))
|
|
@@ -155,14 +254,31 @@ def _replace_browser_context(ctx, page):
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_browser_state(ctx, page):
|
|
def _clear_browser_state(ctx, page):
|
|
|
- """清掉当前 context 的 cookies / localStorage / sessionStorage / IndexedDB。"""
|
|
|
|
|
|
|
+ """清掉当前 context 的 cookies / localStorage / sessionStorage / IndexedDB。
|
|
|
|
|
+ 保留 datadome cookie 以便下次复用。
|
|
|
|
|
+ """
|
|
|
ctx.log("[playwright] 清理浏览器缓存与 cookies(支付失败重试用)")
|
|
ctx.log("[playwright] 清理浏览器缓存与 cookies(支付失败重试用)")
|
|
|
|
|
+ # 先提取 datadome cookie,清除后恢复
|
|
|
|
|
+ saved_dd = []
|
|
|
|
|
+ try:
|
|
|
|
|
+ bctx = page.context
|
|
|
|
|
+ all_cookies = bctx.cookies()
|
|
|
|
|
+ saved_dd = [c for c in all_cookies if "datadome" in c.get("name", "").lower()]
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
try:
|
|
try:
|
|
|
bctx = page.context
|
|
bctx = page.context
|
|
|
bctx.clear_cookies()
|
|
bctx.clear_cookies()
|
|
|
ctx.log("[playwright] context.clear_cookies() 完成")
|
|
ctx.log("[playwright] context.clear_cookies() 完成")
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
ctx.log(f"[playwright] clear_cookies 失败: {exc!r}")
|
|
ctx.log(f"[playwright] clear_cookies 失败: {exc!r}")
|
|
|
|
|
+ # 恢复 datadome cookie
|
|
|
|
|
+ if saved_dd:
|
|
|
|
|
+ try:
|
|
|
|
|
+ page.context.add_cookies(saved_dd)
|
|
|
|
|
+ ctx.log(f"[playwright] 恢复了 {len(saved_dd)} 个 datadome cookie")
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[playwright] 恢复 datadome cookie 失败: {exc!r}")
|
|
|
try:
|
|
try:
|
|
|
bctx = page.context
|
|
bctx = page.context
|
|
|
# 在 paypal/stripe/openai/chatgpt 各域都跑一遍清理
|
|
# 在 paypal/stripe/openai/chatgpt 各域都跑一遍清理
|
|
@@ -222,6 +338,8 @@ class RunContext:
|
|
|
phone_e164: str = DEFAULT_PHONE_E164
|
|
phone_e164: str = DEFAULT_PHONE_E164
|
|
|
sms_api_url: str = DEFAULT_SMS_API_URL
|
|
sms_api_url: str = DEFAULT_SMS_API_URL
|
|
|
paypal_proxy: str = "" # PayPal 阶段单独代理(http://user:pass@host:port),空表示不走代理
|
|
paypal_proxy: str = "" # PayPal 阶段单独代理(http://user:pass@host:port),空表示不走代理
|
|
|
|
|
+ long_link_mode: str = "payurl" # "payurl" 或 "local"
|
|
|
|
|
+ long_link_proxy: str = "" # local 模式的代理
|
|
|
card: dict = field(default_factory=dict)
|
|
card: dict = field(default_factory=dict)
|
|
|
address: dict = field(default_factory=dict)
|
|
address: dict = field(default_factory=dict)
|
|
|
run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
|
|
run_id: str = field(default_factory=lambda: time.strftime("%Y%m%d-%H%M%S"))
|
|
@@ -421,6 +539,88 @@ def generate_long_link_payurl(ctx: RunContext) -> str:
|
|
|
raise RuntimeError(f"payurl.ark2.cn 长链获取连续失败 {max_attempts} 次:{last_err}")
|
|
raise RuntimeError(f"payurl.ark2.cn 长链获取连续失败 {max_attempts} 次:{last_err}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def generate_long_link_local(ctx: RunContext, proxy: str = "") -> str:
|
|
|
|
|
+ """本地直连 chatgpt.com/backend-api/payments/checkout 生成长链,不走第三方中转。"""
|
|
|
|
|
+ ctx.log("[longlink-local] === 本地直连 ChatGPT checkout API ===")
|
|
|
|
|
+ ctx.log(f"[longlink-local] plan={ctx.plan} country={ctx.country} currency={ctx.currency} use_promo={ctx.use_promo}")
|
|
|
|
|
+ payload = _checkout_payload(ctx.plan, ctx.country, ctx.currency, ctx.use_promo)
|
|
|
|
|
+ headers = _request_headers(ctx.token)
|
|
|
|
|
+ ctx.log(f"[longlink-local] payload={json.dumps(payload, ensure_ascii=False)}")
|
|
|
|
|
+
|
|
|
|
|
+ proxy_url = (proxy or "").strip()
|
|
|
|
|
+ if proxy_url and "://" not in proxy_url:
|
|
|
|
|
+ proxy_url = "http://" + proxy_url
|
|
|
|
|
+ if proxy_url:
|
|
|
|
|
+ masked = proxy_url.split("@")[-1] if "@" in proxy_url else proxy_url
|
|
|
|
|
+ ctx.log(f"[longlink-local] 使用代理: {masked}")
|
|
|
|
|
+
|
|
|
|
|
+ max_attempts = 5
|
|
|
|
|
+ last_err = ""
|
|
|
|
|
+ for attempt in range(1, max_attempts + 1):
|
|
|
|
|
+ ctx.log(f"[longlink-local] 第 {attempt}/{max_attempts} 次请求 {CHECKOUT_URL}")
|
|
|
|
|
+ started = time.time()
|
|
|
|
|
+ try:
|
|
|
|
|
+ if curl_requests is not None:
|
|
|
|
|
+ proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
|
|
|
|
|
+ r = curl_requests.post(
|
|
|
|
|
+ CHECKOUT_URL,
|
|
|
|
|
+ json=payload,
|
|
|
|
|
+ headers=headers,
|
|
|
|
|
+ impersonate="chrome136",
|
|
|
|
|
+ proxies=proxies,
|
|
|
|
|
+ timeout=30,
|
|
|
|
|
+ )
|
|
|
|
|
+ text = r.text
|
|
|
|
|
+ status = r.status_code
|
|
|
|
|
+ else:
|
|
|
|
|
+ import urllib.request
|
|
|
|
|
+ data = json.dumps(payload).encode("utf-8")
|
|
|
|
|
+ req = urllib.request.Request(CHECKOUT_URL, data=data, method="POST")
|
|
|
|
|
+ for k, v in headers.items():
|
|
|
|
|
+ req.add_header(k, v)
|
|
|
|
|
+ opener = urllib.request.build_opener()
|
|
|
|
|
+ if proxy_url:
|
|
|
|
|
+ proxy_handler = urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url})
|
|
|
|
|
+ opener = urllib.request.build_opener(proxy_handler)
|
|
|
|
|
+ with opener.open(req, timeout=30) as resp:
|
|
|
|
|
+ text = resp.read().decode("utf-8", errors="replace")
|
|
|
|
|
+ status = resp.status
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ last_err = repr(exc)
|
|
|
|
|
+ ctx.log(f"[longlink-local] 第 {attempt} 次异常 ({int((time.time()-started)*1000)}ms): {last_err}")
|
|
|
|
|
+ time.sleep(1.5)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ elapsed = int((time.time() - started) * 1000)
|
|
|
|
|
+ ctx.log(f"[longlink-local] HTTP {status} 耗时 {elapsed}ms 长度={len(text)} 预览={text[:300]}")
|
|
|
|
|
+
|
|
|
|
|
+ if status >= 400:
|
|
|
|
|
+ last_err = f"HTTP {status}: {text[:300]}"
|
|
|
|
|
+ time.sleep(1.5)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ result = json.loads(text or "{}")
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ last_err = f"非 JSON: {exc!r}"
|
|
|
|
|
+ time.sleep(1.5)
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ link = result.get("url") or result.get("stripe_hosted_url") or result.get("checkout_url")
|
|
|
|
|
+ session_id = result.get("checkout_session_id")
|
|
|
|
|
+ processor = result.get("processor_entity")
|
|
|
|
|
+ if not link and session_id and processor:
|
|
|
|
|
+ link = f"https://chatgpt.com/checkout/{processor}/{session_id}"
|
|
|
|
|
+ ctx.log(f"[longlink-local] 无直接 url 字段,由 session_id 构造: {link}")
|
|
|
|
|
+ if link:
|
|
|
|
|
+ ctx.log(f"[longlink-local] 成功 long_link={link} session_id={session_id}")
|
|
|
|
|
+ return link
|
|
|
|
|
+ last_err = f"响应缺 url 字段: {text[:300]}"
|
|
|
|
|
+ time.sleep(1.5)
|
|
|
|
|
+
|
|
|
|
|
+ raise RuntimeError(f"本地长链获取连续失败 {max_attempts} 次:{last_err}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _rand_email() -> str:
|
|
def _rand_email() -> str:
|
|
|
import random
|
|
import random
|
|
|
import string
|
|
import string
|
|
@@ -518,7 +718,8 @@ def _open_paypal_context(ctx, page):
|
|
|
若未配置代理 或 上层已经把代理应用到当前 context(chatgpt_flow.run_full 全局代理模式),
|
|
若未配置代理 或 上层已经把代理应用到当前 context(chatgpt_flow.run_full 全局代理模式),
|
|
|
就返回 (page, None) 表示沿用当前 page。
|
|
就返回 (page, None) 表示沿用当前 page。
|
|
|
"""
|
|
"""
|
|
|
- proxy_cfg = _parse_proxy_url(getattr(ctx, "paypal_proxy", "") or "")
|
|
|
|
|
|
|
+ paypal_proxy_url = (getattr(ctx, "paypal_proxy", "") or "").strip()
|
|
|
|
|
+ proxy_cfg = _parse_proxy_url(paypal_proxy_url)
|
|
|
if not proxy_cfg:
|
|
if not proxy_cfg:
|
|
|
ctx.log("[playwright] 未配置 paypal_proxy,PayPal 阶段直连")
|
|
ctx.log("[playwright] 未配置 paypal_proxy,PayPal 阶段直连")
|
|
|
return page, None
|
|
return page, None
|
|
@@ -544,9 +745,11 @@ def _open_paypal_context(ctx, page):
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
old_ctx = page.context
|
|
old_ctx = page.context
|
|
|
|
|
+ from geo_fingerprint import detect_paypal_geo_fingerprint
|
|
|
|
|
+ geo = detect_paypal_geo_fingerprint(paypal_proxy_url, log=ctx.log)
|
|
|
new_ctx = browser.new_context(
|
|
new_ctx = browser.new_context(
|
|
|
- locale="en-US",
|
|
|
|
|
- timezone_id="America/New_York",
|
|
|
|
|
|
|
+ locale=geo.locale,
|
|
|
|
|
+ timezone_id=geo.timezone_id,
|
|
|
viewport={"width": 1280, "height": 900},
|
|
viewport={"width": 1280, "height": 900},
|
|
|
proxy=proxy_cfg,
|
|
proxy=proxy_cfg,
|
|
|
)
|
|
)
|
|
@@ -625,8 +828,25 @@ def run_paypal_flow(ctx: RunContext, page=None):
|
|
|
ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
|
|
ctx.set_stage(f"支付尝试 {attempt}/{MAX_PAYPAL_RETRIES}")
|
|
|
ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
|
|
ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
|
|
|
paypal_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
|
|
paypal_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
|
|
|
|
|
+ paypal_page.wait_for_timeout(2000)
|
|
|
_dump_page(ctx, paypal_page, f"01-stripe-loaded-attempt{attempt}")
|
|
_dump_page(ctx, paypal_page, f"01-stripe-loaded-attempt{attempt}")
|
|
|
|
|
|
|
|
|
|
+ amount = detect_stripe_amount(paypal_page, log=ctx.log)
|
|
|
|
|
+ decision = decide_paypal_flow_for_amount(
|
|
|
|
|
+ amount,
|
|
|
|
|
+ trial_eligible=getattr(ctx, "_trial_eligible", None),
|
|
|
|
|
+ )
|
|
|
|
|
+ if amount and decision["mode"] == "free_trial":
|
|
|
|
|
+ ctx.log(f"[stripe] 金额为 {amount}(免费试用),继续完成付款")
|
|
|
|
|
+ _set_trial_eligibility(ctx, True)
|
|
|
|
|
+ ctx._is_free_trial = True
|
|
|
|
|
+ elif amount and decision["mode"] == "paid_retry":
|
|
|
|
|
+ ctx.log(f"[stripe] 金额为 {amount}(试用账号重付),继续完成 PayPal 付款")
|
|
|
|
|
+ elif amount and decision["mode"] == "manual_payment_required":
|
|
|
|
|
+ ctx.log(f"[stripe] 金额为 {amount}(当前不支持试用),跳过自动付款,等待手动处理")
|
|
|
|
|
+ _set_trial_eligibility(ctx, False)
|
|
|
|
|
+ raise StripeNonFreeDetected(f"金额为 {amount}")
|
|
|
|
|
+
|
|
|
_stripe_select_paypal_and_submit(ctx, paypal_page)
|
|
_stripe_select_paypal_and_submit(ctx, paypal_page)
|
|
|
_paypal_signup_and_pay(ctx, paypal_page)
|
|
_paypal_signup_and_pay(ctx, paypal_page)
|
|
|
ctx.log("[playwright] 流程完成")
|
|
ctx.log("[playwright] 流程完成")
|
|
@@ -652,9 +872,12 @@ def run_paypal_flow(ctx: RunContext, page=None):
|
|
|
# 关键修复:失败后旧 Stripe checkout session 已被标 redirect_status=failed,
|
|
# 关键修复:失败后旧 Stripe checkout session 已被标 redirect_status=failed,
|
|
|
# 表单会变 disabled。必须重新生成一个新的长链。
|
|
# 表单会变 disabled。必须重新生成一个新的长链。
|
|
|
try:
|
|
try:
|
|
|
- new_link = generate_long_link_payurl(ctx)
|
|
|
|
|
|
|
+ if ctx.long_link_mode == "local":
|
|
|
|
|
+ new_link = generate_long_link_local(ctx, proxy=ctx.long_link_proxy)
|
|
|
|
|
+ else:
|
|
|
|
|
+ new_link = generate_long_link_payurl(ctx)
|
|
|
ctx.long_link = new_link
|
|
ctx.long_link = new_link
|
|
|
- ctx.log(f"[playwright] 重试用新长链 sessionId={new_link.split('/c/pay/', 1)[-1].split('#', 1)[0]}")
|
|
|
|
|
|
|
+ ctx.log(f"[playwright] 重试用新长链(mode={ctx.long_link_mode}) sessionId={new_link.split('/c/pay/', 1)[-1].split('#', 1)[0]}")
|
|
|
except Exception as exc2:
|
|
except Exception as exc2:
|
|
|
ctx.log(f"[playwright] 刷新长链失败(沿用旧的,可能继续失败): {exc2!r}")
|
|
ctx.log(f"[playwright] 刷新长链失败(沿用旧的,可能继续失败): {exc2!r}")
|
|
|
ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
|
|
ctx.log("[playwright] 重试前等待 30s(让 PayPal/PerimeterX 指纹/速率衰减)")
|
|
@@ -700,15 +923,19 @@ def _run_paypal_flow_self_browser(ctx: RunContext):
|
|
|
|
|
|
|
|
with sync_playwright() as p:
|
|
with sync_playwright() as p:
|
|
|
ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
|
|
ctx.log(f"[playwright] 启动 Chromium headless={ctx.headless}")
|
|
|
|
|
+ from geo_fingerprint import detect_paypal_geo_fingerprint
|
|
|
|
|
+ geo = detect_paypal_geo_fingerprint(getattr(ctx, "paypal_proxy", ""), log=ctx.log)
|
|
|
browser = p.chromium.launch(
|
|
browser = p.chromium.launch(
|
|
|
headless=ctx.headless,
|
|
headless=ctx.headless,
|
|
|
args=["--disable-blink-features=AutomationControlled"],
|
|
args=["--disable-blink-features=AutomationControlled"],
|
|
|
)
|
|
)
|
|
|
context = browser.new_context(
|
|
context = browser.new_context(
|
|
|
- locale="en-US",
|
|
|
|
|
- timezone_id="America/New_York",
|
|
|
|
|
|
|
+ locale=geo.locale,
|
|
|
|
|
+ timezone_id=geo.timezone_id,
|
|
|
viewport={"width": 1280, "height": 900},
|
|
viewport={"width": 1280, "height": 900},
|
|
|
)
|
|
)
|
|
|
|
|
+ # 启动时注入已保存的 datadome cookie,减少后续触发验证码的概率
|
|
|
|
|
+ _inject_datadome_cookie_at_startup(ctx, context)
|
|
|
new_page = context.new_page()
|
|
new_page = context.new_page()
|
|
|
|
|
|
|
|
new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
|
|
new_page.on("console", lambda m: ctx.log(f"[browser-console:{m.type}] {m.text[:300]}"))
|
|
@@ -723,7 +950,27 @@ def _run_paypal_flow_self_browser(ctx: RunContext):
|
|
|
try:
|
|
try:
|
|
|
ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
|
|
ctx.log(f"[playwright] 第 {attempt}/{MAX_PAYPAL_RETRIES} 次尝试,打开 Stripe 长链: {ctx.long_link}")
|
|
|
new_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
|
|
new_page.goto(ctx.long_link, wait_until="domcontentloaded", timeout=60000)
|
|
|
|
|
+ new_page.wait_for_timeout(2000)
|
|
|
_dump_page(ctx, new_page, f"01-stripe-loaded-attempt{attempt}")
|
|
_dump_page(ctx, new_page, f"01-stripe-loaded-attempt{attempt}")
|
|
|
|
|
+
|
|
|
|
|
+ amount = detect_stripe_amount(new_page, log=ctx.log)
|
|
|
|
|
+ decision = decide_paypal_flow_for_amount(
|
|
|
|
|
+ amount,
|
|
|
|
|
+ trial_eligible=getattr(ctx, "_trial_eligible", None),
|
|
|
|
|
+ )
|
|
|
|
|
+ if amount and decision["mode"] == "free_trial":
|
|
|
|
|
+ ctx.log(f"[stripe] 金额为 {amount}(免费试用),继续完成付款")
|
|
|
|
|
+ _set_trial_eligibility(ctx, True)
|
|
|
|
|
+ ctx._is_free_trial = True
|
|
|
|
|
+ elif amount and decision["mode"] == "paid_retry":
|
|
|
|
|
+ ctx.log(f"[stripe] 金额为 {amount}(试用账号重付),继续完成 PayPal 付款")
|
|
|
|
|
+ elif amount and decision["mode"] == "manual_payment_required":
|
|
|
|
|
+ ctx.log(f"[stripe] 金额为 {amount}(当前不支持试用),跳过自动付款,等待手动处理")
|
|
|
|
|
+ _set_trial_eligibility(ctx, False)
|
|
|
|
|
+ raise StripeNonFreeDetected(f"金额为 {amount}")
|
|
|
|
|
+ elif amount:
|
|
|
|
|
+ ctx.log(f"[stripe] 金额为 {amount}(正常付费)")
|
|
|
|
|
+
|
|
|
_stripe_select_paypal_and_submit(ctx, new_page)
|
|
_stripe_select_paypal_and_submit(ctx, new_page)
|
|
|
_paypal_signup_and_pay(ctx, new_page)
|
|
_paypal_signup_and_pay(ctx, new_page)
|
|
|
ctx.log("[playwright] 流程完成")
|
|
ctx.log("[playwright] 流程完成")
|
|
@@ -911,7 +1158,19 @@ def _stripe_select_paypal_and_submit(ctx, page):
|
|
|
page.wait_for_timeout(200)
|
|
page.wait_for_timeout(200)
|
|
|
_safe_fill(ctx, page, '#billingLocality', addr["city"], "billingLocality")
|
|
_safe_fill(ctx, page, '#billingLocality', addr["city"], "billingLocality")
|
|
|
_safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
|
|
_safe_fill(ctx, page, '#billingPostalCode', addr["zip"], "billingPostalCode")
|
|
|
- _select_by_text(ctx, page, '#billingAdministrativeArea', addr["state"], "billingAdministrativeArea")
|
|
|
|
|
|
|
+ # Stripe 的州下拉框 option value 使用缩写(如 "IL"),label 使用全名(如 "Illinois")
|
|
|
|
|
+ # 先尝试缩写,再尝试全名
|
|
|
|
|
+ state_abbrev = addr.get("state_abbrev", "")
|
|
|
|
|
+ state_full = addr.get("state", "")
|
|
|
|
|
+ if state_abbrev:
|
|
|
|
|
+ _select_by_text(ctx, page, '#billingAdministrativeArea', state_abbrev, "billingAdministrativeArea")
|
|
|
|
|
+ # 验证是否选中成功,若没有则尝试全名
|
|
|
|
|
+ sel_loc = page.locator('#billingAdministrativeArea').first
|
|
|
|
|
+ if sel_loc.count() > 0:
|
|
|
|
|
+ cur = sel_loc.input_value() if state_abbrev else ""
|
|
|
|
|
+ if not cur and state_full:
|
|
|
|
|
+ ctx.log(f"[stripe] 州下拉框未选中,尝试全名: {state_full}")
|
|
|
|
|
+ _select_by_text(ctx, page, '#billingAdministrativeArea', state_full, "billingAdministrativeArea")
|
|
|
|
|
|
|
|
if page.locator('#phoneNumber').count() > 0:
|
|
if page.locator('#phoneNumber').count() > 0:
|
|
|
_safe_fill(ctx, page, '#phoneNumber', ctx.phone_number, "phoneNumber")
|
|
_safe_fill(ctx, page, '#phoneNumber', ctx.phone_number, "phoneNumber")
|
|
@@ -959,6 +1218,412 @@ def _stripe_select_paypal_and_submit(ctx, page):
|
|
|
_dump_page(ctx, page, "05-paypal-arrived")
|
|
_dump_page(ctx, page, "05-paypal-arrived")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# DataDome 滑块自动模拟 + cookie 复用
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def _human_bezier_points(start_x: float, start_y: float,
|
|
|
|
|
+ end_x: float, end_y: float,
|
|
|
|
|
+ steps: int = 50) -> list[tuple[float, float]]:
|
|
|
|
|
+ """用三阶贝塞尔曲线生成从 start 到 end 的人类鼠标轨迹,带随机抖动和加减速。"""
|
|
|
|
|
+ dx = end_x - start_x
|
|
|
|
|
+ dy = end_y - start_y
|
|
|
|
|
+ dist = math.hypot(dx, dy)
|
|
|
|
|
+ # 随机偏移控制点,制造弧度
|
|
|
|
|
+ offset_y = random.uniform(-dist * 0.15, dist * 0.15)
|
|
|
|
|
+ cp1x = start_x + dx * random.uniform(0.2, 0.4)
|
|
|
|
|
+ cp1y = start_y + dy * random.uniform(0.1, 0.3) + offset_y
|
|
|
|
|
+ cp2x = start_x + dx * random.uniform(0.6, 0.8)
|
|
|
|
|
+ cp2y = start_y + dy * random.uniform(0.7, 0.9) + offset_y
|
|
|
|
|
+
|
|
|
|
|
+ points = []
|
|
|
|
|
+ for i in range(steps):
|
|
|
|
|
+ t = i / max(steps - 1, 1)
|
|
|
|
|
+ # 加速→匀速→减速的时间映射
|
|
|
|
|
+ t_ease = t * t * (3 - 2 * t) # smoothstep
|
|
|
|
|
+ x = ((1 - t_ease) ** 3 * start_x
|
|
|
|
|
+ + 3 * (1 - t_ease) ** 2 * t_ease * cp1x
|
|
|
|
|
+ + 3 * (1 - t_ease) * t_ease ** 2 * cp2x
|
|
|
|
|
+ + t_ease ** 3 * end_x)
|
|
|
|
|
+ y = ((1 - t_ease) ** 3 * start_y
|
|
|
|
|
+ + 3 * (1 - t_ease) ** 2 * t_ease * cp1y
|
|
|
|
|
+ + 3 * (1 - t_ease) * t_ease ** 2 * cp2y
|
|
|
|
|
+ + t_ease ** 3 * end_y)
|
|
|
|
|
+ # 手抖:在中间段加随机微偏移
|
|
|
|
|
+ if 0.15 < t < 0.85:
|
|
|
|
|
+ x += random.gauss(0, dist * 0.008)
|
|
|
|
|
+ y += random.gauss(0, dist * 0.008)
|
|
|
|
|
+ points.append((round(x, 1), round(y, 1)))
|
|
|
|
|
+ points.append((round(end_x, 1), round(end_y, 1)))
|
|
|
|
|
+ return points
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _find_datadome_slider(page) -> dict | None:
|
|
|
|
|
+ """在页面中查找 DataDome iframe 的位置信息。
|
|
|
|
|
+
|
|
|
|
|
+ DataDome 滑块在跨域 iframe 内,JS 无法直接访问内部 DOM。
|
|
|
|
|
+ 返回 iframe 的 bounding box,供 page.mouse 在主页面坐标系操作。
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ return page.evaluate(r"""() => {
|
|
|
|
|
+ const ifr = document.querySelector(
|
|
|
|
|
+ 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!ifr) return null;
|
|
|
|
|
+ const r = ifr.getBoundingClientRect();
|
|
|
|
|
+ if (r.width === 0 || r.height === 0) return null;
|
|
|
|
|
+ return {
|
|
|
|
|
+ iframe: { x: r.left, y: r.top, width: r.width, height: r.height },
|
|
|
|
|
+ iframeSrc: ifr.src || ''
|
|
|
|
|
+ };
|
|
|
|
|
+ }""")
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _datadome_drag_via_frame(ctx, page) -> bool:
|
|
|
|
|
+ """用 Playwright frame_locator 定位 DataDome iframe 内的滑块并拖拽。
|
|
|
|
|
+
|
|
|
|
|
+ DataDome 的 iframe 是跨域的,JS 无法访问内部 DOM,
|
|
|
|
|
+ 但 Playwright 的 frame_locator 可以操作跨域 iframe 内的元素。
|
|
|
|
|
+ 滑块 UI 结构:iframe 内有一个蓝色按钮,需从左拖到右。
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ # 方法一:用 page.frame_locator() 进入 iframe
|
|
|
|
|
+ frame = page.frame_locator(
|
|
|
|
|
+ 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
|
|
|
|
|
+ )
|
|
|
|
|
+ # 尝试多种滑块选择器
|
|
|
|
|
+ slider_selectors = [
|
|
|
|
|
+ # DataDome 常见滑块选择器
|
|
|
|
|
+ '[class*="slider"] [class*="btn"]',
|
|
|
|
|
+ '[class*="slider"] button',
|
|
|
|
|
+ '[class*="slider-track"] > div',
|
|
|
|
|
+ '[class*="slider"] > div',
|
|
|
|
|
+ 'button[class*="slider"]',
|
|
|
|
|
+ '[role="slider"]',
|
|
|
|
|
+ 'div[class*="challenge"] [class*="slider"]',
|
|
|
|
|
+ 'div[class*="challenge"] button',
|
|
|
|
|
+ # 更宽泛
|
|
|
|
|
+ 'button',
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ slider_loc = None
|
|
|
|
|
+ used_sel = ""
|
|
|
|
|
+ for sel in slider_selectors:
|
|
|
|
|
+ try:
|
|
|
|
|
+ loc = frame.locator(sel).first
|
|
|
|
|
+ if loc.count() > 0 and loc.is_visible():
|
|
|
|
|
+ slider_loc = loc
|
|
|
|
|
+ used_sel = sel
|
|
|
|
|
+ break
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ if not slider_loc:
|
|
|
|
|
+ ctx.log("[datadome] frame_locator: iframe 内未找到可拖拽的滑块元素")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ ctx.log(f"[datadome] frame_locator: 找到滑块 selector={used_sel}")
|
|
|
|
|
+
|
|
|
|
|
+ # 获取滑块的 bounding box(在主页面坐标系)
|
|
|
|
|
+ box = slider_loc.bounding_box()
|
|
|
|
|
+ if not box:
|
|
|
|
|
+ ctx.log("[datadome] frame_locator: 滑块无 bounding box")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ # 获取 iframe 的 bounding box 计算拖拽距离
|
|
|
|
|
+ iframe_loc = page.locator(
|
|
|
|
|
+ 'iframe[src*="datadome" i], iframe[src*="captcha" i], iframe[title*="captcha" i], iframe[id*="datadome" i]'
|
|
|
|
|
+ ).first
|
|
|
|
|
+ iframe_box = iframe_loc.bounding_box()
|
|
|
|
|
+ if not iframe_box:
|
|
|
|
|
+ ctx.log("[datadome] frame_locator: iframe 无 bounding box")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ ctx.log(f"[datadome] 滑块 x={box['x']:.0f} y={box['y']:.0f} w={box['width']:.0f} h={box['height']:.0f}")
|
|
|
|
|
+ ctx.log(f"[datadome] iframe x={iframe_box['x']:.0f} y={iframe_box['y']:.0f} w={iframe_box['width']:.0f} h={iframe_box['height']:.0f}")
|
|
|
|
|
+
|
|
|
|
|
+ # 滑块起始中心
|
|
|
|
|
+ start_x = box["x"] + box["width"] / 2
|
|
|
|
|
+ start_y = box["y"] + box["height"] / 2
|
|
|
|
|
+
|
|
|
|
|
+ # 计算拖拽距离:从滑块当前位置到 iframe 右边界的距离
|
|
|
|
|
+ # DataDome track 宽度 ≈ iframe 宽度减去两侧内边距
|
|
|
|
|
+ available_width = iframe_box["x"] + iframe_box["width"] - box["x"] - box["width"] * 0.3
|
|
|
|
|
+ drag_dist = available_width * random.uniform(0.88, 0.97)
|
|
|
|
|
+ end_x = start_x + drag_dist
|
|
|
|
|
+ end_y = start_y + random.uniform(-3, 3)
|
|
|
|
|
+
|
|
|
|
|
+ ctx.log(f"[datadome] 拖拽 ({start_x:.0f},{start_y:.0f}) → ({end_x:.0f},{end_y:.0f}) dist={drag_dist:.0f}")
|
|
|
|
|
+
|
|
|
|
|
+ # === 人类化拖拽 ===
|
|
|
|
|
+ # 阶段1:鼠标从远处自然移到滑块上方
|
|
|
|
|
+ approach_x = start_x - random.uniform(120, 250)
|
|
|
|
|
+ approach_y = start_y + random.uniform(-60, 60)
|
|
|
|
|
+ approach_pts = _human_bezier_points(
|
|
|
|
|
+ approach_x, approach_y,
|
|
|
|
|
+ start_x - random.uniform(2, 8), start_y + random.uniform(-1, 1),
|
|
|
|
|
+ steps=random.randint(10, 18),
|
|
|
|
|
+ )
|
|
|
|
|
+ for px, py in approach_pts:
|
|
|
|
|
+ page.mouse.move(px, py)
|
|
|
|
|
+ page.wait_for_timeout(random.randint(10, 25))
|
|
|
|
|
+ # 到达滑块后短暂停顿(人类瞄准)
|
|
|
|
|
+ page.wait_for_timeout(random.randint(300, 700))
|
|
|
|
|
+
|
|
|
|
|
+ # 阶段2:按下鼠标
|
|
|
|
|
+ page.mouse.down()
|
|
|
|
|
+ page.wait_for_timeout(random.randint(80, 200))
|
|
|
|
|
+
|
|
|
|
|
+ # 阶段3:主拖拽轨迹(变速:慢→快→慢,带 overshot)
|
|
|
|
|
+ # 先拖到目标稍微偏右的位置(overshot),再微调回来
|
|
|
|
|
+ overshot_x = end_x + random.uniform(8, 25)
|
|
|
|
|
+ overshot_y = end_y + random.uniform(-4, 4)
|
|
|
|
|
+
|
|
|
|
|
+ steps_main = random.randint(40, 65)
|
|
|
|
|
+ points_main = _human_bezier_points(start_x, start_y, overshot_x, overshot_y, steps=steps_main)
|
|
|
|
|
+ for i, (px, py) in enumerate(points_main):
|
|
|
|
|
+ page.mouse.move(px, py)
|
|
|
|
|
+ # 速度曲线:起步慢→中间快→结尾慢
|
|
|
|
|
+ progress = i / max(steps_main - 1, 1)
|
|
|
|
|
+ if progress < 0.15:
|
|
|
|
|
+ delay = random.randint(18, 35) # 起步慢
|
|
|
|
|
+ elif progress < 0.7:
|
|
|
|
|
+ delay = random.randint(4, 12) # 中间快
|
|
|
|
|
+ else:
|
|
|
|
|
+ delay = random.randint(12, 28) # 结尾减速
|
|
|
|
|
+ page.wait_for_timeout(delay)
|
|
|
|
|
+
|
|
|
|
|
+ # 阶段4:overshot 回弹(拖过头再回来一点)
|
|
|
|
|
+ page.wait_for_timeout(random.randint(60, 150))
|
|
|
|
|
+ correction_steps = random.randint(3, 6)
|
|
|
|
|
+ for i in range(correction_steps):
|
|
|
|
|
+ t = (i + 1) / correction_steps
|
|
|
|
|
+ cx = overshot_x + (end_x - overshot_x) * t
|
|
|
|
|
+ cy = overshot_y + (end_y - overshot_y) * t + random.gauss(0, 0.5)
|
|
|
|
|
+ page.mouse.move(cx, cy)
|
|
|
|
|
+ page.wait_for_timeout(random.randint(15, 30))
|
|
|
|
|
+
|
|
|
|
|
+ # 阶段5:松手前的微小停顿
|
|
|
|
|
+ page.wait_for_timeout(random.randint(150, 400))
|
|
|
|
|
+ page.mouse.up()
|
|
|
|
|
+
|
|
|
|
|
+ ctx.log("[datadome] frame_locator 拖拽完成")
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[datadome] frame_locator 拖拽异常: {exc!r}")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _auto_solve_datadome(ctx, page, max_attempts: int = 3) -> bool:
|
|
|
|
|
+ """尝试自动通过 DataDome 滑块验证。
|
|
|
|
|
+
|
|
|
|
|
+ 策略:
|
|
|
|
|
+ 1) 注入已保存的 cookie → 如已通过则直接返回
|
|
|
|
|
+ 2) 用 frame_locator 进入跨域 iframe → 定位滑块 → 人类轨迹拖拽
|
|
|
|
|
+ 3) 每次 attempt 之间等待随机时间
|
|
|
|
|
+ """
|
|
|
|
|
+ ctx.set_stage("🤖 尝试自动通过 DataDome 滑块...")
|
|
|
|
|
+
|
|
|
|
|
+ # 1) 注入已有 cookie
|
|
|
|
|
+ _inject_datadome_cookie(ctx, page)
|
|
|
|
|
+ page.wait_for_timeout(1500)
|
|
|
|
|
+ if not _detect_datadome_captcha(page):
|
|
|
|
|
+ ctx.log("[datadome] 注入已有 cookie 后验证已通过")
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ for attempt in range(1, max_attempts + 1):
|
|
|
|
|
+ ctx.log(f"[datadome] 自动解决第 {attempt}/{max_attempts} 次尝试")
|
|
|
|
|
+
|
|
|
|
|
+ # 2) 模拟人类预行为:随机鼠标移动 + 微滚动
|
|
|
|
|
+ try:
|
|
|
|
|
+ vp = page.viewport_size or {"width": 1280, "height": 900}
|
|
|
|
|
+ for _ in range(random.randint(3, 6)):
|
|
|
|
|
+ rx = random.uniform(100, vp["width"] - 100)
|
|
|
|
|
+ ry = random.uniform(100, vp["height"] - 100)
|
|
|
|
|
+ page.mouse.move(rx, ry, steps=random.randint(8, 20))
|
|
|
|
|
+ page.wait_for_timeout(random.randint(80, 250))
|
|
|
|
|
+ page.mouse.wheel(0, random.randint(-80, 80))
|
|
|
|
|
+ page.wait_for_timeout(random.randint(800, 1500))
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[datadome] 预行为异常: {exc!r}")
|
|
|
|
|
+
|
|
|
|
|
+ # 3) 用 frame_locator 拖拽(核心方法,能操作跨域 iframe)
|
|
|
|
|
+ dragged = _datadome_drag_via_frame(ctx, page)
|
|
|
|
|
+
|
|
|
|
|
+ if not dragged:
|
|
|
|
|
+ # 4) 备用方案:基于 iframe bounding box 直接推算滑块位置拖拽
|
|
|
|
|
+ ctx.log("[datadome] frame_locator 失败,尝试基于 iframe 位置推算拖拽")
|
|
|
|
|
+ info = _find_datadome_slider(page)
|
|
|
|
|
+ if not info or not info.get("iframe"):
|
|
|
|
|
+ ctx.log("[datadome] 未找到 DataDome iframe,降级等待手动")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+ iframe = info["iframe"]
|
|
|
|
|
+ # DataDome 滑块 UI:iframe 中部偏下,滑块按钮从左端拖到右端
|
|
|
|
|
+ # 典型布局:图标栏(~30px) → 文字(~20px) → slider track(中间区域)
|
|
|
|
|
+ # 滑块按钮起始在 track 左端
|
|
|
|
|
+ slider_y = iframe["y"] + iframe["height"] * random.uniform(0.55, 0.65)
|
|
|
|
|
+ start_x = iframe["x"] + random.uniform(20, 40)
|
|
|
|
|
+ end_x = iframe["x"] + iframe["width"] - random.uniform(15, 30)
|
|
|
|
|
+
|
|
|
|
|
+ ctx.log(f"[datadome] 推算拖拽 ({start_x:.0f},{slider_y:.0f}) → ({end_x:.0f},{slider_y:.0f})")
|
|
|
|
|
+
|
|
|
|
|
+ # 生成人类轨迹
|
|
|
|
|
+ steps = random.randint(35, 55)
|
|
|
|
|
+ points = _human_bezier_points(start_x, slider_y, end_x, slider_y + random.uniform(-2, 2), steps=steps)
|
|
|
|
|
+
|
|
|
|
|
+ # 移到附近
|
|
|
|
|
+ try:
|
|
|
|
|
+ approach_pts = _human_bezier_points(
|
|
|
|
|
+ start_x - random.uniform(100, 200),
|
|
|
|
|
+ slider_y + random.uniform(-50, 50),
|
|
|
|
|
+ start_x, slider_y, steps=random.randint(10, 18),
|
|
|
|
|
+ )
|
|
|
|
|
+ for px, py in approach_pts:
|
|
|
|
|
+ page.mouse.move(px, py)
|
|
|
|
|
+ page.wait_for_timeout(random.randint(10, 25))
|
|
|
|
|
+ page.wait_for_timeout(random.randint(300, 700))
|
|
|
|
|
+
|
|
|
|
|
+ page.mouse.down()
|
|
|
|
|
+ page.wait_for_timeout(random.randint(80, 200))
|
|
|
|
|
+
|
|
|
|
|
+ # 变速拖拽:慢→快→慢
|
|
|
|
|
+ overshot_x = end_x + random.uniform(5, 20)
|
|
|
|
|
+ points_os = _human_bezier_points(start_x, slider_y, overshot_x, slider_y + random.uniform(-3, 3), steps=random.randint(40, 60))
|
|
|
|
|
+ for i, (px, py) in enumerate(points_os):
|
|
|
|
|
+ page.mouse.move(px, py)
|
|
|
|
|
+ progress = i / max(len(points_os) - 1, 1)
|
|
|
|
|
+ if progress < 0.15:
|
|
|
|
|
+ delay = random.randint(18, 35)
|
|
|
|
|
+ elif progress < 0.7:
|
|
|
|
|
+ delay = random.randint(4, 12)
|
|
|
|
|
+ else:
|
|
|
|
|
+ delay = random.randint(12, 28)
|
|
|
|
|
+ page.wait_for_timeout(delay)
|
|
|
|
|
+
|
|
|
|
|
+ # overshot 回弹
|
|
|
|
|
+ page.wait_for_timeout(random.randint(60, 150))
|
|
|
|
|
+ for i in range(random.randint(2, 5)):
|
|
|
|
|
+ t = (i + 1) / 5
|
|
|
|
|
+ cx = overshot_x + (end_x - overshot_x) * t
|
|
|
|
|
+ page.mouse.move(cx, slider_y + random.gauss(0, 0.5))
|
|
|
|
|
+ page.wait_for_timeout(random.randint(15, 30))
|
|
|
|
|
+
|
|
|
|
|
+ page.wait_for_timeout(random.randint(150, 400))
|
|
|
|
|
+ page.mouse.up()
|
|
|
|
|
+ ctx.log("[datadome] 推算拖拽完成")
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[datadome] 推算拖拽异常: {exc!r}")
|
|
|
|
|
+
|
|
|
|
|
+ # 5) 等待验证结果
|
|
|
|
|
+ page.wait_for_timeout(5000)
|
|
|
|
|
+ if not _detect_datadome_captcha(page):
|
|
|
|
|
+ ctx.log("[datadome] 自动拖拽通过验证!")
|
|
|
|
|
+ _save_datadome_cookie(ctx, page)
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ ctx.log(f"[datadome] 第 {attempt} 次拖拽未通过")
|
|
|
|
|
+ # 重试前等待更久,让 DataDome 状态重置
|
|
|
|
|
+ page.wait_for_timeout(random.randint(2000, 4000))
|
|
|
|
|
+
|
|
|
|
|
+ ctx.log(f"[datadome] {max_attempts} 次自动拖拽均未通过,降级为手动")
|
|
|
|
|
+ return False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _save_datadome_cookie(ctx, page):
|
|
|
|
|
+ """滑块通过后提取 datadome cookie 并保存到文件。"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ cookies = page.context.cookies()
|
|
|
|
|
+ dd_cookies = [c for c in cookies if "datadome" in c.get("name", "").lower()]
|
|
|
|
|
+ if not dd_cookies:
|
|
|
|
|
+ # 也从 document.cookie 中提取
|
|
|
|
|
+ raw = page.evaluate("() => document.cookie") or ""
|
|
|
|
|
+ for part in raw.split(";"):
|
|
|
|
|
+ kv = part.strip()
|
|
|
|
|
+ if kv.lower().startswith("datadome="):
|
|
|
|
|
+ dd_cookies.append({
|
|
|
|
|
+ "name": "datadome",
|
|
|
|
|
+ "value": kv.split("=", 1)[1],
|
|
|
|
|
+ "domain": ".paypal.com",
|
|
|
|
|
+ "path": "/",
|
|
|
|
|
+ })
|
|
|
|
|
+ break
|
|
|
|
|
+ if not dd_cookies:
|
|
|
|
|
+ ctx.log("[datadome] 未找到 datadome cookie 可保存")
|
|
|
|
|
+ return
|
|
|
|
|
+ payload = {
|
|
|
|
|
+ "cookies": dd_cookies,
|
|
|
|
|
+ "saved_at": time.time(),
|
|
|
|
|
+ "url": page.url,
|
|
|
|
|
+ }
|
|
|
|
|
+ with open(DATADOME_COOKIE_FILE, "w", encoding="utf-8") as f:
|
|
|
|
|
+ json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
|
|
|
+ ctx.log(f"[datadome] 已保存 {len(dd_cookies)} 个 cookie 到 {DATADOME_COOKIE_FILE}")
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[datadome] 保存 cookie 异常: {exc!r}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _inject_datadome_cookie(ctx, page):
|
|
|
|
|
+ """从文件读取之前保存的 datadome cookie 并注入到当前浏览器 context。"""
|
|
|
|
|
+ if not os.path.exists(DATADOME_COOKIE_FILE):
|
|
|
|
|
+ ctx.log("[datadome] 无已保存的 cookie 文件")
|
|
|
|
|
+ return
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(DATADOME_COOKIE_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
+ payload = json.load(f)
|
|
|
|
|
+ cookies = payload.get("cookies", [])
|
|
|
|
|
+ if not cookies:
|
|
|
|
|
+ ctx.log("[datadome] cookie 文件为空")
|
|
|
|
|
+ return
|
|
|
|
|
+ saved_at = payload.get("saved_at", 0)
|
|
|
|
|
+ age_hours = (time.time() - saved_at) / 3600
|
|
|
|
|
+ if age_hours > 24:
|
|
|
|
|
+ ctx.log(f"[datadome] cookie 已过期 {age_hours:.1f}h(>24h),跳过注入")
|
|
|
|
|
+ return
|
|
|
|
|
+ ctx.log(f"[datadome] 注入已保存的 cookie({age_hours:.1f}h 前,{len(cookies)} 个)")
|
|
|
|
|
+ for c in cookies:
|
|
|
|
|
+ c.setdefault("path", "/")
|
|
|
|
|
+ if "domain" not in c:
|
|
|
|
|
+ c["domain"] = ".paypal.com"
|
|
|
|
|
+ page.context.add_cookies(cookies)
|
|
|
|
|
+ ctx.log("[datadome] cookie 注入完成")
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[datadome] 注入 cookie 异常: {exc!r}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _inject_datadome_cookie_at_startup(ctx, browser_context):
|
|
|
|
|
+ """浏览器 context 创建后立即注入 datadome cookie(无需 page 对象)。
|
|
|
|
|
+
|
|
|
|
|
+ 使后续所有页面访问(包括首次打开 PayPal)都携带 datadome cookie,
|
|
|
|
|
+ 实现跨进程、跨浏览器实例的 cookie 复用。
|
|
|
|
|
+ """
|
|
|
|
|
+ if not os.path.exists(DATADOME_COOKIE_FILE):
|
|
|
|
|
+ return
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(DATADOME_COOKIE_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
+ payload = json.load(f)
|
|
|
|
|
+ cookies = payload.get("cookies", [])
|
|
|
|
|
+ if not cookies:
|
|
|
|
|
+ return
|
|
|
|
|
+ saved_at = payload.get("saved_at", 0)
|
|
|
|
|
+ age_hours = (time.time() - saved_at) / 3600
|
|
|
|
|
+ if age_hours > 24:
|
|
|
|
|
+ ctx.log(f"[datadome] 启动注入跳过:cookie 已过期 {age_hours:.1f}h")
|
|
|
|
|
+ return
|
|
|
|
|
+ for c in cookies:
|
|
|
|
|
+ c.setdefault("path", "/")
|
|
|
|
|
+ if "domain" not in c:
|
|
|
|
|
+ c["domain"] = ".paypal.com"
|
|
|
|
|
+ browser_context.add_cookies(cookies)
|
|
|
|
|
+ ctx.log(f"[datadome] 启动注入 {len(cookies)} 个 cookie({age_hours:.1f}h 前保存)")
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ ctx.log(f"[datadome] 启动注入异常: {exc!r}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _detect_datadome_captcha(page) -> str:
|
|
def _detect_datadome_captcha(page) -> str:
|
|
|
"""检测 PayPal/DataDome 的滑块/人机校验。返回非空表示需要人工。
|
|
"""检测 PayPal/DataDome 的滑块/人机校验。返回非空表示需要人工。
|
|
|
|
|
|
|
@@ -1041,21 +1706,32 @@ def _detect_datadome_captcha(page) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wait_for_datadome_solved(ctx, page, max_wait_sec: int = 300) -> bool:
|
|
def _wait_for_datadome_solved(ctx, page, max_wait_sec: int = 300) -> bool:
|
|
|
- """提示用户手动过滑块,轮询直到 DataDome 元素消失或超时。"""
|
|
|
|
|
- ctx.set_stage("⚠️ 检测到 DataDome 滑块校验,请在浏览器中手动滑动完成(最长等 5 分钟)")
|
|
|
|
|
|
|
+ """尝试自动过 DataDome 滑块,失败后降级为手动等待。
|
|
|
|
|
+
|
|
|
|
|
+ 策略:
|
|
|
|
|
+ 1) 先注入已保存的 datadome cookie → 如已通过则直接返回
|
|
|
|
|
+ 2) 自动模拟人类拖拽滑块(最多 3 次)
|
|
|
|
|
+ 3) 均失败 → 降级为手动等待(原有逻辑)
|
|
|
|
|
+ """
|
|
|
|
|
+ # 阶段一:自动尝试
|
|
|
|
|
+ if _auto_solve_datadome(ctx, page, max_attempts=3):
|
|
|
|
|
+ return True
|
|
|
|
|
+
|
|
|
|
|
+ # 阶段二:降级为手动等待
|
|
|
|
|
+ ctx.set_stage("⚠️ 自动过滑块失败,请在浏览器中手动滑动完成")
|
|
|
deadline = time.time() + max_wait_sec
|
|
deadline = time.time() + max_wait_sec
|
|
|
last_log = 0.0
|
|
last_log = 0.0
|
|
|
- poll_interval = 0.5 # 高频检测,滑过后 ≤500ms 就能继续
|
|
|
|
|
- stable_needed = 2 # 连续 2 次检测不到才算真的通过(避免 DataDome 中间状态误判)
|
|
|
|
|
|
|
+ poll_interval = 0.5
|
|
|
|
|
+ stable_needed = 2
|
|
|
stable_count = 0
|
|
stable_count = 0
|
|
|
while time.time() < deadline:
|
|
while time.time() < deadline:
|
|
|
_check_stop(ctx)
|
|
_check_stop(ctx)
|
|
|
if not _detect_datadome_captcha(page):
|
|
if not _detect_datadome_captcha(page):
|
|
|
stable_count += 1
|
|
stable_count += 1
|
|
|
if stable_count >= stable_needed:
|
|
if stable_count >= stable_needed:
|
|
|
- ctx.log("[paypal] DataDome 已通过,继续流程")
|
|
|
|
|
- # 给页面 1s 完成跳转,但不再傻等
|
|
|
|
|
|
|
+ ctx.log("[paypal] DataDome 已通过(手动),保存 cookie 并继续")
|
|
|
page.wait_for_timeout(1000)
|
|
page.wait_for_timeout(1000)
|
|
|
|
|
+ _save_datadome_cookie(ctx, page)
|
|
|
return True
|
|
return True
|
|
|
else:
|
|
else:
|
|
|
stable_count = 0
|
|
stable_count = 0
|
|
@@ -1081,8 +1757,7 @@ def _paypal_signup_and_pay(ctx, page):
|
|
|
if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
|
|
if not _wait_for_datadome_solved(ctx, page, max_wait_sec=300):
|
|
|
_dump_page(ctx, page, "06-paypal-datadome-timeout")
|
|
_dump_page(ctx, page, "06-paypal-datadome-timeout")
|
|
|
raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
|
|
raise PayPalPaymentFailed("DataDome 滑块校验超时未通过")
|
|
|
-
|
|
|
|
|
- _paypal_clear_session(ctx, page)
|
|
|
|
|
|
|
+ ctx.log("[paypal] 保留当前 PayPal 会话,继续后续流程")
|
|
|
|
|
|
|
|
# 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
|
|
# 强制走"创建账号"路径,避免点上方 Next 被识别为已存在账号要求输密码
|
|
|
from paypal_flow import ensure_checkoutweb
|
|
from paypal_flow import ensure_checkoutweb
|
|
@@ -1247,10 +1922,10 @@ def _paypal_collect_invalid(page):
|
|
|
|
|
|
|
|
|
|
|
|
|
def _paypal_clear_session(ctx, page):
|
|
def _paypal_clear_session(ctx, page):
|
|
|
- ctx.log("[paypal] 清理 cookie/storage")
|
|
|
|
|
|
|
+ ctx.log("[paypal] 清理 cookie/storage(保留 datadome)")
|
|
|
try:
|
|
try:
|
|
|
before = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
|
|
before = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
|
|
|
- page.evaluate("""() => {
|
|
|
|
|
|
|
+ page.evaluate(r"""() => {
|
|
|
try { localStorage.clear(); } catch (e) {}
|
|
try { localStorage.clear(); } catch (e) {}
|
|
|
try { sessionStorage.clear(); } catch (e) {}
|
|
try { sessionStorage.clear(); } catch (e) {}
|
|
|
const host = location.hostname;
|
|
const host = location.hostname;
|
|
@@ -1261,6 +1936,8 @@ def _paypal_clear_session(ctx, page):
|
|
|
cookies.forEach(c => {
|
|
cookies.forEach(c => {
|
|
|
const name = c.split('=')[0].trim();
|
|
const name = c.split('=')[0].trim();
|
|
|
if (!name) return;
|
|
if (!name) return;
|
|
|
|
|
+ // 保留 datadome cookie
|
|
|
|
|
+ if (name.toLowerCase() === 'datadome') return;
|
|
|
['/', location.pathname].forEach(p => {
|
|
['/', location.pathname].forEach(p => {
|
|
|
domains.forEach(d => {
|
|
domains.forEach(d => {
|
|
|
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p + '; domain=' + d;
|
|
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=' + p + '; domain=' + d;
|
|
@@ -1270,7 +1947,7 @@ def _paypal_clear_session(ctx, page):
|
|
|
});
|
|
});
|
|
|
}""")
|
|
}""")
|
|
|
after = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
|
|
after = page.evaluate("() => document.cookie.split(';').filter(Boolean).length")
|
|
|
- ctx.log(f"[paypal] cookie 数量 {before} -> {after}(httpOnly 项 JS 删不掉)")
|
|
|
|
|
|
|
+ ctx.log(f"[paypal] cookie 数量 {before} -> {after}(保留 datadome)")
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
ctx.log(f"[paypal] 清理会话异常: {exc!r}")
|
|
ctx.log(f"[paypal] 清理会话异常: {exc!r}")
|
|
|
|
|
|
|
@@ -1484,6 +2161,22 @@ def _detect_payment_completion(page) -> str:
|
|
|
return ""
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _detect_payment_failure(page) -> str:
|
|
|
|
|
+ url = getattr(page, "url", "") or ""
|
|
|
|
|
+ if _PAYMENT_FAILED_URL_RE.search(url):
|
|
|
|
|
+ return f"URL 标记 redirect_status=failed: {url}"
|
|
|
|
|
+ snapshot = _payment_page_snapshot(page)
|
|
|
|
|
+ text = snapshot.get("text", "") or ""
|
|
|
|
|
+ alerts = snapshot.get("alerts", "") or ""
|
|
|
|
|
+ combined = f"{alerts} {text}"
|
|
|
|
|
+ m = _PAYMENT_FAILED_TEXT_RE.search(combined)
|
|
|
|
|
+ if m:
|
|
|
|
|
+ start = max(0, m.start() - 40)
|
|
|
|
|
+ end = min(len(combined), m.end() + 80)
|
|
|
|
|
+ return combined[start:end].strip()[:300]
|
|
|
|
|
+ return ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _summarize_payment_wait_status(page) -> str:
|
|
def _summarize_payment_wait_status(page) -> str:
|
|
|
url = getattr(page, "url", "") or ""
|
|
url = getattr(page, "url", "") or ""
|
|
|
snapshot = _payment_page_snapshot(page)
|
|
snapshot = _payment_page_snapshot(page)
|