name: Temporary Proxy Fix on: push: branches: - main paths: - .github/apply-proxy-fix-trigger.txt permissions: contents: write jobs: apply-fix: if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: main fetch-depth: 0 - name: Apply minimal proxy fix shell: bash run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" python - <<'PY' from pathlib import Path def replace_once(text, old, new, label): if old not in text: raise SystemExit(f"missing expected block: {label}") return text.replace(old, new, 1) p = Path("grok_register_ttk.py") s = p.read_text(encoding="utf-8-sig") s = replace_once( s, '''import json\n''', '''import json\nimport base64\nimport select\nimport socket\nimport socketserver\nimport ssl\nimport urllib.parse\n''', "proxy helper imports", ) old_get_proxies = '''def get_proxies():\n proxy = config.get("proxy", "")\n if proxy:\n return {"http": proxy, "https": proxy}\n return {}\n''' new_get_proxies = r'''def get_configured_proxy(): return str(config.get("proxy", "") or "").strip() def get_proxies(): proxy = get_configured_proxy() if proxy: return {"http": proxy, "https": proxy} return {} def _parse_proxy_url(proxy): raw = str(proxy or "").strip() if not raw: return None if "://" not in raw: raw = "http://" + raw try: return urllib.parse.urlsplit(raw) except Exception: return None def _safe_proxy_port(parsed): try: return parsed.port except Exception: return None def _proxy_has_auth(proxy): parsed = _parse_proxy_url(proxy) return bool(parsed and parsed.hostname and (parsed.username is not None or parsed.password is not None)) def _strip_proxy_auth(proxy): raw = str(proxy or "").strip() parsed = _parse_proxy_url(raw) if not parsed or not parsed.hostname: return raw host = parsed.hostname if ":" in host and not host.startswith("["): host = f"[{host}]" port = _safe_proxy_port(parsed) netloc = f"{host}:{port}" if port else host stripped = urllib.parse.urlunsplit((parsed.scheme or "http", netloc, parsed.path, parsed.query, parsed.fragment)) if "://" not in raw: return stripped.split("://", 1)[1] return stripped def _proxy_endpoint_terms(proxy=None): parsed = _parse_proxy_url(proxy or get_configured_proxy()) if not parsed or not parsed.hostname: return [] terms = [parsed.hostname] port = _safe_proxy_port(parsed) if port: terms.append(f"{parsed.hostname}:{port}") terms.append(f"port {port}") return [x.lower() for x in terms if x] def is_proxy_connection_error(exc): if not get_configured_proxy(): return False err = str(exc or "").lower() if not err: return False if any(x in err for x in ("proxy", "tunnel", "socks")): return True connect_markers = ( "could not connect", "failed to connect", "connection refused", "connection reset", "connect error", "timed out", "timeout", ) if any(x in err for x in connect_markers): terms = _proxy_endpoint_terms() if not terms or any(t in err for t in terms): return True return False def page_has_proxy_error(page_obj): try: url = str(getattr(page_obj, "url", "") or "") title = str(page_obj.run_js("return document.title || ''") or "") body = str(page_obj.run_js("return document.body ? document.body.innerText.slice(0, 2000) : ''") or "") except Exception: return False text = f"{url}\n{title}\n{body}".lower() return any( marker in text for marker in ( "err_proxy", "proxy connection failed", "proxy server", "proxy authentication", "tunnel connection failed", "无法连接到代理服务器", "代理服务器", ) ) class _ReusableThreadingTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True def _proxy_recv_until_headers(sock, timeout=20, limit=65536): sock.settimeout(timeout) data = b"" while b"\r\n\r\n" not in data and len(data) < limit: chunk = sock.recv(4096) if not chunk: break data += chunk return data def _proxy_relay(left, right, timeout=60): left.settimeout(timeout) right.settimeout(timeout) sockets = [left, right] while True: readable, _, _ = select.select(sockets, [], [], timeout) if not readable: return for sock in readable: data = sock.recv(65536) if not data: return peer = right if sock is left else left peer.sendall(data) class _LocalAuthProxyBridgeHandler(socketserver.BaseRequestHandler): def handle(self): bridge = self.server.bridge upstream = None try: initial = _proxy_recv_until_headers(self.request, timeout=bridge.timeout) if not initial: return first_line = initial.split(b"\r\n", 1)[0].decode("latin1", "ignore") if first_line.upper().startswith("CONNECT "): target = first_line.split()[1] upstream = bridge.open_upstream() req = [f"CONNECT {target} HTTP/1.1", f"Host: {target}"] if bridge.auth_header: req.append(f"Proxy-Authorization: Basic {bridge.auth_header}") upstream.sendall(("\r\n".join(req) + "\r\n\r\n").encode("latin1")) response = _proxy_recv_until_headers(upstream, timeout=bridge.timeout) if response: self.request.sendall(response) status = response.split(b"\r\n", 1)[0] if b" 200 " not in status: return _proxy_relay(self.request, upstream, timeout=bridge.relay_timeout) else: upstream = bridge.open_upstream() upstream.sendall(bridge.inject_proxy_auth(initial)) _proxy_relay(self.request, upstream, timeout=bridge.relay_timeout) except Exception: return finally: if upstream is not None: try: upstream.close() except Exception: pass class LocalAuthProxyBridge: def __init__(self, proxy_url): parsed = _parse_proxy_url(proxy_url) if not parsed or not parsed.hostname: raise ValueError("认证代理地址格式无效") if (parsed.scheme or "http").lower() not in ("http", "https"): raise ValueError("Chromium 本地认证代理桥仅支持 http/https 上游代理") self.upstream_scheme = (parsed.scheme or "http").lower() self.upstream_host = parsed.hostname self.upstream_port = _safe_proxy_port(parsed) or (443 if self.upstream_scheme == "https" else 80) username = urllib.parse.unquote(parsed.username or "") password = urllib.parse.unquote(parsed.password or "") raw_auth = f"{username}:{password}".encode("utf-8") self.auth_header = base64.b64encode(raw_auth).decode("ascii") if (username or password) else "" self.timeout = 20 self.relay_timeout = 90 self.server = None self.thread = None self.local_proxy = "" def open_upstream(self): sock = socket.create_connection((self.upstream_host, self.upstream_port), timeout=self.timeout) if self.upstream_scheme == "https": context = ssl.create_default_context() sock = context.wrap_socket(sock, server_hostname=self.upstream_host) sock.settimeout(self.timeout) return sock def inject_proxy_auth(self, data): if not self.auth_header or b"\r\n\r\n" not in data: return data if b"\r\nproxy-authorization:" in data.lower(): return data head, body = data.split(b"\r\n\r\n", 1) auth_line = f"Proxy-Authorization: Basic {self.auth_header}".encode("latin1") return head + b"\r\n" + auth_line + b"\r\n\r\n" + body def start(self): self.server = _ReusableThreadingTCPServer(("127.0.0.1", 0), _LocalAuthProxyBridgeHandler) self.server.bridge = self port = self.server.server_address[1] self.local_proxy = f"http://127.0.0.1:{port}" self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() return self.local_proxy def stop(self): if self.server is not None: try: self.server.shutdown() self.server.server_close() except Exception: pass self.server = None self.thread = None self.local_proxy = "" def stop_browser_proxy_bridge(): global browser_proxy_bridge if browser_proxy_bridge is not None: try: browser_proxy_bridge.stop() except Exception: pass browser_proxy_bridge = None def prepare_browser_proxy(use_proxy=True, log_callback=None): proxy = get_configured_proxy() if not use_proxy or not proxy: return "", None if _proxy_has_auth(proxy): parsed = _parse_proxy_url(proxy) scheme = (parsed.scheme or "http").lower() if parsed else "" if scheme in ("http", "https"): bridge = LocalAuthProxyBridge(proxy) browser_proxy = bridge.start() if log_callback: log_callback(f"[*] 已为 Chromium 启动本地认证代理桥: {browser_proxy}") return browser_proxy, bridge stripped = _strip_proxy_auth(proxy) if log_callback: log_callback("[!] Chromium 暂不直接支持该认证代理协议,已使用去认证代理地址,失败将回退直连") return stripped, None return proxy, None ''' s = replace_once(s, old_get_proxies, new_get_proxies, "proxy helpers") old_browser_http = '''def create_browser_options():\n options = ChromiumOptions()\n options.auto_port()\n options.set_timeouts(base=1)\n if os.path.exists(EXTENSION_PATH):\n options.add_extension(EXTENSION_PATH)\n return options\n\n\ndef _build_request_kwargs(**kwargs):\n request_kwargs = dict(kwargs)\n proxies = request_kwargs.pop("proxies", None)\n if proxies is None:\n proxies = get_proxies()\n if proxies:\n request_kwargs["proxies"] = proxies\n request_kwargs.setdefault("timeout", 15)\n return request_kwargs\n\n\ndef http_get(url, **kwargs):\n try:\n return requests.get(url, **_build_request_kwargs(**kwargs))\n except Exception as exc:\n err = str(exc)\n # 代理不可用时自动回退为直连,避免整个流程直接失败\n if "127.0.0.1 port 7890" in err or "Could not connect to server" in err:\n retry_kwargs = dict(kwargs)\n retry_kwargs["proxies"] = {}\n return requests.get(url, **_build_request_kwargs(**retry_kwargs))\n raise\n\n\ndef http_post(url, **kwargs):\n try:\n return requests.post(url, **_build_request_kwargs(**kwargs))\n except Exception as exc:\n err = str(exc)\n if "127.0.0.1 port 7890" in err or "Could not connect to server" in err:\n retry_kwargs = dict(kwargs)\n retry_kwargs["proxies"] = {}\n return requests.post(url, **_build_request_kwargs(**retry_kwargs))\n raise\n''' new_browser_http = r'''def apply_browser_proxy_option(options, proxy): if not proxy: return if hasattr(options, "set_proxy"): try: options.set_proxy(proxy) return except Exception: pass if not hasattr(options, "set_argument"): raise AttributeError("当前 DrissionPage ChromiumOptions 不支持设置浏览器代理") try: options.set_argument(f"--proxy-server={proxy}") except TypeError: options.set_argument("--proxy-server", proxy) def create_browser_options(browser_proxy=""): options = ChromiumOptions() options.auto_port() options.set_timeouts(base=1) apply_browser_proxy_option(options, browser_proxy) if os.path.exists(EXTENSION_PATH): options.add_extension(EXTENSION_PATH) return options def _build_request_kwargs(**kwargs): request_kwargs = dict(kwargs) proxies = request_kwargs.pop("proxies", None) if proxies is None: proxies = get_proxies() if proxies: request_kwargs["proxies"] = proxies request_kwargs.setdefault("timeout", 15) return request_kwargs def http_get(url, **kwargs): request_kwargs = _build_request_kwargs(**kwargs) try: return requests.get(url, **request_kwargs) except Exception as exc: # 代理不可用时自动回退为直连,避免整个流程直接失败。 if request_kwargs.get("proxies") and is_proxy_connection_error(exc): retry_kwargs = dict(kwargs) retry_kwargs["proxies"] = {} return requests.get(url, **_build_request_kwargs(**retry_kwargs)) raise def http_post(url, **kwargs): request_kwargs = _build_request_kwargs(**kwargs) try: return requests.post(url, **request_kwargs) except Exception as exc: if request_kwargs.get("proxies") and is_proxy_connection_error(exc): retry_kwargs = dict(kwargs) retry_kwargs["proxies"] = {} return requests.post(url, **_build_request_kwargs(**retry_kwargs)) raise ''' s = replace_once(s, old_browser_http, new_browser_http, "browser options and HTTP proxy fallback") s = s.replace(", proxies={}", "") s = replace_once( s, '''browser = None\npage = None\n''', '''browser = None\npage = None\nbrowser_proxy_bridge = None\nbrowser_started_with_proxy = False\n''', "browser proxy globals", ) old_start_stop = '''def start_browser(log_callback=None):\n global browser, page\n last_exc = None\n for attempt in range(1, 5):\n try:\n browser = Chromium(create_browser_options())\n tabs = browser.get_tabs()\n page = tabs[-1] if tabs else browser.new_tab()\n if log_callback and getattr(browser, "user_data_path", None):\n log_callback(f"[Debug] 当前浏览器资料目录: {browser.user_data_path}")\n if log_callback and attempt > 1:\n log_callback(f"[*] 浏览器第 {attempt} 次启动成功")\n return browser, page\n except Exception as exc:\n last_exc = exc\n if log_callback:\n log_callback(f"[Debug] 浏览器启动失败(第{attempt}/4次): {exc}")\n try:\n if browser is not None:\n browser.quit(del_data=True)\n except Exception:\n pass\n browser = None\n page = None\n time.sleep(min(1.5 * attempt, 4))\n raise Exception(f"浏览器启动失败,已重试4次: {last_exc}")\n\n\ndef stop_browser():\n global browser, page\n if browser is not None:\n try:\n browser.quit(del_data=True)\n except Exception:\n pass\n browser = None\n page = None\n\n\ndef restart_browser(log_callback=None):\n stop_browser()\n return start_browser(log_callback=log_callback)\n''' new_start_stop = r'''def start_browser(log_callback=None, use_proxy=True): global browser, page, browser_proxy_bridge, browser_started_with_proxy last_exc = None proxy_enabled = bool(use_proxy and get_configured_proxy()) for attempt in range(1, 5): bridge = None try: browser_proxy, bridge = prepare_browser_proxy(use_proxy=use_proxy, log_callback=log_callback) browser = Chromium(create_browser_options(browser_proxy=browser_proxy)) browser_proxy_bridge = bridge browser_started_with_proxy = bool(browser_proxy) tabs = browser.get_tabs() page = tabs[-1] if tabs else browser.new_tab() if log_callback and getattr(browser, "user_data_path", None): log_callback(f"[Debug] 当前浏览器资料目录: {browser.user_data_path}") if log_callback and get_configured_proxy(): mode = "代理" if browser_started_with_proxy else "直连" log_callback(f"[*] 浏览器网络模式: {mode}") if log_callback and attempt > 1: log_callback(f"[*] 浏览器第 {attempt} 次启动成功") return browser, page except Exception as exc: last_exc = exc if bridge is not None: try: bridge.stop() except Exception: pass if log_callback: mode = "代理" if proxy_enabled else "直连" log_callback(f"[Debug] 浏览器{mode}启动失败(第{attempt}/4次): {exc}") try: if browser is not None: browser.quit(del_data=True) except Exception: pass browser = None page = None browser_proxy_bridge = None browser_started_with_proxy = False time.sleep(min(1.5 * attempt, 4)) raise Exception(f"浏览器启动失败,已重试4次: {last_exc}") def stop_browser(): global browser, page, browser_started_with_proxy if browser is not None: try: browser.quit(del_data=True) except Exception: pass stop_browser_proxy_bridge() browser = None page = None browser_started_with_proxy = False def restart_browser(log_callback=None, use_proxy=True): stop_browser() return start_browser(log_callback=log_callback, use_proxy=use_proxy) ''' s = replace_once(s, old_start_stop, new_start_stop, "browser lifecycle proxy handling") old_open_signup = '''def open_signup_page(log_callback=None, cancel_callback=None):\n global browser, page\n raise_if_cancelled(cancel_callback)\n if browser is None:\n start_browser()\n if log_callback:\n log_callback("[*] 浏览器已启动")\n try:\n page = browser.get_tab(0)\n page.get(SIGNUP_URL)\n except Exception as e:\n if log_callback:\n log_callback(f"[Debug] 打开URL异常: {e}")\n try:\n page = browser.new_tab(SIGNUP_URL)\n except Exception as e2:\n if log_callback:\n log_callback(f"[Debug] 创建新标签页异常: {e2}")\n restart_browser()\n page = browser.new_tab(SIGNUP_URL)\n page.wait.doc_loaded()\n sleep_with_cancel(2, cancel_callback)\n if log_callback:\n log_callback(f"[*] 当前URL: {page.url}")\n click_email_signup_button(\n log_callback=log_callback, cancel_callback=cancel_callback\n )\n''' new_open_signup = r'''def open_signup_page(log_callback=None, cancel_callback=None): global browser, page raise_if_cancelled(cancel_callback) if browser is None: start_browser(log_callback=log_callback) if log_callback: log_callback("[*] 浏览器已启动") def _open_with_current_browser(): global page try: page = browser.get_tab(0) page.get(SIGNUP_URL) except Exception as e: if log_callback: log_callback(f"[Debug] 打开URL异常: {e}") page = browser.new_tab(SIGNUP_URL) page.wait.doc_loaded() try: _open_with_current_browser() except Exception as e: if browser_started_with_proxy and get_configured_proxy(): if log_callback: log_callback(f"[!] 浏览器代理访问注册页失败,自动回退直连: {e}") restart_browser(log_callback=log_callback, use_proxy=False) _open_with_current_browser() else: raise if browser_started_with_proxy and page_has_proxy_error(page): if log_callback: log_callback("[!] 浏览器页面显示代理错误,自动回退直连") restart_browser(log_callback=log_callback, use_proxy=False) _open_with_current_browser() sleep_with_cancel(2, cancel_callback) if log_callback: log_callback(f"[*] 当前URL: {page.url}") click_email_signup_button( log_callback=log_callback, cancel_callback=cancel_callback ) ''' s = replace_once(s, old_open_signup, new_open_signup, "signup page proxy fallback") p.write_text(s, encoding="utf-8-sig") PY git add grok_register_ttk.py if git diff --cached --quiet; then echo "No proxy fix changes to commit" git reset --quiet else git diff --cached --check git commit -m "fix(proxy): route browser traffic through configured proxy" fi git rm .github/workflows/apply-proxy-fix.yml .github/apply-proxy-fix-trigger.txt git commit -m "chore: remove temporary proxy fix workflow" git push origin HEAD:main