Skip to content

Commit c33293d

Browse files
authored
Merge pull request #197 from Wolf20180414/codex/proxy-pool-normalization-20260828
Fix proxy pool normalization accounting
2 parents 0af4276 + b035849 commit c33293d

8 files changed

Lines changed: 283 additions & 18 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ logs/
2323
accounts.txt
2424
*_accounts.txt
2525
account.txt
26+
proxies.txt
2627
mail/
2728
smstome_all_numbers.txt
2829
smstome*_numbers.txt

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- [Docker 部署](#docker-部署)
3232
- [插件与外部依赖](#插件与外部依赖)
3333
- [常见问题排查](#常见问题排查)
34+
- [更新记录](#更新记录)
3435
- [项目结构](#项目结构)
3536
- [Electron 开发说明](#electron-开发说明)
3637
- [用户讨论群](#用户讨论群)
@@ -597,6 +598,15 @@ node --version
597598

598599
Sentinel PoW 求解器要在 Node 沙箱里跑 OpenAI 的 `sdk.js`,没有 Node 时算出来的 token 过不了服务端复核,**验证码邮件会被静默丢弃**——日志上看不到明显报错,但码永远收不到。若 `node` 不在 `PATH` 里,用 `OPENAI_SENTINEL_NODE_PATH` 指定绝对路径。
599600

601+
## 更新记录
602+
603+
### 2026-08-28
604+
605+
- 优化代理池对 `host:port:user:pass` 格式的兼容性,代理检测与浏览器执行器会使用一致的规范化代理配置。
606+
- 代理健康统计现在能准确回写到原始代理记录;检测成功会自动恢复启用,从未成功且连续失败的代理会自动停用。
607+
608+
完整历史请查看 [docs/releases/release-notes.md](docs/releases/release-notes.md)
609+
600610
## 项目结构
601611

602612
```text

core/proxy_pool.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
1-
"""代理池 - 从数据库读取代理,支持轮询和按区域选取"""
1+
"""Proxy pool backed by the application database."""
22

3+
from datetime import datetime, timezone
4+
import threading
35
from typing import Optional
6+
47
from sqlmodel import Session, select
8+
59
from .db import ProxyModel, engine
6-
from .proxy_utils import build_requests_proxy_config
7-
import time, threading, random
8-
from datetime import datetime, timezone
10+
from .proxy_utils import build_requests_proxy_config, normalize_proxy_url
911

1012

1113
class ProxyPool:
1214
def __init__(self):
1315
self._index = 0
1416
self._lock = threading.Lock()
1517

18+
def _find_by_url(self, session: Session, url: str) -> ProxyModel | None:
19+
p = session.exec(select(ProxyModel).where(ProxyModel.url == url)).first()
20+
if p:
21+
return p
22+
23+
normalized = normalize_proxy_url(url)
24+
if not normalized:
25+
return None
26+
27+
if normalized != url:
28+
p = session.exec(
29+
select(ProxyModel).where(ProxyModel.url == normalized)
30+
).first()
31+
if p:
32+
return p
33+
34+
for candidate in session.exec(select(ProxyModel)).all():
35+
if normalize_proxy_url(candidate.url) == normalized:
36+
return candidate
37+
return None
38+
1639
def get_next(self, region: str = "") -> Optional[str]:
17-
"""加权轮询取一个可用代理,在高成功率代理间轮换"""
40+
"""Return the next active proxy, biased toward higher success rate."""
1841
with Session(engine) as s:
1942
q = select(ProxyModel).where(ProxyModel.is_active == True)
2043
if region:
@@ -33,27 +56,27 @@ def get_next(self, region: str = "") -> Optional[str]:
3356

3457
def report_success(self, url: str) -> None:
3558
with Session(engine) as s:
36-
p = s.exec(select(ProxyModel).where(ProxyModel.url == url)).first()
59+
p = self._find_by_url(s, url)
3760
if p:
3861
p.success_count += 1
62+
p.is_active = True
3963
p.last_checked = datetime.now(timezone.utc)
4064
s.add(p)
4165
s.commit()
4266

4367
def report_fail(self, url: str) -> None:
4468
with Session(engine) as s:
45-
p = s.exec(select(ProxyModel).where(ProxyModel.url == url)).first()
69+
p = self._find_by_url(s, url)
4670
if p:
4771
p.fail_count += 1
4872
p.last_checked = datetime.now(timezone.utc)
49-
# 连续失败超过10次自动禁用
5073
if p.fail_count > 0 and p.success_count == 0 and p.fail_count >= 5:
5174
p.is_active = False
5275
s.add(p)
5376
s.commit()
5477

5578
def check_all(self) -> dict:
56-
"""检测所有代理可用性"""
79+
"""Probe all configured proxies against a neutral endpoint."""
5780
import requests
5881

5982
with Session(engine) as s:

core/proxy_utils.py

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,54 @@
11
from __future__ import annotations
22

33
import json
4+
from dataclasses import dataclass
45
from typing import Optional
5-
from urllib.parse import unquote, urlsplit, urlunsplit
6+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
7+
8+
9+
@dataclass(frozen=True)
10+
class _LegacyProxyParts:
11+
scheme: str
12+
host: str
13+
port: str
14+
username: str = ""
15+
password: str = ""
16+
17+
18+
def _parse_legacy_proxy(value: str) -> _LegacyProxyParts | None:
19+
fields = value.split(":")
20+
if len(fields) == 2 and fields[1].isdigit():
21+
return _LegacyProxyParts("http", fields[0], fields[1])
22+
if len(fields) == 4 and fields[1].isdigit():
23+
return _LegacyProxyParts("http", fields[0], fields[1], fields[2], fields[3])
24+
if len(fields) >= 5 and fields[2].isdigit() and fields[0].lower() in {
25+
"http",
26+
"https",
27+
"socks4",
28+
"socks5",
29+
"socks5h",
30+
}:
31+
return _LegacyProxyParts(
32+
fields[0].lower(),
33+
fields[1],
34+
fields[2],
35+
fields[3],
36+
":".join(fields[4:]),
37+
)
38+
return None
39+
40+
41+
def _legacy_to_url(parts: _LegacyProxyParts) -> str:
42+
scheme = "socks5h" if parts.scheme == "socks5" else parts.scheme
43+
host = parts.host
44+
if ":" in host and not host.startswith("["):
45+
host = f"[{host}]"
46+
auth = ""
47+
if parts.username or parts.password:
48+
username = quote(parts.username, safe="")
49+
password = quote(parts.password, safe="")
50+
auth = f"{username}:{password}@"
51+
return f"{scheme}://{auth}{host}:{parts.port}"
652

753

854
def _is_auth_socks_proxy(scheme: str, username: str, password: str) -> bool:
@@ -49,24 +95,65 @@ def normalize_proxy_url(proxy_url: Optional[str]) -> Optional[str]:
4995
if not value:
5096
return None
5197

98+
legacy = _parse_legacy_proxy(value)
99+
if legacy:
100+
return _legacy_to_url(legacy)
101+
52102
parts = urlsplit(value)
53103
if (parts.scheme or "").lower() == "socks5":
54104
parts = parts._replace(scheme="socks5h")
55105
return urlunsplit(parts)
56106
return value
57107

58108

109+
def redact_proxy_url(proxy_url: Optional[str]) -> str:
110+
"""Return a log-safe proxy URL without authentication credentials."""
111+
value = str(proxy_url or "").strip()
112+
if not value:
113+
return ""
114+
115+
legacy = _parse_legacy_proxy(value)
116+
if legacy and "://" not in value:
117+
if legacy.username or legacy.password:
118+
if value.split(":", 1)[0].lower() in {
119+
"http",
120+
"https",
121+
"socks4",
122+
"socks5",
123+
"socks5h",
124+
}:
125+
return f"{legacy.scheme}:{legacy.host}:{legacy.port}:***:***"
126+
return f"{legacy.host}:{legacy.port}:***:***"
127+
return f"{legacy.host}:{legacy.port}"
128+
129+
parts = urlsplit(value)
130+
if not parts.scheme:
131+
return "(configured proxy)"
132+
133+
host = parts.hostname or ""
134+
if ":" in host and not host.startswith("["):
135+
host = f"[{host}]"
136+
try:
137+
port = f":{parts.port}" if parts.port is not None else ""
138+
except ValueError:
139+
return f"{parts.scheme}://(configured proxy)"
140+
auth = (
141+
"***:***@" if parts.username is not None or parts.password is not None else ""
142+
)
143+
return urlunsplit(
144+
(parts.scheme, f"{auth}{host}{port}", parts.path, parts.query, parts.fragment)
145+
)
146+
147+
59148
def build_requests_proxy_config(proxy_url: Optional[str]) -> Optional[dict[str, str]]:
60-
if not proxy_url:
149+
normalized = normalize_proxy_url(proxy_url)
150+
if not normalized:
61151
return None
62-
return {"http": proxy_url, "https": proxy_url}
152+
return {"http": normalized, "https": normalized}
63153

64154

65155
def build_playwright_proxy_config(proxy_url: Optional[str]) -> Optional[dict[str, str]]:
66-
if not proxy_url:
67-
return None
68-
69-
value = str(proxy_url).strip()
156+
value = normalize_proxy_url(proxy_url)
70157
if not value:
71158
return None
72159
parts = urlsplit(value)
@@ -77,8 +164,6 @@ def build_playwright_proxy_config(proxy_url: Optional[str]) -> Optional[dict[str
77164
return {"server": server}
78165

79166
scheme = (parts.scheme or "").lower()
80-
if _is_auth_socks_proxy(scheme, parts.username or "", parts.password or ""):
81-
return None
82167
if scheme == "socks5h":
83168
scheme = "socks5"
84169

docs/releases/release-notes.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Release Notes
2+
3+
### 2026-08-28
4+
5+
- Improved proxy import compatibility for common `host:port:user:pass` entries so proxy checks and browser tasks can use the same stored pool consistently.
6+
- Proxy health accounting now updates the original stored proxy entry even when the runtime uses a normalized URL form.
7+
- Proxies that pass a neutral health check are re-enabled automatically, while never-successful proxies are disabled after repeated failures.

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,13 @@
2020
from core.db import init_db # noqa: E402 必须在 DATABASE_URL 设好之后再 import
2121

2222
init_db()
23+
24+
25+
def pytest_sessionfinish(session, exitstatus):
26+
from core.db import engine
27+
28+
engine.dispose()
29+
try:
30+
_TMP_DB_DIR.cleanup()
31+
except PermissionError:
32+
pass

tests/test_proxy_pool.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from sqlmodel import Session, select
2+
3+
from core.db import ProxyModel, engine
4+
from core.proxy_pool import ProxyPool
5+
6+
7+
def test_report_success_matches_legacy_proxy_after_normalization():
8+
pool = ProxyPool()
9+
raw = "proxy.example:2000:user-name:secret-pass"
10+
normalized = "http://user-name:secret-pass@proxy.example:2000"
11+
12+
with Session(engine) as session:
13+
proxy = ProxyModel(url=raw, is_active=False)
14+
session.add(proxy)
15+
session.commit()
16+
17+
pool.report_success(normalized)
18+
19+
with Session(engine) as session:
20+
proxy = session.exec(select(ProxyModel).where(ProxyModel.url == raw)).one()
21+
assert proxy.success_count == 1
22+
assert proxy.fail_count == 0
23+
assert proxy.is_active is True
24+
assert proxy.last_checked is not None
25+
26+
27+
def test_report_fail_matches_legacy_proxy_after_normalization():
28+
pool = ProxyPool()
29+
raw = "proxy.example:2001:user-name:secret-pass"
30+
normalized = "http://user-name:secret-pass@proxy.example:2001"
31+
32+
with Session(engine) as session:
33+
proxy = ProxyModel(url=raw, is_active=True)
34+
session.add(proxy)
35+
session.commit()
36+
37+
pool.report_fail(normalized)
38+
39+
with Session(engine) as session:
40+
proxy = session.exec(select(ProxyModel).where(ProxyModel.url == raw)).one()
41+
assert proxy.success_count == 0
42+
assert proxy.fail_count == 1
43+
assert proxy.is_active is True
44+
assert proxy.last_checked is not None
45+
46+
47+
def test_report_fail_disables_never_successful_proxy_after_threshold():
48+
pool = ProxyPool()
49+
raw = "proxy.example:2002:user-name:secret-pass"
50+
51+
with Session(engine) as session:
52+
proxy = ProxyModel(url=raw, is_active=True, fail_count=4)
53+
session.add(proxy)
54+
session.commit()
55+
56+
pool.report_fail(raw)
57+
58+
with Session(engine) as session:
59+
proxy = session.exec(select(ProxyModel).where(ProxyModel.url == raw)).one()
60+
assert proxy.success_count == 0
61+
assert proxy.fail_count == 5
62+
assert proxy.is_active is False

0 commit comments

Comments
 (0)