|
@@ -8,15 +8,14 @@ import base64
|
|
|
import uuid
|
|
import uuid
|
|
|
import shutil
|
|
import shutil
|
|
|
import re
|
|
import re
|
|
|
|
|
+import socket
|
|
|
import urllib.parse
|
|
import urllib.parse
|
|
|
from datetime import datetime
|
|
from datetime import datetime
|
|
|
from typing import Dict, Any, Optional, List, Tuple, Callable
|
|
from typing import Dict, Any, Optional, List, Tuple, Callable
|
|
|
|
|
|
|
|
-# DrissionPage 核心引入
|
|
|
|
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
from DrissionPage import ChromiumPage, ChromiumOptions
|
|
|
from DrissionPage.common import Settings
|
|
from DrissionPage.common import Settings
|
|
|
|
|
|
|
|
-# 加密库
|
|
|
|
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.backends import default_backend
|
|
@@ -28,8 +27,6 @@ from toolkit.proxy_tunnel import ProxyTunnel
|
|
|
from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
|
|
from utils.cloudflare_bypass_for_scraping import CloudflareBypasser
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ----------------- 静态常量与辅助数据 -----------------
|
|
|
|
|
-
|
|
|
|
|
VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
|
VFS_PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
|
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
|
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuupFgB+lYIOtSxrRoHzc
|
|
|
LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
|
|
LmCZKJ6+oSbgqgOPzFMM0TasOeLw0NXEn1XfIzXdx75+tegNKwyIZumoh0yhubKs
|
|
@@ -123,7 +120,6 @@ class VfsPlugin(IVSPlg):
|
|
|
self.free_config: Dict[str, Any] = {}
|
|
self.free_config: Dict[str, Any] = {}
|
|
|
self.logger = None
|
|
self.logger = None
|
|
|
|
|
|
|
|
- # 替换 requests.Session 为 DrissionPage
|
|
|
|
|
self.page: Optional[ChromiumPage] = None
|
|
self.page: Optional[ChromiumPage] = None
|
|
|
|
|
|
|
|
self.jwt_token: str = ""
|
|
self.jwt_token: str = ""
|
|
@@ -208,14 +204,7 @@ class VfsPlugin(IVSPlg):
|
|
|
4. JS fetch 登录
|
|
4. JS fetch 登录
|
|
|
"""
|
|
"""
|
|
|
self._log(f"Initializing Session (ID: {self.instance_id})...")
|
|
self._log(f"Initializing Session (ID: {self.instance_id})...")
|
|
|
-
|
|
|
|
|
- # 0. 配置浏览器
|
|
|
|
|
co = ChromiumOptions()
|
|
co = ChromiumOptions()
|
|
|
- # -------------------------------------------------------------
|
|
|
|
|
- # [核心修复] 解决 'not enough values to unpack'
|
|
|
|
|
- # -------------------------------------------------------------
|
|
|
|
|
- # 1. 不要用 co.auto_port(),因为它依赖解析 stdout,会被 DBus 报错干扰
|
|
|
|
|
- # 2. 我们手动随机生成一个端口
|
|
|
|
|
import random
|
|
import random
|
|
|
import socket
|
|
import socket
|
|
|
|
|
|
|
@@ -227,38 +216,24 @@ class VfsPlugin(IVSPlg):
|
|
|
debug_port = get_free_port()
|
|
debug_port = get_free_port()
|
|
|
self._log(f"Assigned Debug Port: {debug_port}")
|
|
self._log(f"Assigned Debug Port: {debug_port}")
|
|
|
|
|
|
|
|
- # 3. 强制指定端口,DrissionPage 就会直接连接,不再解析日志
|
|
|
|
|
co.set_local_port(debug_port)
|
|
co.set_local_port(debug_port)
|
|
|
-
|
|
|
|
|
- # --- [关键配置] 设置独立的用户数据目录 ---
|
|
|
|
|
- # 这样每个实例的 Cache, Cookies, LocalStorage 都是完全隔离的
|
|
|
|
|
- # 同时也防止了多进程争抢同一个 Default 文件夹导致的崩溃
|
|
|
|
|
co.set_user_data_path(self.user_data_path)
|
|
co.set_user_data_path(self.user_data_path)
|
|
|
|
|
|
|
|
- # --- 1. 指定浏览器路径 (适配 Docker) ---
|
|
|
|
|
chrome_path = os.getenv("CHROME_BIN")
|
|
chrome_path = os.getenv("CHROME_BIN")
|
|
|
if chrome_path and os.path.exists(chrome_path):
|
|
if chrome_path and os.path.exists(chrome_path):
|
|
|
co.set_paths(browser_path=chrome_path)
|
|
co.set_paths(browser_path=chrome_path)
|
|
|
|
|
|
|
|
- # --- [核心修改] 代理配置 ---
|
|
|
|
|
if self.config.proxy and self.config.proxy.ip:
|
|
if self.config.proxy and self.config.proxy.ip:
|
|
|
p = self.config.proxy
|
|
p = self.config.proxy
|
|
|
|
|
|
|
|
if p.username and p.password:
|
|
if p.username and p.password:
|
|
|
self._log(f"Starting Proxy Tunnel for {p.ip}...")
|
|
self._log(f"Starting Proxy Tunnel for {p.ip}...")
|
|
|
-
|
|
|
|
|
- # 1. 启动本地隧道
|
|
|
|
|
self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
|
|
self.tunnel = ProxyTunnel(p.ip, p.port, p.username, p.password)
|
|
|
local_proxy = self.tunnel.start()
|
|
local_proxy = self.tunnel.start()
|
|
|
-
|
|
|
|
|
self._log(f"Tunnel started at {local_proxy}")
|
|
self._log(f"Tunnel started at {local_proxy}")
|
|
|
-
|
|
|
|
|
- # 2. Chrome 连接本地免密端口
|
|
|
|
|
- # 必须使用 --proxy-server 强制指定,绝对稳健
|
|
|
|
|
co.set_argument(f'--proxy-server={local_proxy}')
|
|
co.set_argument(f'--proxy-server={local_proxy}')
|
|
|
|
|
|
|
|
else:
|
|
else:
|
|
|
- # 无密码代理,直接用
|
|
|
|
|
proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
|
|
proxy_str = f"{p.scheme}://{p.ip}:{p.port}"
|
|
|
co.set_argument(f'--proxy-server={proxy_str}')
|
|
co.set_argument(f'--proxy-server={proxy_str}')
|
|
|
else:
|
|
else:
|
|
@@ -267,16 +242,13 @@ class VfsPlugin(IVSPlg):
|
|
|
co.headless(False)
|
|
co.headless(False)
|
|
|
co.set_argument('--no-sandbox')
|
|
co.set_argument('--no-sandbox')
|
|
|
co.set_argument('--disable-gpu')
|
|
co.set_argument('--disable-gpu')
|
|
|
- # Docker 默认 /dev/shm 只有 64MB,Chromium 很容易爆内存崩溃
|
|
|
|
|
co.set_argument('--disable-dev-shm-usage')
|
|
co.set_argument('--disable-dev-shm-usage')
|
|
|
-
|
|
|
|
|
co.set_argument('--window-size=1920,1080')
|
|
co.set_argument('--window-size=1920,1080')
|
|
|
co.set_argument('--disable-blink-features=AutomationControlled')
|
|
co.set_argument('--disable-blink-features=AutomationControlled')
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
self.page = ChromiumPage(co)
|
|
self.page = ChromiumPage(co)
|
|
|
|
|
|
|
|
- # 1. 导航到登录页面
|
|
|
|
|
mission = self.free_config.get("mission_code", "")
|
|
mission = self.free_config.get("mission_code", "")
|
|
|
country = self.free_config.get("country_code", "")
|
|
country = self.free_config.get("country_code", "")
|
|
|
lang = self.free_config.get("language", "en")
|
|
lang = self.free_config.get("language", "en")
|
|
@@ -289,24 +261,14 @@ class VfsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
self.page.get(login_page_url)
|
|
self.page.get(login_page_url)
|
|
|
|
|
|
|
|
- # -------------------------------------------------------------
|
|
|
|
|
- # [核心修改] 2. 智能 Cloudflare 过盾逻辑
|
|
|
|
|
- # -------------------------------------------------------------
|
|
|
|
|
self._log("Handling Cloudflare challenge...")
|
|
self._log("Handling Cloudflare challenge...")
|
|
|
|
|
|
|
|
- # 初始化过盾助手
|
|
|
|
|
cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
|
|
cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
|
|
|
cf_token = ""
|
|
cf_token = ""
|
|
|
|
|
|
|
|
- # 循环检测 (40秒超时)
|
|
|
|
|
for i in range(40):
|
|
for i in range(40):
|
|
|
time.sleep(1)
|
|
time.sleep(1)
|
|
|
-
|
|
|
|
|
- # A. 优先处理 Cookie 遮挡 (VFS 必须步骤)
|
|
|
|
|
- # 如果不关掉 cookie banner,验证码可能点不到
|
|
|
|
|
self._handle_cookie_banner()
|
|
self._handle_cookie_banner()
|
|
|
-
|
|
|
|
|
- # B. 尝试从 DOM 获取 Token (无感验证可能自动通过)
|
|
|
|
|
try:
|
|
try:
|
|
|
ele = self.page.ele('@name=cf-turnstile-response')
|
|
ele = self.page.ele('@name=cf-turnstile-response')
|
|
|
if ele and ele.value:
|
|
if ele and ele.value:
|
|
@@ -316,28 +278,21 @@ class VfsPlugin(IVSPlg):
|
|
|
except:
|
|
except:
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
|
- # C. 如果前 3 秒没自动出 Token,开始尝试点击
|
|
|
|
|
if i > 2:
|
|
if i > 2:
|
|
|
try:
|
|
try:
|
|
|
- # 开启 DFS 深度搜索模式 (防止 Shadow DOM 嵌套太深找不到)
|
|
|
|
|
- # 在第 10 秒后开启深度搜索,前期用快速搜索
|
|
|
|
|
use_dfs = False
|
|
use_dfs = False
|
|
|
cf_bypasser.click_verification_button(is_dfs=use_dfs)
|
|
cf_bypasser.click_verification_button(is_dfs=use_dfs)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
- # 点击错误忽略,继续下一轮
|
|
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
|
- # D. 检查是否已经看到了登录框 (有时候 Token 提取慢了,但页面已经变了)
|
|
|
|
|
if self.page.ele('tag:form') or self.page.ele('#mat-input-0'):
|
|
if self.page.ele('tag:form') or self.page.ele('#mat-input-0'):
|
|
|
self._log("Login form detected.")
|
|
self._log("Login form detected.")
|
|
|
- # 继续尝试提取一次 Token,如果实在没有也不要死循环
|
|
|
|
|
if i > 5 and not cf_token:
|
|
if i > 5 and not cf_token:
|
|
|
self._log("Form visible but token not found yet...")
|
|
self._log("Form visible but token not found yet...")
|
|
|
|
|
|
|
|
# -------------------------------------------------------------
|
|
# -------------------------------------------------------------
|
|
|
|
|
|
|
|
if not cf_token:
|
|
if not cf_token:
|
|
|
- # 最后尝试一次强取
|
|
|
|
|
try:
|
|
try:
|
|
|
cf_token = self.page.ele('@name=cf-turnstile-response').value
|
|
cf_token = self.page.ele('@name=cf-turnstile-response').value
|
|
|
except:
|
|
except:
|
|
@@ -347,7 +302,6 @@ class VfsPlugin(IVSPlg):
|
|
|
self._log("[WARN] Could not extract Turnstile token.")
|
|
self._log("[WARN] Could not extract Turnstile token.")
|
|
|
raise BizLogicError(f"Could not extract Turnstile token.")
|
|
raise BizLogicError(f"Could not extract Turnstile token.")
|
|
|
|
|
|
|
|
- # 3. 准备登录 API 参数
|
|
|
|
|
email = self.config.account.username
|
|
email = self.config.account.username
|
|
|
password = self.config.account.password
|
|
password = self.config.account.password
|
|
|
enc_password = self._encrypt_password(password)
|
|
enc_password = self._encrypt_password(password)
|
|
@@ -378,16 +332,12 @@ class VfsPlugin(IVSPlg):
|
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=data)
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=data)
|
|
|
resp_json = resp.json()
|
|
resp_json = resp.json()
|
|
|
|
|
|
|
|
- # 分支 1: 登录成功
|
|
|
|
|
if resp_json.get('accessToken'):
|
|
if resp_json.get('accessToken'):
|
|
|
self.jwt_token = resp_json["accessToken"]
|
|
self.jwt_token = resp_json["accessToken"]
|
|
|
self._log("Login successful, JWT obtained.")
|
|
self._log("Login successful, JWT obtained.")
|
|
|
|
|
|
|
|
- # 分支 2: OTP
|
|
|
|
|
elif resp_json.get("enableOTPAuthentication"):
|
|
elif resp_json.get("enableOTPAuthentication"):
|
|
|
self._log("Login requires OTP.")
|
|
self._log("Login requires OTP.")
|
|
|
- # 注意:_submit_login_otp 内部也会调用 _refresh_turnstile_token
|
|
|
|
|
- # 所以这里旧的 cf_token 其实用处不大,传过去也没事
|
|
|
|
|
otp = self._read_otp_email(sent_at=sent_at)
|
|
otp = self._read_otp_email(sent_at=sent_at)
|
|
|
self._submit_login_otp(cf_token, otp)
|
|
self._submit_login_otp(cf_token, otp)
|
|
|
|
|
|
|
@@ -442,19 +392,11 @@ class VfsPlugin(IVSPlg):
|
|
|
if not self.page:
|
|
if not self.page:
|
|
|
raise BizLogicError("Browser session not initialized")
|
|
raise BizLogicError("Browser session not initialized")
|
|
|
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 1. 预处理 URL (构造最终请求地址)
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
req_url = url
|
|
req_url = url
|
|
|
if params:
|
|
if params:
|
|
|
- # 确保引用了 urllib
|
|
|
|
|
- import urllib.parse
|
|
|
|
|
sep = '&' if '?' in req_url else '?'
|
|
sep = '&' if '?' in req_url else '?'
|
|
|
req_url += sep + urllib.parse.urlencode(params)
|
|
req_url += sep + urllib.parse.urlencode(params)
|
|
|
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 2. 构造 Body 和 Fetch 选项
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
final_headers = headers or {}
|
|
final_headers = headers or {}
|
|
|
|
|
|
|
|
fetch_options = {
|
|
fetch_options = {
|
|
@@ -463,7 +405,6 @@ class VfsPlugin(IVSPlg):
|
|
|
"credentials": "include" # 关键:带上浏览器 Cookie
|
|
"credentials": "include" # 关键:带上浏览器 Cookie
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- # 用于日志记录的 Body 内容(字符串形式)
|
|
|
|
|
log_body = "None"
|
|
log_body = "None"
|
|
|
|
|
|
|
|
if json_data:
|
|
if json_data:
|
|
@@ -473,7 +414,6 @@ class VfsPlugin(IVSPlg):
|
|
|
log_body = json_str
|
|
log_body = json_str
|
|
|
elif data:
|
|
elif data:
|
|
|
if isinstance(data, dict):
|
|
if isinstance(data, dict):
|
|
|
- import urllib.parse
|
|
|
|
|
encoded_data = urllib.parse.urlencode(data)
|
|
encoded_data = urllib.parse.urlencode(data)
|
|
|
fetch_options['body'] = encoded_data
|
|
fetch_options['body'] = encoded_data
|
|
|
fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
fetch_options['headers']['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
@@ -482,16 +422,10 @@ class VfsPlugin(IVSPlg):
|
|
|
fetch_options['body'] = data
|
|
fetch_options['body'] = data
|
|
|
log_body = str(data)
|
|
log_body = str(data)
|
|
|
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # [日志] 记录请求数据
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
self._log(f"┌── [TRAFFIC REQUEST] {method} {req_url}")
|
|
self._log(f"┌── [TRAFFIC REQUEST] {method} {req_url}")
|
|
|
self._log(f"├── Headers: {json.dumps(final_headers)}")
|
|
self._log(f"├── Headers: {json.dumps(final_headers)}")
|
|
|
self._log(f"└── Body: {log_body}")
|
|
self._log(f"└── Body: {log_body}")
|
|
|
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 3. 注入 JS 执行 Fetch
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
js_script = f"""
|
|
js_script = f"""
|
|
|
const url = "{req_url}";
|
|
const url = "{req_url}";
|
|
|
const options = {json.dumps(fetch_options)};
|
|
const options = {json.dumps(fetch_options)};
|
|
@@ -525,8 +459,6 @@ class VfsPlugin(IVSPlg):
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
- # run_js 直接返回 return 的对象
|
|
|
|
|
- # 适当增加超时时间,防止网络慢导致 Python 侧报错
|
|
|
|
|
res_dict = self.page.run_js(js_script, timeout=60)
|
|
res_dict = self.page.run_js(js_script, timeout=60)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
self._log(f"[TRAFFIC ERROR] JS Execution failed: {e}")
|
|
self._log(f"[TRAFFIC ERROR] JS Execution failed: {e}")
|
|
@@ -534,20 +466,12 @@ class VfsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
resp = BrowserResponse(res_dict)
|
|
resp = BrowserResponse(res_dict)
|
|
|
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # [日志] 记录响应数据
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
duration = res_dict.get('duration', 0)
|
|
duration = res_dict.get('duration', 0)
|
|
|
- # 截取过长的响应体,避免日志文件爆炸 (保留前 1000 字符)
|
|
|
|
|
- # 如果需要完整分析,可以去掉 [:1000]
|
|
|
|
|
resp_preview = resp.text[:1000] + "..." if len(resp.text) > 1000 else resp.text
|
|
resp_preview = resp.text[:1000] + "..." if len(resp.text) > 1000 else resp.text
|
|
|
|
|
|
|
|
self._log(f"┌── [TRAFFIC RESPONSE] Status: {resp.status_code} | Time: {duration}ms")
|
|
self._log(f"┌── [TRAFFIC RESPONSE] Status: {resp.status_code} | Time: {duration}ms")
|
|
|
self._log(f"└── Body: {resp_preview}")
|
|
self._log(f"└── Body: {resp_preview}")
|
|
|
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
- # 4. 统一处理状态码
|
|
|
|
|
- # ---------------------------------------------------------
|
|
|
|
|
if resp.status_code == 200:
|
|
if resp.status_code == 200:
|
|
|
return resp
|
|
return resp
|
|
|
|
|
|
|
@@ -556,25 +480,20 @@ class VfsPlugin(IVSPlg):
|
|
|
raise SessionExpiredOrInvalidError(f"401 Unauthorized: {resp.text[:100]}")
|
|
raise SessionExpiredOrInvalidError(f"401 Unauthorized: {resp.text[:100]}")
|
|
|
|
|
|
|
|
elif resp.status_code == 403:
|
|
elif resp.status_code == 403:
|
|
|
- # 检查是否是 Cloudflare 拦截
|
|
|
|
|
if "Just a moment" in resp.text or "cloudflare" in resp.text.lower():
|
|
if "Just a moment" in resp.text or "cloudflare" in resp.text.lower():
|
|
|
self._log(f"[TRAFFIC] HTTP 403 (Cloudflare) detected. Re-verifying (Try {retry_count+1}/3)...")
|
|
self._log(f"[TRAFFIC] HTTP 403 (Cloudflare) detected. Re-verifying (Try {retry_count+1}/3)...")
|
|
|
|
|
|
|
|
if retry_count < 3:
|
|
if retry_count < 3:
|
|
|
- # 调用过盾逻辑
|
|
|
|
|
new_token = self._refresh_turnstile_token()
|
|
new_token = self._refresh_turnstile_token()
|
|
|
|
|
|
|
|
if new_token:
|
|
if new_token:
|
|
|
self._log("[TRAFFIC] In-page verification success. Retrying...")
|
|
self._log("[TRAFFIC] In-page verification success. Retrying...")
|
|
|
|
|
|
|
|
- # 如果原请求包含验证码字段,更新它
|
|
|
|
|
if json_data and "captcha_api_key" in json_data:
|
|
if json_data and "captcha_api_key" in json_data:
|
|
|
json_data["captcha_api_key"] = new_token
|
|
json_data["captcha_api_key"] = new_token
|
|
|
|
|
|
|
|
- # 递归重试
|
|
|
|
|
return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
|
|
return self._perform_request(method, url, headers, data, json_data, params, retry_count+1)
|
|
|
|
|
|
|
|
- # 如果不是 CF 或者重试耗尽
|
|
|
|
|
raise PermissionDeniedError(f"HTTP 403 Forbidden: {resp.text[:100]}")
|
|
raise PermissionDeniedError(f"HTTP 403 Forbidden: {resp.text[:100]}")
|
|
|
|
|
|
|
|
elif resp.status_code == 429:
|
|
elif resp.status_code == 429:
|
|
@@ -585,11 +504,9 @@ class VfsPlugin(IVSPlg):
|
|
|
raise BizLogicError(f"Network Error (Fetch Failed): {resp.text}")
|
|
raise BizLogicError(f"Network Error (Fetch Failed): {resp.text}")
|
|
|
|
|
|
|
|
else:
|
|
else:
|
|
|
- # 允许 400 业务错误通过,交给上层解析 (例如登录失败)
|
|
|
|
|
if url.endswith("/login") and resp.status_code == 400:
|
|
if url.endswith("/login") and resp.status_code == 400:
|
|
|
return resp
|
|
return resp
|
|
|
|
|
|
|
|
- # 其他错误视为业务逻辑异常
|
|
|
|
|
raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
|
|
raise BizLogicError(message=f"HTTP Error {resp.status_code}: {resp.text[:100]}")
|
|
|
|
|
|
|
|
def _handle_cookie_banner(self):
|
|
def _handle_cookie_banner(self):
|
|
@@ -638,20 +555,15 @@ class VfsPlugin(IVSPlg):
|
|
|
不带 Origin: visa.vfsglobal.com,也不带 credentials,符合 ipify 规则。
|
|
不带 Origin: visa.vfsglobal.com,也不带 credentials,符合 ipify 规则。
|
|
|
"""
|
|
"""
|
|
|
try:
|
|
try:
|
|
|
- # 1. 新建一个标签页 (后台静默打开)
|
|
|
|
|
tab = self.page.new_tab("https://api.ipify.org/?format=json")
|
|
tab = self.page.new_tab("https://api.ipify.org/?format=json")
|
|
|
|
|
|
|
|
- # 2. 获取页面内容 (DrissionPage 会自动等待页面加载)
|
|
|
|
|
- # ipify 返回的是纯 JSON 文本,通常在 body 或 pre 标签里
|
|
|
|
|
if tab.ele('tag:pre'):
|
|
if tab.ele('tag:pre'):
|
|
|
json_text = tab.ele('tag:pre').text
|
|
json_text = tab.ele('tag:pre').text
|
|
|
else:
|
|
else:
|
|
|
json_text = tab.ele('tag:body').text
|
|
json_text = tab.ele('tag:body').text
|
|
|
|
|
|
|
|
- # 3. 提取 IP
|
|
|
|
|
ip = json.loads(json_text)['ip']
|
|
ip = json.loads(json_text)['ip']
|
|
|
|
|
|
|
|
- # 4. 务必关闭标签页,释放资源
|
|
|
|
|
tab.close()
|
|
tab.close()
|
|
|
|
|
|
|
|
self._log(f"Real Network IP: {ip}")
|
|
self._log(f"Real Network IP: {ip}")
|
|
@@ -659,7 +571,6 @@ class VfsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
self._log(f"[WARN] Failed to check IP via new tab: {e}")
|
|
self._log(f"[WARN] Failed to check IP via new tab: {e}")
|
|
|
- # 尝试清理可能没关掉的标签页
|
|
|
|
|
try:
|
|
try:
|
|
|
if self.page.tabs_count > 1:
|
|
if self.page.tabs_count > 1:
|
|
|
tab.close()
|
|
tab.close()
|
|
@@ -677,14 +588,9 @@ class VfsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
h = {
|
|
h = {
|
|
|
"accept": "application/json, text/plain, */*",
|
|
"accept": "application/json, text/plain, */*",
|
|
|
- # "origin": ... 浏览器自动处理
|
|
|
|
|
- # "referer": ... 浏览器自动处理
|
|
|
|
|
"route": route
|
|
"route": route
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- # 即使是浏览器环境,VFS 也需要这两个加密参数
|
|
|
|
|
- # 注意:这里可能需要从 JS 获取,或者保持 Python 生成
|
|
|
|
|
- # 如果 Python 生成的总是报错,可以考虑把加密逻辑移到 JS 里跑
|
|
|
|
|
h["clientsource"] = self._get_client_source()
|
|
h["clientsource"] = self._get_client_source()
|
|
|
|
|
|
|
|
if with_auth and self.jwt_token:
|
|
if with_auth and self.jwt_token:
|
|
@@ -725,9 +631,6 @@ class VfsPlugin(IVSPlg):
|
|
|
"payCode": ""
|
|
"payCode": ""
|
|
|
}
|
|
}
|
|
|
headers = self._get_common_headers(with_auth=True)
|
|
headers = self._get_common_headers(with_auth=True)
|
|
|
- # fetch 不需要显式 content-type application/json,json_data会自动处理
|
|
|
|
|
-
|
|
|
|
|
- # DrissionPage 不需要手动处理 403 绕盾,因为浏览器本身就在盾后面
|
|
|
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=data, retry_count=2)
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=data, retry_count=2)
|
|
|
|
|
|
|
|
if "WaitList" in resp.text:
|
|
if "WaitList" in resp.text:
|
|
@@ -819,8 +722,6 @@ class VfsPlugin(IVSPlg):
|
|
|
def _submit_login_otp(self, old_cf_token: str, otp: str):
|
|
def _submit_login_otp(self, old_cf_token: str, otp: str):
|
|
|
self._log("Submitting Login OTP...")
|
|
self._log("Submitting Login OTP...")
|
|
|
|
|
|
|
|
- # --- [新增] 必须刷新 Token ---
|
|
|
|
|
- # 旧的 old_cf_token 已经在第一步登录时失效了
|
|
|
|
|
new_cf_token = self._refresh_turnstile_token()
|
|
new_cf_token = self._refresh_turnstile_token()
|
|
|
# ---------------------------
|
|
# ---------------------------
|
|
|
|
|
|
|
@@ -859,7 +760,6 @@ class VfsPlugin(IVSPlg):
|
|
|
self._log("OTP Login successful.")
|
|
self._log("OTP Login successful.")
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
- # 增加错误详情日志
|
|
|
|
|
error_desc = resp_json.get("description", resp.text)
|
|
error_desc = resp_json.get("description", resp.text)
|
|
|
raise PermissionDeniedError(message=f"OTP Login Failed: {error_desc}")
|
|
raise PermissionDeniedError(message=f"OTP Login Failed: {error_desc}")
|
|
|
|
|
|
|
@@ -869,7 +769,6 @@ class VfsPlugin(IVSPlg):
|
|
|
"""
|
|
"""
|
|
|
self._log("Refreshing Cloudflare Turnstile token...")
|
|
self._log("Refreshing Cloudflare Turnstile token...")
|
|
|
|
|
|
|
|
- # 1. JS 强制重置 (保持不变)
|
|
|
|
|
js_reset = """
|
|
js_reset = """
|
|
|
try {
|
|
try {
|
|
|
var input = document.querySelector('input[name="cf-turnstile-response"]');
|
|
var input = document.querySelector('input[name="cf-turnstile-response"]');
|
|
@@ -881,16 +780,11 @@ class VfsPlugin(IVSPlg):
|
|
|
"""
|
|
"""
|
|
|
self.page.run_js(js_reset)
|
|
self.page.run_js(js_reset)
|
|
|
|
|
|
|
|
- # 2. 初始化过盾助手
|
|
|
|
|
- # 假设 CloudflareBypasser 类已在当前文件中定义
|
|
|
|
|
cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
|
|
cf_bypasser = CloudflareBypasser(self.page, log=self.config.debug)
|
|
|
-
|
|
|
|
|
- # 3. 轮询等待 (30秒)
|
|
|
|
|
|
|
+
|
|
|
for i in range(60):
|
|
for i in range(60):
|
|
|
time.sleep(0.5)
|
|
time.sleep(0.5)
|
|
|
|
|
|
|
|
- # A. 检查 Token 是否已生成
|
|
|
|
|
- # 使用 DrissionPage 的方式获取 value 比较稳定
|
|
|
|
|
try:
|
|
try:
|
|
|
ele = self.page.ele('@name=cf-turnstile-response')
|
|
ele = self.page.ele('@name=cf-turnstile-response')
|
|
|
if ele and ele.value:
|
|
if ele and ele.value:
|
|
@@ -899,18 +793,11 @@ class VfsPlugin(IVSPlg):
|
|
|
except:
|
|
except:
|
|
|
pass
|
|
pass
|
|
|
|
|
|
|
|
- # B. 尝试点击验证框
|
|
|
|
|
- # 策略:前2秒等待,之后开始尝试点击
|
|
|
|
|
if i > 4:
|
|
if i > 4:
|
|
|
- # [重要] VFS 经常有 Cookie 弹窗遮挡,先尝试清理一下
|
|
|
|
|
self._handle_cookie_banner()
|
|
self._handle_cookie_banner()
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
- # 使用 CloudflareBypasser 的高级点击逻辑
|
|
|
|
|
- # is_dfs=True 表示如果普通搜索找不到,就递归搜索 iframe (更耗时但更强)
|
|
|
|
|
- # 我们在尝试 10 次 (5秒) 后开启 DFS 模式
|
|
|
|
|
- use_dfs = (i > 14)
|
|
|
|
|
-
|
|
|
|
|
|
|
+ use_dfs = (i > 14)
|
|
|
cf_bypasser.click_verification_button(is_dfs=use_dfs)
|
|
cf_bypasser.click_verification_button(is_dfs=use_dfs)
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
# 点击过程报错不要中断主循环
|
|
# 点击过程报错不要中断主循环
|
|
@@ -924,14 +811,11 @@ class VfsPlugin(IVSPlg):
|
|
|
"""
|
|
"""
|
|
|
self._log("Starting booking process...")
|
|
self._log("Starting booking process...")
|
|
|
|
|
|
|
|
- # 1. 准备数据
|
|
|
|
|
user_email = user_inputs.get('email')
|
|
user_email = user_inputs.get('email')
|
|
|
- # 生成别名邮箱 (防止邮箱被 VFS 黑名单)
|
|
|
|
|
user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
|
|
user_inputs['alias_email'] = get_alias_email(user_email, new_domain="gmail-app.com")
|
|
|
|
|
|
|
|
res = VSBookResult()
|
|
res = VSBookResult()
|
|
|
app_type = slot_info.apt_type
|
|
app_type = slot_info.apt_type
|
|
|
- # 如果没有 earliest_date,默认从今天开始
|
|
|
|
|
from_date = slot_info.earliest_date.strftime("%Y-%m-%d") if slot_info.earliest_date else datetime.now().strftime("%Y-%m-%d")
|
|
from_date = slot_info.earliest_date.strftime("%Y-%m-%d") if slot_info.earliest_date else datetime.now().strftime("%Y-%m-%d")
|
|
|
|
|
|
|
|
apt_config = self.free_config.get("apt_configs", {}).get(app_type.routing_key)
|
|
apt_config = self.free_config.get("apt_configs", {}).get(app_type.routing_key)
|
|
@@ -939,14 +823,11 @@ class VfsPlugin(IVSPlg):
|
|
|
if not apt_config:
|
|
if not apt_config:
|
|
|
raise NotFoundError(message="Book: Config missing for this routing key.")
|
|
raise NotFoundError(message="Book: Config missing for this routing key.")
|
|
|
|
|
|
|
|
- # 确保配置已加载 (SubCategory 等)
|
|
|
|
|
self._fetch_configurations(apt_config)
|
|
self._fetch_configurations(apt_config)
|
|
|
|
|
|
|
|
sub_cc = apt_config.get("subcategory_code")
|
|
sub_cc = apt_config.get("subcategory_code")
|
|
|
sub_conf = self.subcategory_conf.get(sub_cc, {})
|
|
sub_conf = self.subcategory_conf.get(sub_cc, {})
|
|
|
|
|
|
|
|
- # 3. OCR 识别 / 文档上传 (如果需要)
|
|
|
|
|
- # 上传结果存入 user_inputs 供后续使用
|
|
|
|
|
ocr_enabled = sub_conf.get("isOCREnable", False)
|
|
ocr_enabled = sub_conf.get("isOCREnable", False)
|
|
|
if ocr_enabled:
|
|
if ocr_enabled:
|
|
|
self._log("OCR Enabled, uploading documents...")
|
|
self._log("OCR Enabled, uploading documents...")
|
|
@@ -957,7 +838,6 @@ class VfsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
enable_reference_number = sub_conf.get("enableReferenceNumber", False)
|
|
enable_reference_number = sub_conf.get("enableReferenceNumber", False)
|
|
|
|
|
|
|
|
- # 4. 添加申请人 (核心步骤 1)
|
|
|
|
|
final_urn = None
|
|
final_urn = None
|
|
|
is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
|
|
is_waitlist = (slot_info.availability_status == AvailabilityStatus.Waitlist)
|
|
|
|
|
|
|
@@ -966,23 +846,23 @@ class VfsPlugin(IVSPlg):
|
|
|
time.sleep(20)
|
|
time.sleep(20)
|
|
|
self.booking_wait_applied = True
|
|
self.booking_wait_applied = True
|
|
|
|
|
|
|
|
- # 重试机制:添加申请人有时候会因为并发冲突失败
|
|
|
|
|
- MAX_RETRY = 4
|
|
|
|
|
|
|
+ MAX_RETRY = 2
|
|
|
for i in range(MAX_RETRY):
|
|
for i in range(MAX_RETRY):
|
|
|
try:
|
|
try:
|
|
|
final_urn = self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number)
|
|
final_urn = self._add_primary_applicant(apt_config, user_inputs, is_waitlist, ocr_enabled, enable_reference_number)
|
|
|
- if final_urn:
|
|
|
|
|
- break
|
|
|
|
|
- except Exception as e:
|
|
|
|
|
- self._log(f"Add Applicant retry {i+1}/{MAX_RETRY}: {e}")
|
|
|
|
|
- time.sleep(5.0)
|
|
|
|
|
|
|
+ break
|
|
|
|
|
+ except BizLogicError as e:
|
|
|
|
|
+ err_msg = str(e)
|
|
|
|
|
+ self._log(f"Add Applicant retry {i+1}/{MAX_RETRY}: {err_msg}")
|
|
|
|
|
+ if 'Capping has exceeded' in err_msg:
|
|
|
|
|
+ raise e
|
|
|
|
|
+ time.sleep(10.0)
|
|
|
|
|
|
|
|
if not final_urn:
|
|
if not final_urn:
|
|
|
raise BizLogicError(message="Failed to add primary applicant (Slot likely taken or API error)")
|
|
raise BizLogicError(message="Failed to add primary applicant (Slot likely taken or API error)")
|
|
|
|
|
|
|
|
self._log(f"Applicant Added. URN: {final_urn}")
|
|
self._log(f"Applicant Added. URN: {final_urn}")
|
|
|
|
|
|
|
|
- # 5. 申请人 OTP 验证 (核心步骤 2 - 视配置而定)
|
|
|
|
|
otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
|
|
otp_enabled = sub_conf.get("isApplicantOTPEnabled", False)
|
|
|
if otp_enabled:
|
|
if otp_enabled:
|
|
|
self._log("Applicant OTP Required.")
|
|
self._log("Applicant OTP Required.")
|
|
@@ -991,12 +871,10 @@ class VfsPlugin(IVSPlg):
|
|
|
if not self._applicant_otp_send(apt_config, final_urn):
|
|
if not self._applicant_otp_send(apt_config, final_urn):
|
|
|
raise BizLogicError(message='Applicant OTP send failed')
|
|
raise BizLogicError(message='Applicant OTP send failed')
|
|
|
|
|
|
|
|
- # 复用之前的读邮件逻辑
|
|
|
|
|
otp_code = self._read_otp_email(sent_at=sent_at)
|
|
otp_code = self._read_otp_email(sent_at=sent_at)
|
|
|
if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
|
|
if not self._applicant_otp_verify(apt_config, final_urn, otp_code):
|
|
|
raise BizLogicError(message='Applicant OTP verify failed')
|
|
raise BizLogicError(message='Applicant OTP verify failed')
|
|
|
|
|
|
|
|
- # 6. Waitlist 模式直接返回
|
|
|
|
|
if is_waitlist:
|
|
if is_waitlist:
|
|
|
if self._confirm_waitlist(apt_config, final_urn):
|
|
if self._confirm_waitlist(apt_config, final_urn):
|
|
|
res.success = True
|
|
res.success = True
|
|
@@ -1006,11 +884,9 @@ class VfsPlugin(IVSPlg):
|
|
|
return res
|
|
return res
|
|
|
raise BizLogicError(message='Confirm waitlist failed')
|
|
raise BizLogicError(message='Confirm waitlist failed')
|
|
|
|
|
|
|
|
- # 7. 寻找具体的时间槽 (核心步骤 3)
|
|
|
|
|
expected_start = user_inputs.get("expected_start_date", "")
|
|
expected_start = user_inputs.get("expected_start_date", "")
|
|
|
expected_end = user_inputs.get("expected_end_date", "")
|
|
expected_end = user_inputs.get("expected_end_date", "")
|
|
|
|
|
|
|
|
- # 计算需要扫描的月份
|
|
|
|
|
months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
|
|
months = self._get_filtered_covered_months(expected_start, expected_end, from_date)
|
|
|
self._log(f"Scanning months: {months} (Start looking from: {from_date})")
|
|
self._log(f"Scanning months: {months} (Start looking from: {from_date})")
|
|
|
|
|
|
|
@@ -1024,40 +900,31 @@ class VfsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
for m_str in months:
|
|
for m_str in months:
|
|
|
self._log(f"Checking calendar for {m_str}...")
|
|
self._log(f"Checking calendar for {m_str}...")
|
|
|
- # 查询日历
|
|
|
|
|
ads = self._query_slot_calendar(apt_config, final_urn, m_str)
|
|
ads = self._query_slot_calendar(apt_config, final_urn, m_str)
|
|
|
|
|
|
|
|
- # 去重
|
|
|
|
|
new_ads = [d for d in ads if d not in all_ads]
|
|
new_ads = [d for d in ads if d not in all_ads]
|
|
|
all_ads.update(new_ads)
|
|
all_ads.update(new_ads)
|
|
|
|
|
|
|
|
- # 尝试选中一个日期
|
|
|
|
|
- # 这里做一个简单循环,如果选中日期没时间了,就换一个日期
|
|
|
|
|
for _ in range(3):
|
|
for _ in range(3):
|
|
|
avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
|
|
avail_candidates = [d for d in list(all_ads) if d not in forbidden_dates]
|
|
|
- # 根据用户期望过滤
|
|
|
|
|
sel_dates = self._filter_dates(avail_candidates, expected_start, expected_end)
|
|
sel_dates = self._filter_dates(avail_candidates, expected_start, expected_end)
|
|
|
|
|
|
|
|
if not sel_dates:
|
|
if not sel_dates:
|
|
|
- break # 当前月没有符合要求的日期,去下一个月
|
|
|
|
|
-
|
|
|
|
|
- tmp_date = sel_dates[0] # 取第一个(通常 _filter_dates 里已经 shuffle 过了)
|
|
|
|
|
- forbidden_dates.add(tmp_date) # 标记为已尝试
|
|
|
|
|
|
|
+ break
|
|
|
|
|
|
|
|
- # 关键:Audit Log (锁定日期)
|
|
|
|
|
- # VFS 要求在查 timeslot 之前必须先发这个请求
|
|
|
|
|
|
|
+ tmp_date = sel_dates[0]
|
|
|
|
|
+ forbidden_dates.add(tmp_date)
|
|
|
|
|
+
|
|
|
if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
|
|
if not self._saveuseractionaudit(apt_config, final_urn, tmp_date):
|
|
|
self._log(f"Audit failed for {tmp_date}, skipping...")
|
|
self._log(f"Audit failed for {tmp_date}, skipping...")
|
|
|
time.sleep(1)
|
|
time.sleep(1)
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
- # 查询具体时间
|
|
|
|
|
ats = self._query_slot_time(apt_config, final_urn, tmp_date)
|
|
ats = self._query_slot_time(apt_config, final_urn, tmp_date)
|
|
|
if not ats:
|
|
if not ats:
|
|
|
self._log(f"No timeslots for {tmp_date}")
|
|
self._log(f"No timeslots for {tmp_date}")
|
|
|
continue
|
|
continue
|
|
|
|
|
|
|
|
- # 随机选一个时间
|
|
|
|
|
sel_tm = random.choice(ats)
|
|
sel_tm = random.choice(ats)
|
|
|
|
|
|
|
|
selected_slot_id = sel_tm.get("allocationId")
|
|
selected_slot_id = sel_tm.get("allocationId")
|
|
@@ -1077,11 +944,9 @@ class VfsPlugin(IVSPlg):
|
|
|
|
|
|
|
|
self._log(f"Slot Selected: {selected_slot_date} {selected_slot_time_range} (ID: {selected_slot_id})")
|
|
self._log(f"Slot Selected: {selected_slot_date} {selected_slot_time_range} (ID: {selected_slot_id})")
|
|
|
|
|
|
|
|
- # 8. 服务与费用 (核心步骤 4)
|
|
|
|
|
self._submit_no_addition_service(final_urn)
|
|
self._submit_no_addition_service(final_urn)
|
|
|
amount, currency = self._query_fee(apt_config, final_urn)
|
|
amount, currency = self._query_fee(apt_config, final_urn)
|
|
|
|
|
|
|
|
- # 9. 最终提交
|
|
|
|
|
self._log("Submitting schedule...")
|
|
self._log("Submitting schedule...")
|
|
|
schedule_res = self._schedule(apt_config, final_urn, amount, currency, selected_slot_id)
|
|
schedule_res = self._schedule(apt_config, final_urn, amount, currency, selected_slot_id)
|
|
|
|
|
|
|
@@ -1090,7 +955,6 @@ class VfsPlugin(IVSPlg):
|
|
|
res.success = False
|
|
res.success = False
|
|
|
return res
|
|
return res
|
|
|
|
|
|
|
|
- # 10. 构造成功结果
|
|
|
|
|
res.success = True
|
|
res.success = True
|
|
|
res.account = self.config.account.username
|
|
res.account = self.config.account.username
|
|
|
res.book_date = selected_slot_date
|
|
res.book_date = selected_slot_date
|
|
@@ -1099,7 +963,6 @@ class VfsPlugin(IVSPlg):
|
|
|
res.fee_amount = int(amount * 100)
|
|
res.fee_amount = int(amount * 100)
|
|
|
res.fee_currency = currency
|
|
res.fee_currency = currency
|
|
|
|
|
|
|
|
- # 11. 处理支付链接
|
|
|
|
|
if schedule_res.get("IsPaymentRequired", False):
|
|
if schedule_res.get("IsPaymentRequired", False):
|
|
|
payload = schedule_res.get("payLoad", "")
|
|
payload = schedule_res.get("payLoad", "")
|
|
|
if payload:
|
|
if payload:
|
|
@@ -1131,7 +994,6 @@ class VfsPlugin(IVSPlg):
|
|
|
if not passport_url:
|
|
if not passport_url:
|
|
|
raise NotFoundError(message="Missing passport_image_url")
|
|
raise NotFoundError(message="Missing passport_image_url")
|
|
|
|
|
|
|
|
- # 下载图片 (不走代理或走系统代理,不使用 DrissionPage,因为是外部链接)
|
|
|
|
|
try:
|
|
try:
|
|
|
img_resp = standard_requests.get(passport_url, timeout=30)
|
|
img_resp = standard_requests.get(passport_url, timeout=30)
|
|
|
if img_resp.status_code != 200:
|
|
if img_resp.status_code != 200:
|
|
@@ -1141,7 +1003,6 @@ class VfsPlugin(IVSPlg):
|
|
|
raise BizLogicError(message=f"Image download error: {e}")
|
|
raise BizLogicError(message=f"Image download error: {e}")
|
|
|
|
|
|
|
|
headers = self._get_common_headers(with_auth=True)
|
|
headers = self._get_common_headers(with_auth=True)
|
|
|
- # DrissionPage fetch 不需要显式 content-type application/json,json_data会自动处理
|
|
|
|
|
|
|
|
|
|
data = {
|
|
data = {
|
|
|
"missioncode": self.free_config.get("mission_code"),
|
|
"missioncode": self.free_config.get("mission_code"),
|
|
@@ -1157,7 +1018,6 @@ class VfsPlugin(IVSPlg):
|
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=data)
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=data)
|
|
|
result = resp.json()
|
|
result = resp.json()
|
|
|
|
|
|
|
|
- # 补充返回数据供后续使用
|
|
|
|
|
result["passportImageFilename"] = "passport_img.jpg"
|
|
result["passportImageFilename"] = "passport_img.jpg"
|
|
|
result["passportImageFileBytes"] = b64_str
|
|
result["passportImageFileBytes"] = b64_str
|
|
|
return result
|
|
return result
|
|
@@ -1277,7 +1137,11 @@ class VfsPlugin(IVSPlg):
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=payload)
|
|
resp = self._perform_request("POST", url, headers=headers, json_data=payload)
|
|
|
- return resp.json().get("urn")
|
|
|
|
|
|
|
+ urn = resp.json().get("urn")
|
|
|
|
|
+ if not urn:
|
|
|
|
|
+ err_msg = resp.json().get('error')
|
|
|
|
|
+ raise BizLogicError(message=str(err_msg))
|
|
|
|
|
+ return urn
|
|
|
|
|
|
|
|
def _applicant_otp_send(self, apt_config, urn) -> bool:
|
|
def _applicant_otp_send(self, apt_config, urn) -> bool:
|
|
|
url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
|
|
url = "https://lift-api.vfsglobal.com/appointment/applicantotp"
|
|
@@ -1392,7 +1256,6 @@ class VfsPlugin(IVSPlg):
|
|
|
"urn": urn,
|
|
"urn": urn,
|
|
|
"applicants": []
|
|
"applicants": []
|
|
|
}
|
|
}
|
|
|
- # 只要不报错即可
|
|
|
|
|
self._perform_request("POST", url, headers=headers, json_data=data)
|
|
self._perform_request("POST", url, headers=headers, json_data=data)
|
|
|
|
|
|
|
|
def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
|
|
def _query_fee(self, apt_config, urn) -> Tuple[float, str]:
|
|
@@ -1442,7 +1305,6 @@ class VfsPlugin(IVSPlg):
|
|
|
"""
|
|
"""
|
|
|
解析支付重定向 URL (DrissionPage 新标签页版)
|
|
解析支付重定向 URL (DrissionPage 新标签页版)
|
|
|
"""
|
|
"""
|
|
|
- # 初始 URL,通常是一个 Redirect 接口
|
|
|
|
|
start_url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
|
|
start_url = f"https://online.vfsglobal.com/PG-Component/Payment/PayRequest?payLoad={payload}"
|
|
|
final_url = ""
|
|
final_url = ""
|
|
|
|
|
|
|
@@ -1458,7 +1320,6 @@ class VfsPlugin(IVSPlg):
|
|
|
final_url = pay_tab.url
|
|
final_url = pay_tab.url
|
|
|
self._log(f"Payment URL resolved: {final_url}")
|
|
self._log(f"Payment URL resolved: {final_url}")
|
|
|
|
|
|
|
|
- # 关闭标签页
|
|
|
|
|
pay_tab.close()
|
|
pay_tab.close()
|
|
|
|
|
|
|
|
except Exception as e:
|
|
except Exception as e:
|