V3.0.0测试版
V3.0.0测试版
No files matched your search
@@ -1,3 +0,0 @@
|
||||
*.js linguist-language=python
|
||||
*.css linguist-language=python
|
||||
*.html linguist-language=python
|
||||
@@ -1,17 +1,43 @@
|
||||
FROM python:3.11
|
||||
FROM python:3.11.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# tzdata 不可省:slim 基础镜像不保证带 /usr/share/zoneinfo,缺失时 compose 里的
|
||||
# TZ=Asia/Shanghai 不生效,日志与访问记录的时间会整体落成 UTC。
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --upgrade pip -i https://pypi.mirrors.ustc.edu.cn/simple/ && \
|
||||
pip install --no-cache-dir -r requirements.txt -i https://pypi.mirrors.ustc.edu.cn/simple/
|
||||
RUN pip install --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN rm -f config/config.ini
|
||||
|
||||
# PUID/PGID 由 compose 从宿主传入,容器用户必须与 bind mount 进来的
|
||||
# config/logs/data 属主一致:镜像里的 chown 对挂载点无效,属主不匹配时容器内的
|
||||
# 非 root 用户写不了这三个目录。宿主 UID/GID 不是 1000 时在 .env 里设 PUID/PGID。
|
||||
ARG PUID=1000
|
||||
ARG PGID=1000
|
||||
RUN groupadd -g ${PGID} proxycat && \
|
||||
useradd -u ${PUID} -g proxycat -M proxycat && \
|
||||
mkdir -p /app/logs /app/modules/proxypool/data /app/modules/proxypool/logs && \
|
||||
chown -R proxycat:proxycat /app
|
||||
USER proxycat
|
||||
|
||||
# VOLUME 必须声明在 chown 之后:声明之后对卷目录内容的改动,旧版 builder 会丢弃、
|
||||
# BuildKit 会保留,放在 chown 之前会让镜像里 config/ 的属主随构建器而变
|
||||
VOLUME ["/app/config"]
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
# 按 config.ini 里实际的 web_port 探测(面板允许在线改端口,写死会让改过端口的容器
|
||||
# 永远停在 unhealthy)。用 http.client 而不是 urllib:根路径是 302 跳转,urllib 会跟到
|
||||
# 需要 token 的 /web 并因 401 判失败。encoding 必须显式给 utf-8-sig,否则 configparser
|
||||
# 按系统本地代码页解码会失败。
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD python -c "import configparser,http.client,sys;c=configparser.ConfigParser();c.read('/app/config/config.ini',encoding='utf-8-sig');p=c.get('Server','web_port',fallback='5001');h=http.client.HTTPConnection('127.0.0.1',int(p),timeout=3);h.request('GET','/');sys.exit(0 if h.getresponse().status<400 else 1)"
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,346 @@
|
||||
# ProxyCat Configuration Guide
|
||||
|
||||
[Back to README](../README-EN.md) | [Features](Features-EN.md) | [Operation Manual](Operation%20Manual-EN.md) | [Investigation Manual](Investigation%20Manual-EN.md) | [Changelog](logs-EN.md) | [API Documentation](../API-EN.md)
|
||||
|
||||
## Configuration Files and Authoritative Sources
|
||||
|
||||
Configuration is centralized in `config/config.ini`, in four sections:
|
||||
|
||||
| Section | Contents |
|
||||
|---|---|
|
||||
| `[Server]` | All outbound proxy service parameters: listening ports, rotation mode and interval, proxy source, authentication, access list files, logging and statistics switches |
|
||||
| `[Users]` | Basic authentication accounts for the outbound proxy |
|
||||
| `[api_credentials]` | Multiple credential sets for upstream proxy endpoints; the set in effect can be switched from the panel |
|
||||
| `[Pool]` | All proxy pool parameters, with dotted key names (e.g. `validator.timeout_seconds`) |
|
||||
|
||||
The panel's settings forms use the same grouping: the common `[Server]` options are visible directly in the seven groups under Proxy Config, while tuning items that require understanding the internals are collected in the Advanced group; the `[Pool]` settings form folds advanced items inside each group, and the panel remembers the group you last viewed.
|
||||
|
||||
**There are three authorities, in order of precedence:**
|
||||
|
||||
1. **The panel forms** (Proxy Config and Pool Settings) — validated, applied on save, recommended for daily use.
|
||||
2. **The inline comments in `config.ini`** — the most complete, one Chinese sentence per key.
|
||||
3. **`GET /api/pool/schema`** — the machine-readable form of the `[Pool]` section (the panel's Pool Settings form is rendered from it).
|
||||
|
||||
Some of these options directly determine which external addresses the program sends requests to (e.g. `test_url`, `api_proxy_url`, `pool_remote_url`, `validator.*_apis`, `validator.host_public_ip`); for the full list see [Outbound Network Requests](Features-EN.md#outbound-network-requests).
|
||||
|
||||
> Note: the comments inside `config.ini` are in **Chinese**; only the panel's field descriptions are rendered per language.
|
||||
|
||||
The **Default** column in this page's tables means the **built-in program default** (the value used when a key is missing or the whole `config.ini` is deleted). The `config.ini` shipped in the repo has been tuned by the author, and the following options differ from the built-in defaults:
|
||||
|
||||
| Option | Built-in default | Shipped `config.ini` |
|
||||
|---|---|---|
|
||||
| `proxy_source_mode` | `local` | `pool` |
|
||||
| `api_proxy_url` | `http://example.com/getip` | empty |
|
||||
| `mode` | `cycle` | `request` |
|
||||
| `interval` | `300` | `60` |
|
||||
| `exit_count` | `5` | `10` |
|
||||
| `exit_wait_timeout` | `15` | `30` |
|
||||
| `max_concurrent_requests` | `1000` | `3000` |
|
||||
| `check_concurrency` | `50` | `100` |
|
||||
| `switch_cooldown` | `2` | `1` |
|
||||
| `proxy_check_ttl` | `60` | `30` |
|
||||
| `check_cooldown` | `10` | `5` |
|
||||
| `access_records_real_ip_probe` | `false` | `true` |
|
||||
| `token` | empty (the panel does not authenticate) | `honmashironeko`, must be changed before exposing it |
|
||||
|
||||
> When started with the `config.ini` shipped in the repo, the source is `pool` and no `pool_remote_url` is configured, so **the built-in proxy pool starts together with the service automatically** — this is the normal behaviour of the shipped configuration, not a fault.
|
||||
|
||||
The shipped values of the `[Pool]` section match the built-in defaults; both sides are the same configuration.
|
||||
|
||||
## How Changes Take Effect
|
||||
|
||||
| Mechanism | Applies to | Notes |
|
||||
|---|---|---|
|
||||
| Immediately | the vast majority of `[Server]` and `[Pool]` options | Applies at once when saved from the panel; a hand-edited `config.ini` is hot-reloaded once the background watcher notices it |
|
||||
| Needs a proxy service restart | `port` | The port is bound only when the service starts; the process does not have to be touched — restarting the proxy service once from the panel rebinds it to the new port |
|
||||
| Needs a full application restart | `web_port`, `log_max_bytes`, `log_backup_count` | The web port is bound at application startup, and the log rotation parameters are fixed when the log handlers are assembled at startup; restarting only the proxy service is not enough |
|
||||
| Needs a pool service or service restart | `database.path`, `database.pool_max_readers` (pool service); `write_queue.max_queue_size` (service); `access_records_buffer_size` (when lowered) | The resource is fixed when it is created; changing it halfway either has no effect or loses the data already queued |
|
||||
|
||||
**Hot-reload boundaries** (easy to get wrong):
|
||||
|
||||
- **Configuration saved from the panel applies immediately**, on both entry points.
|
||||
- **When you hand-edit `config.ini`**, both entry points reload the `[Server]` section: `app.py` has a background thread polling the file every second — about 1 second; `ProxyCat.py` notices the change in its status polling loop and reloads within about 1 second in countdown mode, or up to about 5 seconds under `loadbalance` or non-time-based rotation (`interval` is `0`).
|
||||
- The `[Pool]` section is hot-reloaded too, taking effect within about 3 seconds on both entry points.
|
||||
- `port` and `web_port` are bound only when the service starts: `web_port` must be followed by a full application restart (the panel only warns on save); `port` needs no process restart — restarting the proxy service once from the panel applies the new port.
|
||||
|
||||
So **there is no such thing as "every configuration change needs a restart"** — only startup-bound options such as `port` and `web_port` need one; every other option, whether saved from the panel or hand-edited in `config.ini`, takes effect through the hot reload described above.
|
||||
|
||||
## `[Server]` Options
|
||||
|
||||
On the panel's Proxy Config page the `[Server]` section is split by purpose into seven groups; the keys are listed below in the same groups, with how each option applies in the last column.
|
||||
|
||||
### Source
|
||||
|
||||
Only the fields the current source uses are shown.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Config → Source group: the buttons at the top switch the proxy source, and the form renders only the fields the current source uses.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `proxy_source_mode` | `local` | Proxy source: `local` reads `proxy_file`; `api` fetches `api_proxy_url`; `pool` uses the proxy pool. The shipped `config.ini` uses `pool` | Immediately |
|
||||
| `proxy_file` | `ip.txt` | File name of the proxy list for the `local` source; only the file name is used (any path part is ignored) and it is always read from the `config/` directory; saving the local proxy list from the panel writes back to this file | Immediately |
|
||||
| `api_proxy_url` | `http://example.com/getip` | Endpoint the `api` source fetches proxy addresses from. The response is parsed line by line: one proxy address per line, a line with no protocol is treated as http, and lines with an invalid format are skipped | Immediately |
|
||||
| `proxy_username` | empty | Authentication account appended to fetched proxy addresses; it takes effect only when `proxy_password` is non-empty as well, and it is not used to authenticate to the endpoint itself (`api_proxy_url`) | Immediately |
|
||||
| `proxy_password` | empty | Used as a pair with `proxy_username`; authentication is attached to fetched addresses only when both are non-empty | Immediately |
|
||||
| `pool_remote_url` | empty | The **fetch** endpoint of an external proxy pool: ProxyCat issues a single GET and parses every line of the response body into a proxy address, performing no management operations; `404` means no proxy is available at the moment. Empty means the in-process pool is used | Immediately |
|
||||
|
||||
### Listening Ports
|
||||
|
||||
Only needed when a port is taken.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Config → Listening ports group, with an extra note: the access token is not changed here.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `port` | `1080` | Proxy listening port, shared by HTTP and SOCKS5 (1-65535) | Needs a proxy service restart |
|
||||
| `web_port` | `5001` | Web panel port (1-65535); changing it requires restarting the whole application, not just the proxy service | Needs a full application restart |
|
||||
| `token` | empty | Web panel access token, validated through `?token=`; empty means the panel does not authenticate. The panel has **no** field for this — changing it invalidates the current URL at once and locks you out of the panel, so to replace it, edit `config.ini` and save | Hot-reloads after the file is saved (about 1 second); the old token stops working right away |
|
||||
|
||||
> **Risk of an empty `token`**: with it empty, anyone who can reach the panel port can read and write configuration, view access records and obtain the upstream credentials. The panel is meant to be used locally by default; if you expose it to a network, be sure to set a `token`.
|
||||
> Also, **the `config.ini` shipped in this repo carries a public default token** — change it before exposing the panel.
|
||||
|
||||
### Exit Rotation
|
||||
|
||||
How long an exit serves, and how many serve at once.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Config → Exit rotation group: the run mode, the two lifetime measures, how many exits are in use, and how long a request queues when no exit is available.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `mode` | `cycle` | **The same key asks two different questions depending on the source.** For the `local` source it asks *which one to pick*: `cycle` works down the list in order (ignoring load — the next entry is used only once the previous one is full); `loadbalance` picks whichever exit is under the least pressure. For the `api` / `pool` sources it asks *when to rotate*: `request` (On request) — it only fetches and swaps exits when a new task arrives, and with no tasks at all expired exits are removed outright and the pool drains, until the next request fetches a fresh set to fill it; `continuous` (Continuous) — regardless of traffic, the background proactively replaces expired exits and refills to the target. When the source is switched and the current value does not belong to the new group, it is normalised to that group's default (the first item in the list): the first item of the `api` / `pool` group is `request` (which likewise comes first in the panel's dropdown), and the first item of the `local` group is `cycle` | Immediately |
|
||||
| `interval` | `300` | Lifetime cap of each exit (seconds): once it has been in the roster that long it is replaced. Each exit times itself (that is, "its own arrival + `interval`", so a batch that entered together expires together), and **all** expired exits are replaced at once — not a single swap every so often. It applies to every source and all four run modes (the local source rotates by lifetime too, taking the next entry from the file; with no spare entry in the file, the exit restarts its lifetime in place). **When** the swap happens is decided by the run mode: `continuous` swaps in the background on its own, while `request` swaps only when a new task arrives and simply removes expired exits while idle. It and `request_interval` are **two independent rules that both apply**: set both and whichever comes first wins — it is not either/or. `0` disables time-based replacement | Immediately |
|
||||
| `request_interval` | `0` | Lifetime cap of each exit measured in assignments (replaced after being handed out this many times): again each exit counts its own. What is counted is "assignments" — a request that uses it counts once, and a retry that lands on a different exit counts once there. It and `interval` both apply, and whichever comes first wins. `0` disables request-count-based replacement | Immediately |
|
||||
| `exit_count` | `5` | The target capacity of the exit pool: this many upstream exits are kept in use — under `continuous` it **stays full even while there are no requests**; `request` (the shipped value) and the `local` source are request-driven instead: after about 10 idle seconds no more restocking happens, expired exits are removed rather than replaced and the pool may drain, and the next request fetches a fresh set to fill it (an empty pool or a gap left standing means the next request has to wait on the request path for one fetch plus validation). Fetched candidates are validated concurrently and fill the slots one by one until full; when an exit dies, only that one is replaced, not the whole batch. For `api` / `pool`, every fetch over-fetches by the shortfall, `min(shortfall × 8, 24)`; lines an `api` endpoint returns beyond that cap are truncated and discarded. `local` uses the file itself as the source, with this value as its cap (fewer entries means that is how many are used). Lowering it recalls the surplus on the spot (no waiting for lifetimes); each exit holds one client, so watch the connection and memory cost of a very large value. The shipped `config.ini` uses `10` | Immediately |
|
||||
| `exit_wait_timeout` | `15` | How long a request queues (seconds) when there is no usable exit at all (a fresh start, a source switch, every exit disabled, a failed fetch); it is served as usual once an exit arrives, and only gets a `503` after the wait runs out. `0` means do not wait — return `503` on the spot | Immediately |
|
||||
|
||||
### Availability Checks
|
||||
|
||||
What may enter the pool.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Config → Availability checks group: the check target, startup checking and pre-use validation, and the batch check concurrency.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `test_url` | `https://www.baidu.com` | Target URL for proxy liveness checks; the pool's `validator.target_url` is derived from it as well (Pool Settings has no such field — changing it here is changing the pool's validation target) | Immediately |
|
||||
| `check_proxies_on_startup` | `true` | Whether to check proxy availability once at startup. When the service starts with the `local` source (either entry point counts), a full check of the current roster also runs: unreachable entries are **removed one by one**, and the gaps are left to the refill. The "Check proxies" button on the panel checks the **source list** (every entry in `ip.txt`), not the current roster | Immediately |
|
||||
| `check_proxies_on_use` | `true` | Whether to validate a candidate (one real request) before it fills an exit slot; a failed one is discarded and the next is tried. **It is not a per-request check**: an exit that dies while serving is caught by failure retirement. Addresses from a source can expire within a minute — an open port does not mean the proxy still works | Immediately |
|
||||
| `check_concurrency` | `50` | Concurrency cap for batch proxy checks (1-1000). The shipped `config.ini` uses `100` | Immediately |
|
||||
|
||||
### Performance
|
||||
|
||||
How much this host can carry at once.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Config → Performance group: global concurrency, per-exit concurrency, and the auto-expansion switch.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `max_concurrent_requests` | `1000` | Cap on requests handled simultaneously; beyond it requests queue on the semaphore. It is the global semaphore, applied alike to all three request kinds, and sized in **tunnels** rather than requests — when the sum of per-exit quotas far exceeds it, this is the value that actually applies. The shipped `config.ini` uses `3000` | Immediately |
|
||||
| `max_concurrent_per_proxy` | `0` | Cap on active connections through a single upstream exit; `0` means unlimited. It counts connections "in use": a request counts while it is being forwarded, and a tunnel counts after establishment only while it is actually relaying data — idle tunnels do not consume quota (so it is an **admission control**, not a hard ceiling on the number of open upstream connections). Total upstream concurrency ≈ exits × this value (capped by the global limit). When your upstream rate-limits by exit IP, set it to what a single IP can bear (say 20); the excess queues instead of knocking the upstream over | Immediately |
|
||||
| `auto_expand_enabled` | `true` | Auto-expands the exit pool under load, with two trigger paths: (1) **immediate** — when the connections currently held on the listening port exceed "exits in use × per-exit quota", the exit count is doubled on the spot (capped at 2×) and filled in one go, without waiting for the watermark; (2) **fallback** — the background adds exits one step at a time based on "the share of exits pinned at their per-exit quota" (80% sustained for 1 second). Release looks at pool-wide utilisation (below half for 30 seconds); the thresholds and durations are fixed in code and not configurable. The per-exit quota is relaxed (at most doubled) only when this round did fetch from the source, got an answer, and still has no usable new candidate; the roster ceiling is `max(exit_count, min(exit_count × 2, max_concurrent_requests))`. After expansion stops, the surplus exits **serve out their own lifetimes** before being retired one by one. With `max_concurrent_per_proxy` at `0` (unlimited) nothing expands, but an expansion already open is still released as usual | Immediately |
|
||||
|
||||
### Logging and Records
|
||||
|
||||
What to record, and for how long.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Config → Logging and records group: the log level, the three sets of switches for the access log, access records and domain statistics, and their respective retention rules.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `log_level` | `INFO` | Log level: `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`. Successful access lines are `INFO`, so setting the level to `WARNING` or above filters them out (the panel warns you in place) | Immediately |
|
||||
| `log_access_enabled` | `true` | Whether to write every proxied access to the access log (`logs/access.log`). Turning it off stops writing that file; domain statistics and access record details are both unaffected | Immediately |
|
||||
| `access_records_enabled` | `true` | Whether to record every proxy request individually (the access details on the panel's Logs page, stored in `logs/access_records.db`). It is a destination independent of the access log file and domain statistics, and the switches do not affect each other | Immediately |
|
||||
| `access_records_real_ip_probe` | `false` | When the exit address is a gateway, probe the real egress IP through it once (only for the `api` source; each address costs one extra unit of proxied traffic). The pool source uses the exit IP already verified in the pool, and local proxies are not probed. The shipped `config.ini` uses `true` | Immediately |
|
||||
| `domain_stats_enabled` | `true` | Whether to accumulate access statistics per "domain × upstream proxy" (the access statistics on the panel's Logs page) | Immediately |
|
||||
| `access_records_retention_days` | `7` | Retention in days for access records; `0` means no time-based cleanup | Immediately |
|
||||
| `access_records_max_rows` | `200000` | Row cap for access records; rows beyond it are trimmed from the oldest; `0` means no limit | Immediately |
|
||||
| `access_records_flush_interval` | `5` | How often access records are flushed to the database (seconds) | Immediately |
|
||||
| `access_records_buffer_size` | `20000` | In-memory buffer size for access records; beyond it the oldest are dropped and counted. Lowering it requires a restart | Restart needed when lowered |
|
||||
| `domain_stats_retention_days` | `30` | Retention in days for access statistics; `0` means no cleanup | Immediately |
|
||||
| `domain_stats_flush_interval` | `15` | How often access statistics are flushed to the database (seconds) | Immediately |
|
||||
|
||||
### Advanced
|
||||
|
||||
Normally left alone; a mistake here is hard to trace.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Config → Advanced group: the cooldowns, caching and internal-mechanism tuning items all live here.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `proxy_check_ttl` | `60` | Cache lifetime of proxy availability check results (seconds). The shipped `config.ini` uses `30` | Immediately |
|
||||
| `check_cooldown` | `10` | Cooldown between two availability checks (seconds). The shipped `config.ini` uses `5` | Immediately |
|
||||
| `switch_cooldown` | `2` | Cooldown after a batch refetch of exits (seconds); no refetch during the cooldown (refills are rate-limited by it too). It applies to all three sources alike. The shipped `config.ini` uses `1` | Immediately |
|
||||
| `buffer_size` | `8192` | How many bytes of plaintext HTTP forwarding accumulate before backpressure is applied once (waiting for the client to drain the data); minimum 1024. It **does not control chunk size** — response bodies are forwarded in the upstream's native chunks (usually 64 KiB) with no re-chunking; the tunnel relay is fixed at 64 KiB and is unaffected by this option | Immediately |
|
||||
| `client_keepalive_expiry` | `30` | Idle recycling (seconds): a keep-alive connection expires after being idle this long, and the clients holding those connections are recycled on the same clock (it should not outlive its connections). Lowering it discards freshly warmed connections and degrades connection reuse | Immediately |
|
||||
| `tunnel_idle_timeout` | `10` | After a tunnel is established, how long "the client has sent data and the upstream has not returned a single byte" lasts before the upstream is declared dead (seconds); `0` disables the check. It is the **only way** to catch an upstream that accepts CONNECT and then sends nothing back, but an upstream that is merely very slow to queue can trip it too — if upstreams routinely take longer than this under high concurrency, raise it | Immediately |
|
||||
| `log_max_bytes` | `10485760` | Size cap of a single log file (bytes); it rotates beyond it | Needs a full application restart |
|
||||
| `log_backup_count` | `3` | Number of historical files kept after rotation; at least 1 (`0` would make logs never rotate) | Needs a full application restart |
|
||||
| `proxy_failure_cooldown` | `3` | Folding window for failure warnings (seconds): warnings of the same kind are written to the log once per window, with a record of how many were folded. Under high concurrency failures arrive in batches — without folding, one sentence would flood the log. Folding **happens at the log layer only**; an exit's accounting and scheduling are unaffected (they look at the recent failure share, not a timer) | Immediately |
|
||||
| `display_level` | `1` | Console verbosity: `0` shows only exit roster changes and error messages, `1` shows the exit roster with per-exit state and the countdown, `2` shows all detailed information (the behaviour of `2` and `3` is identical item by item, and a `3` is normalised to `2`); `2` appends an explanation of each level after the startup banner. It only affects output when running the `ProxyCat.py` command-line entry | Immediately (affects only the command-line entry's output) |
|
||||
|
||||
### Access Control and UI Language
|
||||
|
||||
These keys are not among the seven groups on the Proxy Config page: the whitelist, blacklist and bypass list are set on the panel's Access Control page, and the language switch sits at the bottom of the sidebar.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `whitelist_file` | `whitelist.txt` | Client IP whitelist file name; only the file name is used, and it is always read from the `config/` directory | Immediately |
|
||||
| `blacklist_file` | `blacklist.txt` | Client IP blacklist file name; only the file name is used, and it is always read from the `config/` directory | Immediately |
|
||||
| `ip_auth_priority` | `whitelist` | Which side wins when a client matches both lists: `whitelist` (allow) / `blacklist` (block) | Immediately |
|
||||
| `bypass_whitelist_file` | `bypass_whitelist.txt` | Bypass list file name: matching target addresses connect directly, bypassing the upstream proxy (wildcards supported), with no blocking decision; only the file name is used, and it is always read from the `config/` directory | Immediately |
|
||||
| `language` | `cn` | UI and message language: `cn` / `en`. The language is a **server-side setting** (written back to `config.ini`): switching it changes the panel text, API error messages and exported file headers together; the command-line entry's console banner is printed once at startup and is not reprinted | Immediately |
|
||||
|
||||
## `[Users]`
|
||||
|
||||
The Basic authentication account table for the outbound proxy: the key is the username and the value is the password, one account per line; an empty section means the proxy requires no authentication.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `username = password` (e.g. `neko = 123456`) | the built-in defaults have no `[Users]` section, so no authentication is required | Requests must carry Basic authentication; a request without it gets a `407`. The `config.ini` shipped in this repo has two accounts: `k = 123` and `neko = 123456` | Applied immediately when saved from Access Control → User Management |
|
||||
|
||||
Maintaining it through the panel's User Management is recommended — saving from the panel updates the running service directly; hand-editing this section is also read in through the configuration hot reload.
|
||||
|
||||
## `[api_credentials]`
|
||||
|
||||
Multiple credential sets for upstream proxy endpoints, maintained from the credential management under Proxy Config → Source (API source) in the panel, where the active set can be switched in one click.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `credential_sets` | `[]` | Available credential sets for fetching IPs, a JSON array whose items are `{"name", "url", "username", "password"}`; if you edit this line by hand it must stay valid JSON, otherwise the whole set of credentials cannot be read | Applied immediately when saved from the panel |
|
||||
| `active_credential` | empty | Name of the credential currently in effect; it must match the `name` of an item in `credential_sets` exactly — a mismatch counts as none selected | Applied immediately when saved from the panel |
|
||||
|
||||
## `[Pool]` Options
|
||||
|
||||
Keys in the `[Pool]` section use dotted names. Saving a change applies it hot without a restart; you can also edit them from the panel's Proxy Pool → Pool Settings page — both sides are the same configuration. The groups below follow the same order as the Pool Settings page, driven by `GET /api/pool/schema`.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Pool → Pool Settings page: the forms of all eight groups below live on this one page.
|
||||
|
||||
### Database
|
||||
|
||||
Where the data is stored and how it is backed up.
|
||||
|
||||

|
||||
|
||||
The panel's Proxy Pool → Database Maintenance page: database statistics and optimization, the backup list and restore, and offline geolocation database updates and recomputation.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `database.path` | `data/proxies.db` | File holding all pool data (SQLite format). A relative path is resolved from the `modules/proxypool/` directory. The database is already open at startup | Needs a pool service restart |
|
||||
| `database.pool_max_readers` | `4` | Cap on connections querying the database at once; writes always take exactly 1 connection. Raising it makes concurrent queries smoother at the cost of some file handles; keep the default when the pool is small | Needs a pool service restart |
|
||||
| `database.backup_enabled` | `true` | Whether to back up the database automatically at the interval below; it takes effect as soon as it is saved (turning it off stops the backups, turning it back on starts them) | Immediately |
|
||||
| `database.backup_interval_hours` | `24` | Interval between automatic backups (hours) | Immediately |
|
||||
| `database.backup_retention_days` | `7` | How many days backup files are kept before being deleted automatically | Immediately |
|
||||
|
||||
### Proxy Validation
|
||||
|
||||
What counts as a usable proxy.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `validator.check_anonymity` | `true` | Whether to check proxy anonymity. Anonymity is judged by the request headers the target actually received, which can only be measured with one plaintext request through the proxy; turning it off stops those requests and stops judging anonymity, while levels already measured are kept as they are | Immediately |
|
||||
| `validator.host_public_ip` | empty | This host's public egress IP, the reference for judging a "transparent proxy". On a dual-stack host, fill in one per family separated by a comma (at most one IPv4 and one IPv6); both families take part in the comparison, and a single value works too. When it is empty, ProxyCat fetches it by reaching an IP-check site **without going through the proxy** (at most once every 30 minutes; both success and failure restart the timer); filling in your own egress IP removes that external request entirely. When it is empty or cannot be obtained, anonymity cannot be judged and that field keeps its previous value | Immediately |
|
||||
| `validator.timeout_seconds` | `5` | How many seconds one proxy validation may take before it is judged unusable. All probe requests obey this cap | Immediately |
|
||||
| `validator.connect_timeout_seconds` | `2.0` | How many seconds connecting to a proxy may take. Dead proxies mostly stall at the connection step, so lowering it noticeably speeds up a whole validation batch; `0` means no separate limit — only the overall timeout above applies | Immediately |
|
||||
| `validator.max_concurrent` | `400` | How many proxies to validate at once. Every validation connects to a different host, so it is safe to raise; it also sets this machine's connection-pool ceiling and open-socket budget (one validation fires a dozen-odd requests concurrently), so lower it on a constrained host, and lower it a little if the test sites start rate-limiting (visible as a flood of sudden proxy timeouts). Note also that queueing timeouts during the **liveness probe** count as "no conclusion" and write no field, whereas identity and plaintext probes fired concurrently after liveness passes are, if they time out in the local connection-pool queue, recorded as a failed sample and drag down the success-rate dimension of the health score — so higher is not always better | Immediately |
|
||||
| `validator.identity_echo_apis` | `https://httpbin.org/anything, https://httpbingo.org/anything, https://eu.httpbin.org/anything` | Endpoints used to probe the egress IP and anonymity, separated by commas; they must be https. Such an endpoint must return `origin` and `headers`, so a single request yields the egress IP, anonymity and HTTPS support at once. Unreachable ones are skipped automatically; the usable ones are raced one by one in order of direct-connection latency, without truncating by count — any extras stay as substitutes | Immediately |
|
||||
| `validator.plain_identity_echo_apis` | `http://postman-echo.com/get` | Supplementary plaintext echo endpoints, separated by commas; they must be http. Anonymity is judged by "which request headers the target actually received", and the plaintext path is its standard observation surface — the defaults are the plaintext addresses of the endpoints above. If the proxies cannot reach those sites and anonymity keeps being judged as the fallback tier, put echo sites the proxies can reach here | Immediately |
|
||||
| `validator.ip_check_apis` | `https://icanhazip.com, https://checkip.amazonaws.com, https://api.ip.sb/ip, https://realip.cc/simple, https://httpbin.org/ip, https://api.ipify.org, https://ipinfo.io/json, https://ipwho.is/, https://www.cloudflare.com/cdn-cgi/trace` | IP-only endpoints, separated by commas; they must be https. They are the fallback when none of the endpoints above produces a result, and are likewise raced one by one by direct-connection latency, without truncating by count | Immediately |
|
||||
|
||||
### Fetch Plugins
|
||||
|
||||
How often each source is scraped.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `plugins.scan_interval_seconds` | `60` | How often to check whether any plugin is due for a fetch (seconds). It only sets the checking frequency, and does not change each plugin's own fetch interval | Immediately |
|
||||
| `plugins.default_interval_minutes` | `60` | Default fetch interval for new plugins (minutes). Every plugin can adjust its own interval; this option only affects plugins created later | Immediately |
|
||||
| `plugins.execution_timeout_seconds` | `300` | How many seconds a single plugin run may take before it is interrupted | Immediately |
|
||||
| `plugins.request_timeout_seconds` | `30` | How many seconds a plugin waits for one web request; when a target site is unreachable, that is how long it waits | Immediately |
|
||||
| `plugins.max_parallel_plugins` | `3` | How many plugins may run at the same time | Immediately |
|
||||
| `plugins.max_retries_on_failure` | `2` | How many extra attempts a failed plugin fetch gets. `2` means at most 3 attempts in total | Immediately |
|
||||
| `plugins.retry_base_delay_seconds` | `10.0` | Seconds to wait before retrying after a plugin failure. The wait doubles with each further attempt (1× before the first retry, 2× before the second, 4× before the third), so the same site is not hammered again in quick succession | Immediately |
|
||||
|
||||
### Automatic Re-validation
|
||||
|
||||
How dead proxies are found.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `auto_revalidation.enabled` | `true` | Whether stored proxies are re-checked periodically for liveness | Immediately |
|
||||
| `auto_revalidation.interval_minutes` | `360` | Default re-check interval (minutes). Each plugin can set its own interval in Plugin Management | Immediately |
|
||||
| `auto_revalidation.only_valid_proxies` | `true` | Check only currently valid proxies, skipping ones already judged invalid | Immediately |
|
||||
| `auto_revalidation.run_on_start` | `true` | Run a check right after startup instead of waiting a full interval | Immediately |
|
||||
| `auto_revalidation.full_recheck_interval_minutes` | `1440` | How often a full check runs (minutes). Routine checks only test whether a proxy is still reachable; egress IP, anonymity and protocol support rarely change, so they can be re-measured after a longer interval | Immediately |
|
||||
|
||||
### Automatic Cleanup
|
||||
|
||||
Which proxies get deleted.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `auto_cleanup.enabled` | `true` | Whether to delete proxies whose quality is too low automatically. Deletion is irreversible — when unsure, turn it off and only run the checks | Immediately |
|
||||
| `auto_cleanup.min_health_score` | `10.0` | Proxies whose health score is below this value are deleted, but the check-count requirement below must be met as well | Immediately |
|
||||
| `auto_cleanup.min_checks_before_cleanup` | `3` | How many checks a proxy must have before it may be dropped by score, so freshly stored ones are not deleted on too little evidence | Immediately |
|
||||
| `auto_cleanup.max_failures` | `3` | Delete after this many consecutive failures | Immediately |
|
||||
| `auto_cleanup.max_age_days_if_invalid` | `3` | How many days an invalid proxy is kept at most before deletion | Immediately |
|
||||
|
||||
### Use-time Feedback
|
||||
|
||||
Whether a forwarding failure counts.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `use_feedback.enabled` | `true` | Whether a proxy that fails during real forwarding is recorded as a failure and marked invalid once consecutive failures hit the threshold. Validation cannot tell "a broken proxy" from "a hiccup on our side", and the threshold is there to absorb the latter; when off, the pool trusts only its own validation results | Immediately |
|
||||
| `use_feedback.invalid_threshold` | `3` | Mark the proxy invalid after this many consecutive failures; any success in between resets the count | Immediately |
|
||||
|
||||
### Write Queue
|
||||
|
||||
How many records accumulate before being written.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `write_queue.batch_size` | `100` | How many records to accumulate before writing them to the database in one batch | Immediately |
|
||||
| `write_queue.max_retries` | `3` | How many times a batch may be attempted (first attempt included). `0` and `1` both mean give up on failure | Immediately |
|
||||
| `write_queue.max_queue_size` | `10000` | How many pending write tasks may queue at most. Once full, new tasks are rejected so that too much memory is not tied up. The queue capacity is fixed at creation; changing it halfway loses the data already queued | Needs a service restart |
|
||||
|
||||
### Logging
|
||||
|
||||
How large the logs grow, and how many are kept.
|
||||
|
||||
| Option | Default | Description | Applies |
|
||||
|---|---|---|---|
|
||||
| `logging.file_path` | `logs/proxy_pool.log` | The proxy pool's log file. A relative path is resolved from the `modules/proxypool/` directory | Immediately |
|
||||
| `logging.max_bytes` | `10485760` | Size cap of a single log file (bytes). Once full, a new file is started and the old one is kept | Immediately |
|
||||
| `logging.backup_count` | `5` | How many historical log files are kept at most | Immediately |
|
||||
|
||||
## Options Not Shown in the Panel
|
||||
|
||||
The following four keys still live in `config.ini` but are no longer shown in the panel; the value actually used is derived from the current runtime parameters:
|
||||
|
||||
| Option | Default | Actual value |
|
||||
|---|---|---|
|
||||
| `client_max_connections` | `1000` | `max(this value, per-exit concurrency cap × 2)`; when per-exit concurrency is unlimited (`max_concurrent_per_proxy` is `0`), it falls back to the global concurrency cap |
|
||||
| `max_pool_size` | `500` | `max(this value, roster size × 2, exits × 4)` — derived from the current roster and the expansion ceiling; below that derived value this one is ignored |
|
||||
| `client_max_keepalive` | `8` | This value is used as is |
|
||||
| `client_idle_timeout` | `300` | Derived from `client_keepalive_expiry` as `max(it, 30)`; the panel does not read this value, and on write-back it is overwritten with the derived one |
|
||||
|
||||
A few notes:
|
||||
|
||||
- Setting `client_max_connections` too low leaves requests queueing inside the httpx connection pool; when `max_pool_size` is below its derived value, each exit's clients evict one another and connection reuse is lost. Both still take effect when set above their derived values; they remain here only for compatibility with older configurations where they were raised.
|
||||
- **Do not raise `client_max_keepalive`**: when allocating a request, httpx's connection pool scans the whole idle set (as implemented in httpcore 1.0.9), so the more idle connections there are, the more CPU each request spends on the scan — measured, a few dozen idle connections in the pool can eat more than half a core and cut throughput to a third. Keeping it small (close connections when done, recreate them on use) was measured faster both with and without upstream latency; to make a single exit carry more concurrency, tune `client_max_connections` rather than this one.
|
||||
- `client_idle_timeout` is how long a client's connection pool keeps an idle connection before recycling it (seconds); it is derived from `client_keepalive_expiry`, a hand-edited value is overwritten by the derived one, and it is kept here only for compatibility with older configurations.
|
||||
@@ -0,0 +1,347 @@
|
||||
# ProxyCat 详细配置
|
||||
|
||||
[返回 README](../README.md) | [功能特性](Features.md) | [使用手册](Operation%20Manual.md) | [报错排查](Investigation%20Manual.md) | [更新日志](logs.md) | [接口文档](../API.md)
|
||||
|
||||
## 配置文件与权威参考
|
||||
|
||||
配置集中在 `config/config.ini`,分四段:
|
||||
|
||||
| 段 | 内容 |
|
||||
|---|---|
|
||||
| `[Server]` | 出口代理服务的全部参数:监听端口、轮换模式与间隔、代理来源、认证、名单文件、日志与统计开关等 |
|
||||
| `[Users]` | 出口代理的 Basic 认证账号表 |
|
||||
| `[api_credentials]` | 上游取代理接口的多套凭据,可在面板里切换当前生效的一套 |
|
||||
| `[Pool]` | 代理池的全部参数,点号命名的键(如 `validator.timeout_seconds`) |
|
||||
|
||||
面板上的设置表单按同一口径分组:`[Server]` 的常用项在「代理配置」的七个组里直接可见,需要理解内部机制的调优项集中在「高级选项」组;`[Pool]` 的设置表单在每个组内折叠高级项。面板会记住上次查看的分组。
|
||||
|
||||
**权威参考有三处,优先级从高到低:**
|
||||
|
||||
1. **面板表单**(「代理配置」与「池设置」)—— 带校验,保存即生效,推荐日常使用。
|
||||
2. **`config.ini` 的行内注释** —— 最全,每个键都有一句中文说明。
|
||||
3. **`GET /api/pool/schema`** —— `[Pool]` 段的机器可读版(面板的「池设置」表单就由它渲染)。
|
||||
|
||||
这些配置项里有一批直接决定程序会往哪些外部地址发请求(例如 `test_url`、`api_proxy_url`、`pool_remote_url`、`validator.*_apis`、`validator.host_public_ip`),完整清单见 [功能特性里的「对外网络请求」](Features.md#对外网络请求)。
|
||||
|
||||
> 注:`config.ini` 里的注释是**中文**;面板里的字段说明才会按中英文分别渲染。
|
||||
|
||||
本页表格里的**「默认值」指程序内置默认**(键缺失、或整份 `config.ini` 被删掉时的取值)。随仓库发布的 `config.ini` 已按作者调优,下面这些项与内置默认不同:
|
||||
|
||||
| 配置项 | 内置默认 | 出厂 `config.ini` |
|
||||
|---|---|---|
|
||||
| `proxy_source_mode` | `local` | `pool` |
|
||||
| `api_proxy_url` | `http://example.com/getip` | 空 |
|
||||
| `mode` | `cycle` | `request` |
|
||||
| `interval` | `300` | `60` |
|
||||
| `exit_count` | `5` | `10` |
|
||||
| `exit_wait_timeout` | `15` | `30` |
|
||||
| `max_concurrent_requests` | `1000` | `3000` |
|
||||
| `check_concurrency` | `50` | `100` |
|
||||
| `switch_cooldown` | `2` | `1` |
|
||||
| `proxy_check_ttl` | `60` | `30` |
|
||||
| `check_cooldown` | `10` | `5` |
|
||||
| `access_records_real_ip_probe` | `false` | `true` |
|
||||
| `token` | 空(面板不校验) | `honmashironeko`,对外使用前必须换掉 |
|
||||
|
||||
> 按仓库自带的 `config.ini` 启动时,来源是 `pool` 且没有配置 `pool_remote_url`,**内置代理池会随服务自动启动** —— 这是出厂配置的正常行为,不是故障。
|
||||
|
||||
`[Pool]` 段的出厂值与内置默认一致,两边是同一份配置。
|
||||
|
||||
## 生效方式
|
||||
|
||||
| 方式 | 适用于 | 说明 |
|
||||
|---|---|---|
|
||||
| 即时 | 绝大多数 `[Server]` 与 `[Pool]` 项 | 面板保存后立即生效;手工改 `config.ini` 由后台巡检发现后热重载 |
|
||||
| 需重启代理服务 | `port` | 端口只在服务启动时绑定;可以不动进程,在面板里重启一次代理服务即可按新端口生效 |
|
||||
| 需重启整个应用 | `web_port`、`log_max_bytes`、`log_backup_count` | Web 端口在应用启动时绑定,不是只重启代理服务;日志轮转参数在启动装配 handler 时固定,面板保存与热重载都不会重建 handler |
|
||||
| 需重启池服务或服务 | `database.path`、`database.pool_max_readers`(池服务);`write_queue.max_queue_size`(服务);`access_records_buffer_size`(调小时) | 资源在创建时就已固定,中途改要么不生效、要么会丢掉已排队的数据 |
|
||||
|
||||
**热加载的边界**(容易踩):
|
||||
|
||||
- **从面板保存的配置即时生效**,两个入口都是。
|
||||
- **手工编辑 `config.ini` 时**,两个入口都会重载 `[Server]` 段:`app.py` 有后台线程按秒巡检配置文件,`ProxyCat.py` 同样在状态轮询里发现改动后重载。
|
||||
- `[Pool]` 段同样会热加载,两个入口都会在约 3 秒内生效。
|
||||
- `port` 与 `web_port` 只在服务启动时绑定:`web_port` 改了必须重启整个应用(面板只在保存时给出告警);`port` 可以不动进程,在面板里重启一次代理服务即可按新端口生效。
|
||||
|
||||
因此**不存在「每次改配置都要重启」这回事** —— 只有 `port`、`web_port` 这类启动时绑定的项需要重启,其余配置项无论从面板保存还是手工改 `config.ini`,都由上面的热重载生效。
|
||||
|
||||
## [Server] 配置项
|
||||
|
||||
`[Server]` 段在面板「代理配置」页里按用途分成七组,下面按同一分组列出全部键,每项的生效方式见表格末列。
|
||||
|
||||
### 来源设置
|
||||
|
||||
只显示当前来源用得上的字段。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理配置 → 来源设置」组:顶部按钮切换代理来源,表单只渲染当前来源用得上的字段。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `proxy_source_mode` | `local` | 代理来源:`local` 读 `proxy_file`;`api` 取 `api_proxy_url`;`pool` 用代理池。出厂 `config.ini` 是 `pool` | 即时 |
|
||||
| `proxy_file` | `ip.txt` | `local` 来源的代理列表文件名;只取文件名(忽略路径部分),固定从 `config/` 目录读取;面板保存本地代理列表时会写回这个文件 | 即时 |
|
||||
| `api_proxy_url` | `http://example.com/getip` | `api` 来源取代理地址的接口。接口返回按行解析:每行一条代理地址,未写协议的行按 http 处理,格式非法的行会被跳过 | 即时 |
|
||||
| `proxy_username` | 空 | 拼接到取回的代理地址上的认证账号;与 `proxy_password` 都非空才生效,不用于接口本身(`api_proxy_url`)的认证 | 即时 |
|
||||
| `proxy_password` | 空 | 与 `proxy_username` 成对使用;两者都非空时才为取回的地址附加认证 | 即时 |
|
||||
| `pool_remote_url` | 空 | 外部代理池的**取用**接口地址:ProxyCat 只发一次 GET,把响应体每一行解析成代理地址,不做任何管理操作;`404` 表示暂时没有可用代理。留空表示使用进程内的代理池 | 即时 |
|
||||
|
||||
### 监听端口
|
||||
|
||||
端口被占用时才需要改。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理配置 → 监听端口」组,另附一条说明:访问令牌不在这里修改。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `port` | `1080` | 代理监听端口,HTTP 与 SOCKS5 共用(1-65535) | 需重启代理服务 |
|
||||
| `web_port` | `5001` | Web 面板端口(1-65535);改了必须重启整个应用,不是只重启代理服务 | 需重启整个应用 |
|
||||
| `token` | 空 | Web 面板访问令牌,经 `?token=` 校验;留空表示面板不校验。面板**没有**这个入口 —— 改了会让当前地址立刻失效、把你锁在面板外,需要更换时编辑 `config.ini` 保存 | 改文件后热重载(约 1 秒),旧 token 随即失效 |
|
||||
|
||||
> **`token` 留空的风险**:留空时任何人只要连得上面板端口就能读写配置、查看访问记录,
|
||||
> 也能拿到上游凭据。面板默认只应在本机使用;要暴露到网络上请务必设置 `token`。
|
||||
> 另外,**仓库里自带的 `config.ini` 带了一个公开的默认 token**,对外使用前请换掉。
|
||||
|
||||
### 出口轮换
|
||||
|
||||
一个出口用多久、同时用几个。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理配置 → 出口轮换」组:运行模式、两种寿命口径、在用出口数与无出口时的排队时长。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `mode` | `cycle` | **同一个键在两种来源下问的是两件不同的事**。`local` 来源问「挑谁」:`cycle` 按清单顺序使用(忽略负载,前一条满了才用下一条);`loadbalance` 谁压力小先用谁。`api` / `pool` 来源问「什么时候换」:`request` 触发更换 —— 只在收到新任务时取货、换出口,没有任务时到期的出口直接移除、池子会空掉,下一个请求到达时重新取货填满;`continuous` 持续更换 —— 与有没有流量无关,后台按寿命主动换掉到期的并补满。换来源时若当前值不属于新那一组,会自动归一到该组的默认值(列表中第一个):`api` / `pool` 组的首项是 `request`(面板下拉里它同样排在前面),`local` 组的首项是 `cycle` | 即时 |
|
||||
| `interval` | `300` | 每个出口的寿命上限(秒):入池满这么久就被换掉。每个出口各自计时(就是「自己入池 + `interval`」,一批同时入池的因此同时到期),到期的**全部**更换,不是每隔这么久只换一个。所有来源、四种运行模式都生效(本地来源也按寿命轮换,换上来的是文件里的下一条;文件里没有备用条目时原地重开一次寿命)。**换的时机**由运行模式决定:持续更换在后台主动换,触发更换只在收到新任务时换、空闲时到期的直接移除。它与 `request_interval` 是**两条各自独立的规则,同时生效**,两个都填时谁先到谁换,不是二选一。`0` 表示不按时间计 | 即时 |
|
||||
| `request_interval` | `0` | 每个出口的寿命上限(分配出去多少次后更换):也是每个出口各数各的。数的是「分配次数」,一次请求用到它算一次,重试换到另一个出口会在那边再算一次。它与 `interval` 两条规则同时生效,谁先到谁换。`0` 表示不按请求数计 | 即时 |
|
||||
| `exit_count` | `5` | 出口池的目标容量:`continuous`(持续更换)模式下即使空闲也由后台保持满员;`request`(触发更换,仓库出厂配置即此)与本地来源的取货、换新由请求驱动 —— 空闲约 10 秒后不再补货,到期的出口被移除而不是换新,池子可能空掉,下一个请求到达时才重新取货填满(池子空着或缺口挂着,下一个请求就要在请求路径上干等一次取货 + 校验)。取回后并发校验候补、逐槽填到满,某个出口失效就单独补上它,不整批替换。`api` / `pool` 每次按缺额超量取回(`min(缺额 × 8, 24)`),`api` 返回超出该上限的行会被截断丢弃;`local` 直接以文件为源、以本值为上限(文件不够就用多少算多少)。调小它,名册当场收回多余的(不等寿命);每个出口一个客户端,调很大要留意连接与内存开销。出厂 `config.ini` 是 `10` | 即时 |
|
||||
| `exit_wait_timeout` | `15` | 一个可用出口都没有时(刚启动、换来源、出口全被停用、取代理失败),请求排队等多久(秒);等到有出口就照常服务,超时才回 `503`。填 `0` 表示不等、当场回 `503` | 即时 |
|
||||
|
||||
### 可用性检测
|
||||
|
||||
什么样的代理能进池。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理配置 → 可用性检测」组:检测目标、启动检测与取用前校验、批量检测并发数。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `test_url` | `https://www.baidu.com` | 代理存活检测的目标地址;代理池的 `validator.target_url` 也由它派生(「池设置」里没有这一项,改这里等于改池的验证目标) | 即时 |
|
||||
| `check_proxies_on_startup` | `true` | 启动时是否先检测一遍代理可用性。以 `local` 来源启动服务时(两个入口都算),还会对当前名册做一次完整检测:不通的**逐个移出**、缺的交给补货。面板上的「检测代理」按钮检测的是**源清单**(`ip.txt` 里的全部条目),不是当前名册 | 即时 |
|
||||
| `check_proxies_on_use` | `true` | 把候选填进出口池之前是否先校验一次(发一次真实请求);不合格的丢掉换下一条。**不是按请求校验**:出口在服役期间失效靠失败淘汰兜底。来源给的地址可能一分钟就失效,端口开着不代表还能用 | 即时 |
|
||||
| `check_concurrency` | `50` | 批量检测代理的并发数上限(1-1000)。出厂 `config.ini` 是 `100` | 即时 |
|
||||
|
||||
### 性能与并发
|
||||
|
||||
本机能同时扛多少请求。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理配置 → 性能与并发」组:全局并发、单出口并发与自动扩容开关。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `max_concurrent_requests` | `1000` | 同时处理的请求数上限,超出后在信号量上排队。它是全局信号量,对三类请求一视同仁,按「隧道」而不是「请求」估 —— 每出口额度之和远超它时,真正生效的是它。出厂 `config.ini` 是 `3000` | 即时 |
|
||||
| `max_concurrent_per_proxy` | `0` | 单个上游出口的活跃连接上限;`0` 表示不限制。数的是「正在用」的连接:请求转发期间算,隧道建立后只有真的在传数据才算,空闲隧道不占额度(所以它是一道**准入控制**,不是「同时在挂的上游连接数」的硬上限)。总上游并发 ≈ 出口数 × 该值(受全局上限封顶)。上游按出口 IP 限流时,把它调成单 IP 能承受的并发数(例如 20),超出部分会排队而不是把上游打挂 | 即时 |
|
||||
| `auto_expand_enabled` | `true` | 高峰期自动扩容出口池,两条触发路径:①**立刻** —— 监听端口上此刻挂着的连接数超过「在用出口数 × 单出口额度」时,当场把出口数翻倍(上限 2 倍)并一次补齐,不用等水位;②**兜底** —— 后台按「顶到单出口额度的出口占比」(八成持续 1 秒)逐格加出口。收回看「整池利用率」(一半以下持续 30 秒),阈值与时长固定在代码里、不开放配置。只在取回过货、拿到答复、却没有可用新候选时才放宽单出口额度(最多到 2 倍);名册上限 = `max(exit_count, min(exit_count × 2, max_concurrent_requests))`。停止扩容后,多出来的出口**用满自己的寿命**才逐个撤下。`max_concurrent_per_proxy` 为 `0`(不限)时不扩,但已经张开的膨胀状态照常收回 | 即时 |
|
||||
|
||||
### 日志与记录
|
||||
|
||||
记什么、留多久。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理配置 → 日志与记录」组:日志级别与访问日志、访问明细、域名统计三套开关及各自的保留口径。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `log_level` | `INFO` | 日志级别:`DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`。成功访问行是 `INFO`,级别调到 `WARNING` 及以上会把它们过滤掉(面板会就地提示) | 即时 |
|
||||
| `log_access_enabled` | `true` | 是否把每次代理访问写进访问日志(`logs/access.log`)。关闭后不再写这个文件,域名统计与访问记录明细都不受影响 | 即时 |
|
||||
| `access_records_enabled` | `true` | 是否逐条记录每一次代理请求(面板「运行日志」页的访问明细,落在 `logs/access_records.db`)。与访问日志文件、域名统计是两个独立的记录去向,开关互不影响 | 即时 |
|
||||
| `access_records_real_ip_probe` | `false` | 出口地址是网关时,经它探测一次真实出口 IP(仅 `api` 来源;每个地址多消耗一次代理流量)。代理池来源直接用池里已验证的出口 IP,本地代理不探测。出厂 `config.ini` 是 `true` | 即时 |
|
||||
| `domain_stats_enabled` | `true` | 是否按「域名 × 上游代理」累计访问统计(面板「运行日志」页的访问统计) | 即时 |
|
||||
| `access_records_retention_days` | `7` | 逐条记录的保留天数;`0` 表示不按时间清理 | 即时 |
|
||||
| `access_records_max_rows` | `200000` | 逐条记录的行数上限,超出后裁掉最旧的;`0` 表示不限制 | 即时 |
|
||||
| `access_records_flush_interval` | `5` | 逐条记录的落库间隔(秒) | 即时 |
|
||||
| `access_records_buffer_size` | `20000` | 逐条记录的内存缓冲条数,超出后丢最旧的并计数;调小需重启生效 | 调小需重启 |
|
||||
| `domain_stats_retention_days` | `30` | 访问统计的保留天数;`0` 表示不清理 | 即时 |
|
||||
| `domain_stats_flush_interval` | `15` | 访问统计的落库间隔(秒) | 即时 |
|
||||
|
||||
### 高级选项
|
||||
|
||||
平时不用动,改错很难查。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理配置 → 高级选项」组:各项冷却、缓存与内部机制相关的调优项都在这里。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `proxy_check_ttl` | `60` | 代理可用性检测结果的缓存有效期(秒)。出厂 `config.ini` 是 `30` | 即时 |
|
||||
| `check_cooldown` | `10` | 两次可用性检测之间的冷却时间(秒)。出厂 `config.ini` 是 `5` | 即时 |
|
||||
| `switch_cooldown` | `2` | 重取出口批次后的冷却时间(秒);冷却内不再重取(补货也受它限流)。对三种来源一视同仁。出厂 `config.ini` 是 `1` | 即时 |
|
||||
| `buffer_size` | `8192` | 明文 HTTP 转发累计多少字节就施加一次背压(等客户端把数据读走),最小 1024。它**不控制切块大小** —— 响应体按上游交付的原生块(通常 64 KiB)转发,不做重切;隧道中继固定 64 KiB,也不受这一项影响 | 即时 |
|
||||
| `client_keepalive_expiry` | `30` | 空闲回收(秒):保活连接空闲这么久就失效;装这些连接的客户端按同一个时间回收(它不该比连接活得短)。调小会让刚预热好的连接被丢掉、连接复用变差 | 即时 |
|
||||
| `tunnel_idle_timeout` | `10` | 隧道建立后「客户端发过数据、上游一个字节都没回」持续多久判为上游装死(秒);`0` 表示不判。这是发现「收下 CONNECT 后不回数据」的**唯一手段**,但上游只是排队很慢时也会命中 —— 上游在高并发下响应普遍超过这个值,就把它调大 | 即时 |
|
||||
| `log_max_bytes` | `10485760` | 单个日志文件的大小上限(字节),超出后轮转 | 需重启整个应用 |
|
||||
| `log_backup_count` | `3` | 轮转后保留的历史文件份数;至少 1(`0` 会让日志永不轮转) | 需重启整个应用 |
|
||||
| `proxy_failure_cooldown` | `3` | 故障告警的折叠窗口(秒):同一类告警在窗口内只写一条日志,并记下被折叠了多少条。高并发下失败是成批来的,不折叠日志会被同一句话刷满。折叠**只发生在日志层**,出口的记账与调度不受影响(那套看的是近期失败占比,不靠计时器) | 即时 |
|
||||
| `display_level` | `1` | 控制台详细程度:`0` 仅显示出口名册变化与错误信息;`1` 显示出口名册、各出口状态、倒计时与错误信息;`2` 显示所有详细信息(`2` 与 `3` 的行为逐条相同,填 `3` 会被归一成 `2`);`2` 会在启动横幅后附上各级别说明。只影响命令行运行 `ProxyCat.py` 时的输出 | 即时(仅作用于命令行入口的输出) |
|
||||
|
||||
### 访问控制与界面语言
|
||||
|
||||
这几个键不在「代理配置」页的七个分组里:黑白名单与绕过名单的设置在面板「访问控制」页,语言切换在侧边栏底部。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `whitelist_file` | `whitelist.txt` | 客户端 IP 白名单文件名;只取文件名,固定从 `config/` 目录读取 | 即时 |
|
||||
| `blacklist_file` | `blacklist.txt` | 客户端 IP 黑名单文件名;只取文件名,固定从 `config/` 目录读取 | 即时 |
|
||||
| `ip_auth_priority` | `whitelist` | 同时命中黑白名单时的判定优先级:`whitelist`(放行)/ `blacklist`(拦截) | 即时 |
|
||||
| `bypass_whitelist_file` | `bypass_whitelist.txt` | 绕过名单文件名:命中的目标地址直连、不经过上游代理(支持通配符),不做拦截判断;只取文件名,固定从 `config/` 目录读取 | 即时 |
|
||||
| `language` | `cn` | 界面与提示语言:`cn` / `en`。语言是**服务端设置**(写回 `config.ini`):切换后面板文案、接口错误文案、导出文件的表头一起变;命令行入口的控制台横幅只在启动时打印一次,不会跟着重印 | 即时 |
|
||||
|
||||
## [Users]
|
||||
|
||||
出口代理的 Basic 认证账号表:键为用户名、值为密码,一行一个账号;整段为空表示代理不要求认证。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `用户名 = 密码`(如 `neko = 123456`) | 内置默认没有 `[Users]` 段,不要求认证 | 请求需带 Basic 认证,不带认证的请求会得到 `407`。仓库自带的 `config.ini` 里有两个账号:`k = 123` 与 `neko = 123456` | 面板「访问控制 → 用户管理」保存即时生效 |
|
||||
|
||||
建议通过面板的用户管理维护 —— 面板保存会直接更新运行中的服务;手工编辑本段也会随配置热重载被读到。
|
||||
|
||||
## [api_credentials]
|
||||
|
||||
上游取代理接口的多套凭据,由面板「代理配置 → 来源设置(API 来源)」里的凭据管理维护,可一键切换当前生效的一套。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `credential_sets` | `[]` | 可用的取 IP 凭据集合,JSON 数组,每项为 `{"name", "url", "username", "password"}`;手改本行必须保持为合法 JSON,否则整份凭据都读不出来 | 面板保存即时生效 |
|
||||
| `active_credential` | 空 | 当前启用的凭据名,须与 `credential_sets` 中某一项的 `name` 完全一致;不一致时视为未选用 | 面板保存即时生效 |
|
||||
|
||||
## [Pool] 配置项
|
||||
|
||||
`[Pool]` 段的键用点号命名。修改后保存即可热生效,无需重启;也可以直接通过面板的「代理池 → 池设置」页修改 —— 两边是同一份配置。下面的分组顺序与「池设置」页一致,由 `GET /api/pool/schema` 驱动。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理池 → 池设置」页:下面八个分组的表单都在这一页里。
|
||||
|
||||
### 数据库
|
||||
|
||||
数据存在哪、怎么备份。
|
||||
|
||||

|
||||
|
||||
图中为面板「代理池 → 数据库维护」页:数据库统计与优化、备份列表与恢复、离线归属地库的更新与重算。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `database.path` | `data/proxies.db` | 代理池所有数据存放的文件(SQLite 格式)。填相对路径时以 `modules/proxypool/` 目录为起点。库在启动时就已经打开 | 需重启池服务 |
|
||||
| `database.pool_max_readers` | `4` | 同时查询数据库的连接数上限,写入固定只占 1 个连接。调大能让并发查询更顺畅,也会多占用一些文件句柄;池子不大时保持默认即可 | 需重启池服务 |
|
||||
| `database.backup_enabled` | `true` | 是否按下面的间隔自动备份数据库;保存后立即生效(关掉即停,重新打开即起) | 即时 |
|
||||
| `database.backup_interval_hours` | `24` | 自动备份的间隔(小时) | 即时 |
|
||||
| `database.backup_retention_days` | `7` | 备份文件保留多少天,超期自动删除 | 即时 |
|
||||
|
||||
### 代理验证
|
||||
|
||||
什么样的代理算可用。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `validator.check_anonymity` | `true` | 是否检查代理的匿名度。匿名度靠「目标站点实际收到了哪些请求头」判定,只在经代理发一次明文请求时才测得出来;关掉后不再发这类请求、也不再判匿名度,已经测出来的等级原样保留 | 即时 |
|
||||
| `validator.host_public_ip` | 空 | 本机公网出口 IP,判定「透明代理」时的对比基准。双栈主机两族各填一个、用逗号隔开(IPv4 与 IPv6 各至多一个),两族都会参与比对;只填一个也能用。留空时 ProxyCat 会**不经过代理**直接访问 IP 查询站点获取(每 30 分钟最多一次,成功与失败都重新计时);填上你自己的出口 IP 可以完全免掉这次对外请求。留空或拿不到时无法判定匿名度,那一项会保持原样 | 即时 |
|
||||
| `validator.timeout_seconds` | `5` | 验证一个代理最多等多少秒,超过就判为不可用。所有探测请求都按这个上限 | 即时 |
|
||||
| `validator.connect_timeout_seconds` | `2.0` | 连接代理最多等多少秒。失效的代理大多卡在连不上这一步,调小它可以明显加快整批验证;填 `0` 表示不单独限制,只受上面的总超时约束 | 即时 |
|
||||
| `validator.max_concurrent` | `400` | 同时验证多少个代理。每次验证连的是不同主机,可以放心调高;同时它也决定本机连接池上限与可打开的 socket 数(一次验证会并发发出十几个请求),受限环境应调低;如果测试站点开始限流(表现为大量代理突然超时),也调低一些。另外,存活探测阶段的排队超时算「不下结论」、不写任何字段;存活通过之后并发发出的身份与明文探测若因本机连接池排队超时,会被记成一次失败样本、拉低健康分的成功率维度 —— 所以它不是越高越好 | 即时 |
|
||||
| `validator.identity_echo_apis` | `https://httpbin.org/anything, https://httpbingo.org/anything, https://eu.httpbin.org/anything` | 用来探测出口 IP 和匿名度的接口,多个用英文逗号分隔,必须是 https。这类接口要能返回 origin 与 headers,一次请求就能同时得出出口 IP、匿名度和 HTTPS 支持情况。连不上的会被自动跳过;可用的按直连延迟排序后逐个补发竞速,不按条数截断,多写的留作替补 | 即时 |
|
||||
| `validator.plain_identity_echo_apis` | `http://postman-echo.com/get` | 补充的明文回显接口,多个用英文逗号分隔,必须是 http。匿名度靠「目标实际收到了哪些请求头」判定,明文路径是它的标准观测面,默认用的是上面几个接口的明文地址。如果代理够不到那些站点、导致匿名度总被判成兜底档,可以把代理能访问的回显站点填在这里 | 即时 |
|
||||
| `validator.ip_check_apis` | `https://icanhazip.com, https://checkip.amazonaws.com, https://api.ip.sb/ip, https://realip.cc/simple, https://httpbin.org/ip, https://api.ipify.org, https://ipinfo.io/json, https://ipwho.is/, https://www.cloudflare.com/cdn-cgi/trace` | 只返回 IP 的接口,多个用英文逗号分隔,必须是 https。上面的接口都拿不到结果时用它们兜底,同样按直连延迟逐个补发竞速,不按条数截断 | 即时 |
|
||||
|
||||
### 抓取插件
|
||||
|
||||
多久去各来源抓一次。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `plugins.scan_interval_seconds` | `60` | 多久检查一次有没有插件该抓取了(秒)。它只决定检查的频率,不改变各插件自己的抓取周期 | 即时 |
|
||||
| `plugins.default_interval_minutes` | `60` | 新插件默认多久抓取一次(分钟)。每个插件都可以单独调整自己的周期;这一项只影响之后新建的插件 | 即时 |
|
||||
| `plugins.execution_timeout_seconds` | `300` | 单个插件一次抓取最多跑多少秒,超时就中断 | 即时 |
|
||||
| `plugins.request_timeout_seconds` | `30` | 插件请求一次网页最多等多少秒;目标站点打不开时按这个时间等待 | 即时 |
|
||||
| `plugins.max_parallel_plugins` | `3` | 最多同时运行几个插件 | 即时 |
|
||||
| `plugins.max_retries_on_failure` | `2` | 插件抓取失败后额外重试几次。填 `2` 表示最多一共尝试 3 次 | 即时 |
|
||||
| `plugins.retry_base_delay_seconds` | `10.0` | 插件失败后重试前等待的秒数。每多试一次等待翻倍(第 1 次等 1 倍、第 2 次 2 倍、第 3 次 4 倍),避免短时间内反复打同一个站点 | 即时 |
|
||||
|
||||
### 自动重新验证
|
||||
|
||||
失效的代理怎么被发现。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `auto_revalidation.enabled` | `true` | 是否定时重新检查已入库的代理还能不能用 | 即时 |
|
||||
| `auto_revalidation.interval_minutes` | `360` | 默认多久重新检查一遍(分钟)。每个插件可以在「插件管理」里单独设置自己的周期 | 即时 |
|
||||
| `auto_revalidation.only_valid_proxies` | `true` | 只检查当前可用的代理,跳过已经判定失效的 | 即时 |
|
||||
| `auto_revalidation.run_on_start` | `true` | 启动后马上先检查一遍,而不是等满一个周期 | 即时 |
|
||||
| `auto_revalidation.full_recheck_interval_minutes` | `1440` | 多久做一次完整检测(分钟)。日常检查只看代理还通不通;出口 IP、匿名度、协议支持这些信息很少变,隔较长时间重新测一遍即可 | 即时 |
|
||||
|
||||
### 自动清理
|
||||
|
||||
什么样的代理会被删掉。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `auto_cleanup.enabled` | `true` | 是否自动删除质量太差的代理。删除不可恢复,拿不准时先关掉它只做检查 | 即时 |
|
||||
| `auto_cleanup.min_health_score` | `10.0` | 健康分低于这个值的代理会被删除,同时还要满足下一条的次数要求 | 即时 |
|
||||
| `auto_cleanup.min_checks_before_cleanup` | `3` | 至少检查过多少次才允许按评分淘汰,避免刚入库的代理因为样本太少被误删 | 即时 |
|
||||
| `auto_cleanup.max_failures` | `3` | 连续失败这么多次就删除 | 即时 |
|
||||
| `auto_cleanup.max_age_days_if_invalid` | `3` | 判定失效后最多再保留几天,超过就删除 | 即时 |
|
||||
|
||||
### 使用期反馈
|
||||
|
||||
转发失败算不算数。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `use_feedback.enabled` | `true` | 代理在实际转发中不通时,是否把它记为一次失败并在连续失败达阈值后标记失效。检测分不清「代理坏了」和「我们自己网络抖动」,阈值就是用来吃掉后者的;关掉后池只信自己的验证结果 | 即时 |
|
||||
| `use_feedback.invalid_threshold` | `3` | 连续失败这么多次就标记失效,期间任何一次成功都会清零 | 即时 |
|
||||
|
||||
### 写入队列
|
||||
|
||||
记录攒多少再落库。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `write_queue.batch_size` | `100` | 攒够多少条记录才一次性写进数据库 | 即时 |
|
||||
| `write_queue.max_retries` | `3` | 一批记录最多尝试写几次(含第一次)。填 `0` 或 `1` 都表示失败就放弃 | 即时 |
|
||||
| `write_queue.max_queue_size` | `10000` | 等待写入的任务最多排多少个。排满后新任务会被拒绝,以免占用过多内存。队列容量在创建时固定,中途改会丢掉已排队的数据 | 需重启服务 |
|
||||
|
||||
### 日志
|
||||
|
||||
日志写多大、留几份。
|
||||
|
||||
| 配置项 | 默认值 | 说明 | 生效方式 |
|
||||
|---|---|---|---|
|
||||
| `logging.file_path` | `logs/proxy_pool.log` | 代理池的日志文件。填相对路径时以 `modules/proxypool/` 目录为起点 | 即时 |
|
||||
| `logging.max_bytes` | `10485760` | 单个日志文件的大小上限(字节)。写满后自动换一个新文件,旧文件继续保留 | 即时 |
|
||||
| `logging.backup_count` | `5` | 最多保留几个历史日志文件 | 即时 |
|
||||
|
||||
## 面板不展示的配置项
|
||||
|
||||
下面四个键仍写在 `config.ini` 里,但面板不再展示;实际取值由当前运行参数推算:
|
||||
|
||||
| 配置项 | 默认值 | 实际取值 |
|
||||
|---|---|---|
|
||||
| `client_max_connections` | `1000` | `max(本值, 单出口并发上限 × 2)`;单出口不限并发(`max_concurrent_per_proxy` 为 `0`)时退回全局并发上限 |
|
||||
| `max_pool_size` | `500` | `max(本值, 名册数 × 2, 出口数 × 4)` —— 按当前名册与扩容上限推算,低于推算值时本值被忽略 |
|
||||
| `client_max_keepalive` | `8` | 直接使用本值 |
|
||||
| `client_idle_timeout` | `300` | 由 `client_keepalive_expiry` 推出,取 `max(它, 30)`;面板不读取本值,写回时按推导值覆盖 |
|
||||
|
||||
几点说明:
|
||||
|
||||
- `client_max_connections` 设小了会把请求闷进 httpx 连接池排队;`max_pool_size` 低于推算值时,每个出口的客户端会互相淘汰,连接复用就没了。两者的值高于推算值时照常生效,留在这里只为了兼容老配置里调大过的值。
|
||||
- `client_max_keepalive` **不要调大**:httpx 的连接池在分配请求时会对空闲连接做一遍全池扫描(httpcore 1.0.9 的实现),空闲连接越多,每个请求花在这上面的 CPU 越多 —— 实测池里空闲连接几十条时它就能吃掉单核的一半以上,吞吐掉到三分之一。压小这一项(连接用完就关、用时再建)实测在有无上游延迟两种场景下都更快;真要让单个出口扛更多并发,调 `client_max_connections` 而不是这一项。
|
||||
- `client_idle_timeout` 是客户端的连接池空闲多久后回收(秒),由 `client_keepalive_expiry` 推算,手改的值会被推导值覆盖,留在这里只为了兼容老配置。
|
||||
@@ -0,0 +1,530 @@
|
||||
# ProxyCat Features
|
||||
|
||||
> Documentation: [Back to README](../README-EN.md) · [Configuration](Configuration-EN.md) · [Operation Manual](Operation%20Manual-EN.md) · [Investigation Manual](Investigation%20Manual-EN.md) · [Changelog](logs-EN.md)
|
||||
|
||||
## Outbound Proxy Service
|
||||
|
||||
- **Dual Protocol Listening**: one port accepts both HTTP and SOCKS5 inbound requests.
|
||||
- **Triple Upstream Types**: authenticated HTTP / HTTPS / SOCKS5 proxy servers.
|
||||
- **Two Rotation Modes**: `cycle` works down the list in order (ignoring load — the next entry is
|
||||
used only once the previous one is full), `loadbalance` picks the least stressed exit by live load.
|
||||
- **Three Proxy Sources**: `local` reads a local list, `api` fetches from an endpoint live, `pool`
|
||||
uses the built-in proxy pool directly.
|
||||
- **Many Exits at Once**: several upstream proxies are fetched in one go and used simultaneously;
|
||||
requests are spread by each exit's live load by default (`local` with `cycle` picks down the list
|
||||
in order, ignoring load). An exit that fails is dropped from rotation and replaced automatically —
|
||||
so an upstream that rate-limits per exit IP scales concurrency with the number of exits (see
|
||||
[Upstream Exits and Concurrency](#upstream-exits-and-concurrency)).
|
||||
- **Access Control**: client IP whitelist / blacklist, with `ip_auth_priority` deciding whether to
|
||||
admit or block a client that matches both; plus a bypass list whose matching targets connect
|
||||
directly, bypassing the upstream proxy entirely.
|
||||
- **Outbound Authentication**: username / password authentication, disabled when the account table
|
||||
is empty.
|
||||
|
||||
Switching the source uses the source switcher at the top of the Proxy Config page; changing the
|
||||
mode and lifetime and adjusting exit capacity happen in the Exit rotation group — saving applies
|
||||
immediately:
|
||||
|
||||

|
||||
|
||||
The screenshot above shows the Exit rotation group with the Pool source selected: the rotation mode,
|
||||
exit lifetime (two independent rules, by time and by request count), exits kept in use, and how long
|
||||
a request queues when no exit is available.
|
||||
|
||||
## Built-in Proxy Pool
|
||||
|
||||
- **Plugin-based Collection**: scrapers live in `modules/proxypool/plugins/` and are scanned and
|
||||
loaded when the pool service starts; a brand-new file requires a pool service restart, while a
|
||||
plugin already in the list can be hot-reloaded after edits by clicking Reload in the panel.
|
||||
- **Verify Before Storing**: a port pre-screen drops addresses with nothing listening; surviving
|
||||
candidates then get full validation, and only confirmed-working proxies are written.
|
||||
- **Three Validation Intensities**: `liveness` checks reachability only, `identity` also fills in
|
||||
identity data, `full` does a complete evaluation. Automatic revalidation picks among the three by
|
||||
strategy; batch validation on the panel can be run as `liveness` / `full`, and `auto` still
|
||||
follows the strategy (`identity` is not an option for the batch API).
|
||||
- **Health Scoring**: a 0-100 score combining delay 25%, success rate 25%, stability 15%, uptime 15%,
|
||||
anonymity 10% and longevity 10%; the success dimension uses the Wilson lower bound, so it shrinks
|
||||
toward the low end when samples are few.
|
||||
- **Offline Geolocation**: a local offline database returns both Chinese and English region names,
|
||||
with no external queries.
|
||||
- **Auto Revalidation & Cleanup**: periodic re-testing, plus cleanup by health score, consecutive
|
||||
failures and how long invalid entries are retained.
|
||||
- **Usage Feedback**: outcomes of real requests during runtime flow back into the pool, and a proxy
|
||||
is marked invalid once consecutive failures hit the threshold (see
|
||||
[How Usage Feedback Works](#how-usage-feedback-works)).
|
||||
|
||||
For when the pool starts and how it operates, see [Proxy Pool Mechanics](#proxy-pool-mechanics)
|
||||
below; the complete plugin authoring contract is in
|
||||
[modules/proxypool/README-EN.md](../modules/proxypool/README-EN.md).
|
||||
|
||||
## Panel and Operations
|
||||
|
||||
- **Web Management Panel**: status overview, configuration editing, proxy list, list management and
|
||||
service start/stop all on one screen; settings are grouped by purpose, with tuning parameters kept
|
||||
in a separate Advanced group rather than mixed onto the same screen as everyday switches.
|
||||
- **Log Center**: four categories by purpose (access / proxy / main / pool), with level and keyword
|
||||
filtering, browsing of rotated historical files, export and clearing. Note that the log level and
|
||||
the access log are linked: successful access lines are INFO, so once the level is raised to
|
||||
WARNING or above the access log keeps only failures and errors — the panel warns you in place when
|
||||
that happens.
|
||||
- **Access Records**: every proxy request recorded individually, filterable by time / upstream /
|
||||
target / outcome and exportable as CSV / JSON.
|
||||
- **Domain Statistics**: success and failure counts accumulated per "target host × upstream proxy",
|
||||
with drill-down.
|
||||
- **Bilingual UI**: the panel switches between Chinese and English; the language is a server-side
|
||||
setting, so the whole UI's text switches together.
|
||||
- **Credential Management**: credentials for the upstream proxy-fetch endpoint can be stored as
|
||||
multiple sets and switched with one click.
|
||||
- **Containerized Deployment**: one-command Docker deployment, with a built-in health check that
|
||||
probes the actual panel port.
|
||||
- **Version Check**: compares against the latest release once at startup and then every 24 hours;
|
||||
the result is persisted, so restarting within 24 hours neither re-checks nor makes opening the
|
||||
panel wait on the network.
|
||||
|
||||
A tab-by-tab walkthrough of the panel's four tabs (Proxy Config / Proxy Pool / Access Control /
|
||||
Logs), with screenshots, is in the [Operation Manual](Operation%20Manual-EN.md).
|
||||
|
||||
### Logs and Access Records
|
||||
|
||||
Logs are routed by purpose into four files under `logs/`, never mixed, each rotating on size:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `logs/main.log` | Main program: startup, configuration, panel address, shutdown |
|
||||
| `logs/access.log` | Proxy requests: time, client, upstream proxy, target host, outcome, duration |
|
||||
| `logs/proxy.log` | Proxy lifecycle: loading, rotation, validation, retries |
|
||||
| `logs/error.log` | Everything at ERROR and above across the categories — look here first when troubleshooting |
|
||||
|
||||
Each line of the access log ends in one of three outcomes, with distinct meanings:
|
||||
|
||||
| Outcome | Meaning |
|
||||
|---|---|
|
||||
| `[OK]` | The request really went through (for an HTTPS tunnel, only once the upstream returns data — writing back 200 alone does not count) |
|
||||
| `[FAIL]` | The request failed; the reason is written at the end of the line |
|
||||
| `[ABORT]` | The request ended before a success or failure could be decided (the client disconnected without sending data, neither side of a tunnel ever sent bytes, the request was cancelled); excluded from both the success and failure rates |
|
||||
|
||||
The status code at the end of the line is what was written back to the client, and it may disagree
|
||||
with the outcome: an `[OK]` line records the upstream's own status code, so an upstream 4xx/5xx
|
||||
still logs as `[OK]`; an `[ABORT]` line keeps whatever status was already written back (200, say)
|
||||
even though the outcome counts as neither success nor failure; and a `[FAIL]` such as a tunnel that
|
||||
was established but whose upstream never sent anything keeps no status code at all — it shows as
|
||||
`-`. So **read the outcome column, not just the status code**.
|
||||
Passwords in upstream proxy addresses are always masked as `***` before being written, so no
|
||||
plaintext credentials are recorded. The proxy pool's own logs still go to
|
||||
`modules/proxypool/logs/proxy_pool.log`.
|
||||
|
||||
Access statistics aggregate by **upstream proxy**, and expanding a proxy shows every host that proxy
|
||||
visited. The aggregation key is "host × upstream proxy" rather than host alone — the same host can
|
||||
behave completely differently through different proxies, and keying on the host alone would flatten
|
||||
away the most useful fact, "this proxy cannot reach this site". The counters are persisted to
|
||||
SQLite, so the cumulative totals survive a restart.
|
||||
|
||||
## Upstream Exits and Concurrency
|
||||
|
||||
ProxyCat maintains an **exit pool with a target size**: `exit_count` upstream proxies are kept in
|
||||
use at all times — not one IP, and not a batch that gets swapped wholesale.
|
||||
|
||||
**How the pool is maintained**:
|
||||
|
||||
1. One source call is made; the addresses it returns go into a **standby queue** (with its own
|
||||
expiry and length cap).
|
||||
2. Candidates are drawn from standby to enter the roster, **up to 4 probed concurrently per round**
|
||||
(not one at a time); those that pass enter the roster in order until it holds `exit_count`. A
|
||||
candidate that fails validation is discarded, and one that passes but is not needed this round
|
||||
goes back to standby.
|
||||
3. When an exit is declared dead, **only that one** is replaced; the rest keep serving. Fetching and
|
||||
rotation are **request-driven**: only while requests are passing through the exits does the
|
||||
background task top up from the source or replace expired exits; a round that makes no progress
|
||||
does not spin on a retry — it backs the retry interval off (doubling it, up to 60 seconds) and
|
||||
tries again, and it always starts only while traffic is recent. Once traffic stops (after about a
|
||||
10-second grace period) nothing more is fetched: expired exits are **removed** rather than
|
||||
replaced (`continuous` does not depend on traffic — see the next point), so the pool can drain
|
||||
empty — a cold start pays one fetch plus validation on its first request.
|
||||
4. **Every exit has its own lifetime** (every source, all four run modes): it expires `interval`
|
||||
seconds after entering the roster, and **all** expired exits are replaced (not one per interval —
|
||||
that would let the unluckiest exit live for `exits × interval`). Exits that entered together
|
||||
**expire together**, so the replacement happens in one burst; it is always add-before-drop, and
|
||||
when standby cannot supply a replacement the old exit is kept rather than swapped, so the roster
|
||||
never develops a gap. `request_interval` is the same lifetime counted in requests (each exit
|
||||
counts how many times it was handed out). It and `interval` are **two independent rules that
|
||||
both apply**: set both and whichever comes first wins — it is not either/or.
|
||||
|
||||
**When** a replacement happens is decided by the run mode: `continuous` (Continuous) is
|
||||
independent of idleness — the background keeps replacing expired exits on their lifetime and
|
||||
refilling, holding the pool at `exit_count`; `request` (On request) fetches and rotates
|
||||
only when a new task arrives, and with no tasks at all expired exits are **removed outright** so
|
||||
the pool empties, until the next request arrives and fetches a fresh fill. The local source's two
|
||||
values (`cycle` / `loadbalance`) decide *which exit a request uses*; the timing of rotation is
|
||||
request-driven, just like `request` mode — the next entry from the file is swapped in, and when
|
||||
the file has no spare entry the exit is renewed in place (otherwise it would sit at
|
||||
"0 remaining" forever).
|
||||
|
||||
If the source cannot supply replacements at that moment the pool briefly runs short: requests
|
||||
then **queue** (up to `exit_wait_timeout` seconds, below) instead of being rejected on the spot;
|
||||
they are served as soon as an exit lands, and only get a 503 once the wait runs out.
|
||||
|
||||
How many are fetched is decided by `exit_count`, independent of the source: every fetch
|
||||
over-fetches **`min(shortfall × 8, 24)`** addresses into standby, and candidates are then drawn from
|
||||
standby, validated, and used to fill the roster to `exit_count`. Both `api` and `pool` fetch by that
|
||||
over-fetch figure — and on `api`, any rows beyond it are **truncated and discarded**, never entering
|
||||
standby — while `local` uses the file itself with `exit_count` as the upper bound. The panel's Proxy
|
||||
Status area lists every exit (address, in use / cap, **remaining lifetime**), and the gauge at the
|
||||
top tracks the **soonest expiry**; the CLI prints `Upstream exits: N/target` plus one line per exit
|
||||
with its own remaining lifetime. When an expiry falls due with no traffic (except in `continuous`
|
||||
mode) the exit is not replaced: expired exits are dropped from the roster one by one, and only a new
|
||||
request fetches replacements.
|
||||
|
||||
Requests are assigned by **each exit's live in-flight count** by default (not plain round-robin) —
|
||||
tunnels live for very different durations, so counting requests would skew the load; `local` with
|
||||
`cycle` picks down the list, ignoring load.
|
||||
|
||||
**How failures are counted**: every exit carries a **sliding window** (its last 10 results), and
|
||||
decisions are read off the failure share in it, not a consecutive-failure count. The window does not
|
||||
record successes from a "clean" period — until an exit has ever failed, successes do not enter the
|
||||
window, so its first failure fills the share and sends it behind the healthy exits on the spot; only
|
||||
once it has a failure on record do successes enter the window and dilute the share:
|
||||
|
||||
- **Mostly failing means someone else goes first** (share above half): when requests are assigned it
|
||||
queues behind the healthy exits, but it **remains usable** — when everything looks suspect they
|
||||
are all used anyway. There is no cooldown: every success is recorded back into the window and
|
||||
pushes the share down, and once the share is back below half it returns to the competition on its
|
||||
own (while failures still dominate the window, one success is not enough to flip it). **A share
|
||||
above half also gets it probed once on its own after a real failure**: if the probe fails it is
|
||||
dropped from the roster (its share can be far below 90% at that point, and the window may hold
|
||||
fewer than 5 results); if the probe passes it stays.
|
||||
- **A 90% share with at least 5 results in the window** also retires it from the roster — that is
|
||||
the failure window's own retirement threshold, not the only path out of the roster. The gap is
|
||||
filled by the background replenisher (source fetches are rate-limited by `switch_cooldown`;
|
||||
filling from standby is not). Reading a share rather than a streak is what separates "one hiccup
|
||||
that cut several tunnels at once" from "this exit is broken": the former's failures get diluted by
|
||||
the successes that follow, while the latter keeps climbing. Conversely, a hiccup that cuts enough
|
||||
tunnels to fill the entire window with failures does retire it right away — at that moment it
|
||||
really did fail every one of dozens of requests.
|
||||
- **The last exit in the roster is never retired.** Retiring it means a 503 for everything, and for
|
||||
the `local` source the "just retired" memory keeps a replacement out for minutes; leaving it in
|
||||
place beats having no exit at all.
|
||||
- **Accounting is never folded; only the log is.** Within the `proxy_failure_cooldown` window a
|
||||
single warning is written per kind of event, noting how many were folded together. Every failure
|
||||
still enters the window as it is — folding the accounting would erase the very fact ("this exit
|
||||
keeps failing") that decides whether it still gets work.
|
||||
|
||||
**How checking works**: `check_proxies_on_use` controls the **validation performed while filling a
|
||||
slot** — every candidate is really probed (one request) before it enters the roster, rather than
|
||||
just having its port checked; a vendor address can expire within a minute, so an open port proves
|
||||
little. It is not a per-request check: an exit that dies while serving is handled by the failure
|
||||
retirement above.
|
||||
|
||||
Starting the service (the `ProxyCat.py` entry or starting it from the panel) also runs a full check
|
||||
over the current roster (`check_proxies_on_startup`): failing exits are **dropped individually**, and
|
||||
the gaps are left to replenishment. The check that follows a **proxy-file reload** only happens on
|
||||
the CLI entry — `ProxyCat.py` notices `ip.txt` was modified externally, reloads it and checks along
|
||||
the way — whereas saving the local proxy list in the panel only reloads the roster and triggers no
|
||||
check (`app.py` does not watch `ip.txt` for changes either). The panel's Check button checks the
|
||||
**source list** (for the `local` source, every entry in `ip.txt`), so exits already retired can
|
||||
still be re-checked.
|
||||
|
||||
**How concurrency adds up**:
|
||||
|
||||
```text
|
||||
requests actually hitting upstream ≈ min(max_concurrent_requests, exits × max_concurrent_per_proxy)
|
||||
```
|
||||
|
||||
- `max_concurrent_per_proxy` defaults to `0` (unlimited), which leaves concurrency governed by
|
||||
`max_concurrent_requests` alone — identical to the single-upstream era. When **your upstream
|
||||
rate-limits per exit IP**, set it to what one IP can bear (say, 20); the excess queues instead of
|
||||
hammering the upstream.
|
||||
- It counts **active** connections: an HTTP request counts while it is being forwarded, and a
|
||||
tunnel counts after establishment only while it is actually relaying data. Idle tunnels (browser
|
||||
keep-alive, a frozen client, a half-dead upstream) do not consume quota, so this is an
|
||||
**admission** control rather than "the number of upstream connections open at once" — when an
|
||||
idle tunnel starts relaying again the active count can briefly sit slightly above the cap,
|
||||
truthfully reflecting that many transfers in flight.
|
||||
- `max_concurrent_requests` is the global semaphore applied to all three request kinds, sized in
|
||||
terms of tunnels rather than requests: it is the hard bound on how many tunnels this machine will
|
||||
carry at once, and it is what actually applies when the per-exit caps sum to far more than it.
|
||||
|
||||

|
||||
|
||||
The screenshot above shows the panel's Proxy Config → Performance group: the global concurrency cap
|
||||
and the per-exit concurrency cap. With the per-exit cap at `0` nothing is limited and auto-expansion
|
||||
will not grow the pool — but an expansion already stretched out is still released as usual.
|
||||
|
||||
**How "in use" is counted**: the panel and the CLI report the active figure above — connections
|
||||
currently serving a request plus tunnels that relayed data recently. An idle but still-open tunnel
|
||||
is not shown (it still holds an upstream connection and a slot, it is just not moving bytes), so the
|
||||
number falls back once requests stop without waiting for the peer to close.
|
||||
|
||||
**How resources come back**: when a transfer finishes (either end of a tunnel closes, or a response
|
||||
is fully written) the exit slot is released immediately, not when the connection object is
|
||||
eventually collected. Tunnels whose **client has vanished** (network drop, NAT timeout, killed
|
||||
process) are reaped by minute-level TCP keepalive: the OS default is two hours, which would pin an
|
||||
exit slot for two hours per abandoned tunnel and drain the pool under continuous traffic — now
|
||||
probing starts after 60 s idle, every 10 s, so dead peers are detected and released by the kernel
|
||||
within a minute or two. Connections that are alive but idle (keep-alive reuse) answer the probes
|
||||
normally and are unaffected.
|
||||
|
||||
**Connection reuse**: plain HTTP forwarding uses a per-exit HTTP connection pool, and a connection's
|
||||
idle recycling time is governed by `client_keepalive_expiry`; the client objects caching those
|
||||
connections are recycled on a separate clock, `client_idle_timeout` (not shown in the panel).
|
||||
CONNECT and SOCKS5 tunnels cannot reuse connections — once an upstream connection carries a tunnel
|
||||
it belongs to that tunnel and cannot be handed to another request.
|
||||
|
||||
> `client_max_connections` (the per-exit connection cap) and `max_pool_size` (how many clients are
|
||||
> cached) are no longer shown in the panel: the former is derived as `per-exit concurrency cap × 2`
|
||||
> (falling back to the global concurrency cap when the per-exit cap is unlimited), the latter is
|
||||
> taken as `max(value, roster × 2, exits × 4)`. Their values still live in config.ini and still win
|
||||
> when set higher than the derived figures — you just no longer need to understand them.
|
||||
> `client_max_keepalive` (keepalive connections per exit) has also been dropped from the panel; it
|
||||
> defaults to 8 and values above 64 are rejected when saving (a hand-edited ini is not
|
||||
> re-validated) — the more idle connections there are, the more expensive httpx's whole-pool scan
|
||||
> gets when handing out a request, and raising it measurably cut throughput to a third.
|
||||
|
||||
**Auto-expansion under load (`auto_expand_enabled`, on by default, switchable off in the panel's
|
||||
Performance group)**: when `max_concurrent_per_proxy` is set, there are two trigger paths:
|
||||
① **immediate** — when the number of connections currently open on the listening port exceeds
|
||||
"exits in use × per-exit quota", the exit count is doubled on the spot (up to 2×) and filled in one
|
||||
go, without waiting for a watermark; ② **watermark fallback** — once 80% of exits reach that quota
|
||||
for a full second, the background adds exits one step at a time, and a step only counts if it
|
||||
actually added one. Expansion **only adds exits** (more upstream IPs sharing the load). The roster
|
||||
tops out at `max(exit_count, min(exit_count × 2, max_concurrent_requests))`: more exits than
|
||||
concurrent requests is pointless, and above that line no more exits get added — though **relaxing
|
||||
the per-exit quota is not subject to that line** and still proceeds on its own (see below). Once
|
||||
the peak passes the expansion is released **in one step**: pool-wide utilisation below half for 30
|
||||
seconds restores the per-exit quota first, then withdraws the surplus exits; an exit still relaying
|
||||
data is kept until it finishes its requests.
|
||||
|
||||
Growing and releasing look at **two different numbers**: growth asks "is it enough" (how many exits
|
||||
are pinned at their quota), release asks "how much is in use" (pool-wide utilisation). Judging
|
||||
release by the former backfires — once the load spreads out, no exit may be pinned any more while
|
||||
the pool is exactly big enough; dropping one step brings the queue right back and the next tick has
|
||||
to re-add it, so the exit count never stops floating.
|
||||
|
||||
Relaxing the per-exit quota (at most doubled) is a **last resort**: on the watermark path it **must
|
||||
be earned** — the quota is raised only when this round really did fetch from the source, got an
|
||||
answer, and still could not obtain a single usable new candidate. Not fetching because the fetch
|
||||
cooldown had not elapsed, candidates that failed their check, and a fetch that failed outright
|
||||
(timeout, source error) all mean "no conclusion this round" — the next round tries again. The
|
||||
immediate path is not gated that way: when the listening side is already overloaded and the roster
|
||||
is at its ceiling it raises the quota anyway (up to 2×) — adding exits spreads the load over more
|
||||
IPs, whereas relaxing the quota pushes the same pressure back onto the existing ones, which is
|
||||
exactly what `max_concurrent_per_proxy` exists to prevent. Conversely, **lowering the exit pool
|
||||
size** withdraws the surplus exits right away (only those with no requests in flight, regardless of
|
||||
the water line), while after a released expansion the surplus exits are not discarded immediately —
|
||||
they are withdrawn one by one **only once their own lifetimes run out**.
|
||||
|
||||
The water line is measured against the **configured** quota, not the relaxed one — otherwise
|
||||
relaxing it would itself depress the water line, the peak would immediately look over, and the pool
|
||||
would oscillate. With `max_concurrent_per_proxy` at `0` (unlimited) nothing expands, but an existing
|
||||
expansion is still released: unlimited per-exit concurrency is no reason to keep a stretched-out
|
||||
pool around.
|
||||
|
||||
## Proxy Pool Mechanics
|
||||
|
||||
The proxy pool is not a standalone project — it runs inside the ProxyCat process, shares the same
|
||||
web port as the panel, has no interface of its own, and is managed from the Proxy Pool tab in the
|
||||
panel.
|
||||
|
||||
**Subsystem documentation**: [modules/proxypool/README-EN.md](../modules/proxypool/README-EN.md) — covers
|
||||
scraper plugin development, validation strategy, anonymity detection, geolocation resolution and
|
||||
automatic cleanup in depth. Consult it as needed.
|
||||
|
||||
> `validator.max_concurrent` ("how many proxies to validate at once" in the pool settings) is the
|
||||
> resource knob for this subsystem: it also sets this machine's HTTP connection-pool ceiling and
|
||||
> open-socket budget, since one validation fires a dozen-odd requests at once. Lower it on a
|
||||
> constrained host or where `ulimit -n` is small. Timeouts caused by local pool queueing during the
|
||||
> **liveness probe** are ruled "no conclusion this round" and are not charged to the proxy — but if
|
||||
> the identity and plaintext probes that run **after liveness has passed** time out on queueing
|
||||
> too, that counts as an unsuccessful probe sample and directly drags down the success-rate
|
||||
> dimension of the health score.
|
||||
|
||||
### When Does It Start
|
||||
|
||||
The built-in pool **only starts when it is actually needed as the proxy source**, decided by
|
||||
`[Server] proxy_source_mode`:
|
||||
|
||||
| `proxy_source_mode` | Does the pool start with the service? |
|
||||
|---|---|
|
||||
| `pool` (maintained pool) | Yes |
|
||||
| `local` (local file) | No |
|
||||
| `api` (API fetch) | No |
|
||||
|
||||
It also does not start when `pool_remote_url` is set (an external pool is in use) — in that case
|
||||
the in-process pool is not used at all.
|
||||
|
||||
When proxies come from a local file or an API, a running pool would only burn resources scraping and
|
||||
validating over the network, so it stays off by default. If you switch the source to Pool in the
|
||||
panel while the pool is not running, it is **started automatically**; switching away does not stop
|
||||
it, so a pool that is currently serving proxies is never stopped by mistake — stop it by hand from
|
||||
the Proxy Pool tab when you want it stopped.
|
||||
|
||||
### In-Process vs. External Pool
|
||||
|
||||
The **in-process pool** is the default and works out of the box. If you already run another proxy
|
||||
pool service, point `pool_remote_url` at it and the outbound proxy will use that external pool
|
||||
instead:
|
||||
|
||||
- It is a **fetch endpoint, not a management API**: ProxyCat issues one GET to that address and
|
||||
**every line of the response body is a proxy address** (e.g. `http://1.2.3.4:8080`). A 200 with a
|
||||
valid format means "use these"; a 404 means "nothing available right now". Point it at another
|
||||
ProxyCat instance's `/api/pool/random?format=text&token=...` for a single exit; to **fetch many
|
||||
at once**, use `/api/pool/get?status=valid&limit=20&format=text&token=...` (you can also point it
|
||||
straight at the built-in pool's `/api/pool/get`, which then goes through the pool's API rather
|
||||
than calling in-process).
|
||||
- It **only takes effect when `proxy_source_mode=pool`**.
|
||||
- Setting it **stops the in-process pool from auto-starting** (the external pool replaces it).
|
||||
- **Usage feedback does not apply in external-pool mode** — the pool is not in this process, so
|
||||
there is nowhere to report it.
|
||||
|
||||
### How Usage Feedback Works
|
||||
|
||||
When the outbound proxy finds an exit failing during **real forwarding**, it first charges that
|
||||
failure to the exit itself. An exit whose failure share is above half is then **probed once on its
|
||||
own**: if the probe fails it is taken out of rotation (its share can be far below 90% at that point,
|
||||
and the window may hold fewer than 5 results); if the probe passes it keeps serving. A 90% share
|
||||
with at least 5 results in the window is retired directly by the failure window instead (both paths
|
||||
are described in [Upstream Exits and Concurrency](#upstream-exits-and-concurrency)). The probed
|
||||
exit's result is reported to the pool; after `use_feedback.invalid_threshold` consecutive failures
|
||||
(3 by default) the pool marks that proxy invalid, and any single success in between resets the
|
||||
counter.
|
||||
|
||||
> Taking an exit out and marking a pool entry invalid are **two different things**: the former only
|
||||
> changes which exits this process uses right now (refilled within seconds), the latter changes the
|
||||
> stored availability of that proxy (persistent). The former is fast and cheap, the latter slow and
|
||||
> heavy.
|
||||
|
||||
It **deliberately touches only two fields, availability and the total check count**, and never the
|
||||
failure count, validation timestamp, probe counters or health score — those are the criteria for
|
||||
automatic cleanup, and letting runtime hiccups pollute them would delete proxies irreversibly.
|
||||
Resetting the total check count is exactly what puts the record back into the revalidation
|
||||
candidates (the revalidator picks up records with a zero check counter regardless of
|
||||
`only_valid_proxies`). Validation cannot tell "the proxy is broken" apart from "our own network
|
||||
hiccuped", so the feedback only downgrades gently.
|
||||
|
||||
So: **a proxy marked invalid by usage feedback goes into the next automatic revalidation round**,
|
||||
and a successful re-test flips it back to available. If you do not want to wait, click "Validate
|
||||
invalid" in the panel's Proxy Management to re-test them immediately.
|
||||
|
||||
### Collection Plugins
|
||||
|
||||
Drop a plugin file into `modules/proxypool/plugins/` and it takes effect after a pool service
|
||||
restart (the directory is scanned once, when the pool service starts); a plugin already in the list
|
||||
can be hot-reloaded after a code change by clicking Reload in the panel's Proxy Pool → Proxy
|
||||
Management plugin list. Two conventions bite people most often:
|
||||
|
||||
- **A failed fetch must raise an exception.** Returning an empty list counts as "the fetch
|
||||
succeeded, it just found nothing this round" — the error is swallowed, the schedule advances
|
||||
normally, and the symptom is simply that you never collect anything.
|
||||
- **A plugin's file name (without the extension) is its identity** — configuration, scheduling and
|
||||
provenance records all use that name.
|
||||
|
||||
The full plugin authoring contract is in
|
||||
[modules/proxypool/README-EN.md](../modules/proxypool/README-EN.md).
|
||||
|
||||
## Outbound Network Requests
|
||||
|
||||
ProxyCat is not a silent local process: besides carrying your clients' traffic, it makes a small,
|
||||
fixed set of connections of its own, and the table below is the complete list of them — what triggers
|
||||
each one, where it goes, whether it is sent directly from this host or through the upstream proxy, and
|
||||
how to stop it. Direct connections are listed first.
|
||||
|
||||
| Trigger | Destination | Direct or through the upstream proxy | Related configuration | How to disable |
|
||||
|---|---|---|---|---|
|
||||
| A background thread starts with either entry point (`app.py`, `ProxyCat.py`) and fetches immediately when 24 hours have passed since the last attempt, when it has never checked, or when the version persisted in its state file differs from the running one; after that it ticks every 600 s and sends at most one request per 24 hours. The 24 hours counts from the last attempt, not the last success — a failed attempt still counts. | `y.shironekosan.cn` — the version announcement page on the author's site, hardcoded; the result is persisted to `logs/version_check.json`. | **Direct** | None — the URL is hardcoded in `modules/version_check.py`. | No switch at all; block the domain at the network layer (hosts file, firewall or DNS). The request uses httpx's default client (`trust_env=True`), so an `HTTP(S)_PROXY` environment variable routes it through that proxy — environment behaviour, not a ProxyCat setting. |
|
||||
| The pool starts with the plugin enabled and no next run recorded, so it scrapes once immediately; after that the scheduler scans every 60 s and runs the plugin on its own interval (60 min for a new plugin), retrying failures with backoff (2 extra attempts by default, 10 s base delay). The **Fetch** button in the plugin table (`POST /api/pool/plugins/geonode_plugin/run`) triggers a fetch at once. | `proxylist.geonode.com` — the public REST API, paged 500 at a time up to 20 pages (hardcoded). | **Direct** | Enabled state and per-plugin interval live in the pool database (Proxy Pool → Proxy Management); the global defaults under `[Pool]` are `plugins.scan_interval_seconds=60`, `plugins.default_interval_minutes=60`, `plugins.execution_timeout_seconds=300`, `plugins.max_retries_on_failure=2` and `plugins.retry_base_delay_seconds=10.0`. | Disable `geonode_plugin` in Plugin Management (written to the pool database, so it survives restarts), or stop the pool service; the address is hardcoded and redirecting it needs a code change. |
|
||||
| Same scheduling as the GeoNode plugin: one fetch as soon as the pool loads it, then every 60 min by default; the **Fetch** button (`POST /api/pool/plugins/github_proxy_plugin/run`) triggers it at once. | `raw.githubusercontent.com` — 12 hardcoded public proxy lists (6 http, 5 socks5, 1 https source). | **Direct** | The same `plugins.*` keys; enabled state and interval live in the pool database. | Disable `github_proxy_plugin` in Plugin Management, or stop the pool service; the addresses are hardcoded. |
|
||||
| Before every validation: each proxy's validation runs the reachability pre-flight (one probe per 300 s cache), the automatic re-validator runs it once at the head of each round, and a `test_url` not covered by the cached results is probed singly on demand. | `test_url` (default `https://www.baidu.com`), `validator.identity_echo_apis`, the plaintext variants derived from them, `validator.plain_identity_echo_apis` (default `http://postman-echo.com/get`) and three hardcoded connectivity checks — `connectivitycheck.platform.hicloud.com`, `cp.cloudflare.com`, `detectportal.firefox.com`; the plaintext variants and the connectivity checks join only with `check_anonymity=true`. | **Direct** | `test_url` (drives `validator.target_url`), `validator.identity_echo_apis`, `validator.plain_identity_echo_apis`, `validator.check_anonymity`. | Not switchable as a whole — it is part of the validation flow. `check_anonymity=false` removes the plaintext group and the three hardcoded probes, but `test_url` and the identity list are still probed directly; point those at addresses you own; emptying `identity_echo_apis` falls back to the built-in defaults (`httpbin.org` / `httpbingo.org`), so it is not an off switch. |
|
||||
| With `check_anonymity=true` and `validator.host_public_ip` left empty: the validation flow re-requests only once a 1800 s cache has expired, and both success and failure restart the timer. | `validator.ip_check_apis` — nine IP-echo endpoints (`icanhazip.com`, `checkip.amazonaws.com`, `api.ip.sb`, `realip.cc`, `httpbin.org/ip`, `api.ipify.org`, `ipinfo.io`, `ipwho.is`, `www.cloudflare.com/cdn-cgi/trace`); on failure the next endpoint is tried, family by family. | **Direct** | `validator.host_public_ip`, `validator.check_anonymity`, `validator.ip_check_apis`. | Set `host_public_ip` in Pool Settings (comma-separated for dual stack; an invalid value is treated as unset and the lookup continues) — the request is then never made; or `check_anonymity=false`; or empty `ip_check_apis` so there is nothing left to try. |
|
||||
| ① startup pre-check when the source is `local` and `check_proxies_on_startup=true`; ② per-candidate probe before a standby entry enters the roster when `check_proxies_on_use=true` (all three sources); ③ confirmation probes before a suspected-dead exit is retired or a silent tunnel is declared dead — not gated by either switch; ④ the panel's **Check** button (`GET /api/check_proxies`). Results are also cached in-process for 10 s. | The candidate or in-use SOCKS5 exit's `host:port` (from the local file, the api fetch or the pool), over direct TCP; the CONNECT target is the host of `test_url` (default `www.baidu.com`). No HTTP data is sent — only the SOCKS5 handshake and CONNECT. | **Direct** | `test_url`, `check_proxies_on_startup` (`true`), `check_proxies_on_use` (`true`). | Both switches off plus not clicking Check removes the startup and admission probes, but the confirmation probes still fire whenever an exit fails; point `test_url` at an address you control to choose the CONNECT target; stopping the proxy service stops it all. |
|
||||
| Every ingest — plugin scrapes and panel imports run the port pre-screen first; with the plugin's `skip_validation` on, this step and the validation are skipped together and the proxies are stored directly. | Each candidate proxy's `host:port` (from the scraped lists or the imported text — wherever those point). | **Direct** | No dedicated switch; a plugin's `skip_validation` (stored in the pool database, editable in the panel) skips both the pre-screen and validation. | Not running the scrapers and not importing anything avoids it; or turn on `skip_validation` for a plugin — at the cost of storing unvalidated proxies. |
|
||||
| `proxy_source_mode=api` and the exit pool needs stock: an empty roster forces a fetch on the request path; the background top-up loop fetches while there is recent traffic, or in `continuous` mode; the panel's **Refresh exits** button (`/api/switch_proxy`) and peak auto-expansion fetch as well. With the shipped empty `api_proxy_url`, api mode fails with a configuration error before anything is sent. | `api_proxy_url` (`[Server]`, empty by default); the fallback `http://example.com/getip` is used only when the `[Server]` section is missing entirely, file included. | **Direct** | `api_proxy_url` (`[Server]`); `proxy_username` / `proxy_password` are only appended to the fetched addresses and are not used for this request. | Leave `api_proxy_url` empty (api mode then reports a configuration error without networking), or switch `proxy_source_mode` to `local` / `pool`. |
|
||||
| `proxy_source_mode=pool` with `pool_remote_url` set, whenever the exit pool needs to top up or rotate (short roster, expiry, the request path, the **Refresh exits** button). With the shipped empty `pool_remote_url` nothing is sent — the in-process pool is used instead, which runs the scrapers and validation described in the rows above. | `pool_remote_url` (`[Server]`, empty by default). | **Direct** | `pool_remote_url` (`[Server]`); the request uses httpx with a 10 s timeout and `verify=False`, explicitly ignoring environment proxies (`trust_env=False`). | Clear `pool_remote_url` (falls back to the in-process pool). |
|
||||
| Manual only: Proxy Pool → Database Maintenance → Update Geo Databases, or `python -m core.infrastructure.geodb` on the command line. Nothing triggers it automatically. | `cdn.jsdelivr.net` — the GeoLite2-City and qqwry.ipdb data files (hardcoded). | **Direct** | None — hardcoded, with no automatic trigger path; the download uses the default `requests` client, so `HTTP(S)_PROXY` environment variables apply. | Not clicking the button means it never runs. Note that Full check (`/api/pool/update-geo/all`) is not a download — it re-validates the pool; Recompute Geo (`/api/pool/geo/recompute`) is offline. |
|
||||
| Any client request through the local proxy port whose target host matches a pattern in the bypass list; the plaintext HTTP, CONNECT tunnel and SOCKS5 paths all have this branch. | The target host the client asked for (this machine's IP is what its peer sees). | **Direct** | `bypass_whitelist_file` (default `config/bypass_whitelist.txt`, currently empty). | Empty the bypass list so everything goes through the upstream proxy; or do not point clients at this proxy. |
|
||||
| Opening the panel in a browser (the browser calls `/api/ads` and then loads each ad image); clicking an ad link navigates to it. These requests come from your browser, not from the ProxyCat process. | The `image_url` / `link_url` in `config/ads/*.json`; of the two files enabled today, one loads an image from `camo.githubusercontent.com` and both link to `github.com`. | **Direct** (browser-side) | No config key; the `enabled` / `image_url` / `link_url` fields in the JSON files. | Delete the ads JSON files or set `enabled` to `false`; an empty `image_url` stops the image from loading. |
|
||||
| ① startup pre-check when the source is `local` and `check_proxies_on_startup=true`; ② before a standby candidate enters the roster when `check_proxies_on_use=true` (all sources; subject to the 30 s result cache and the 5 s cooldown); ③ confirmation probes before a failing exit is retired or a silent tunnel is declared dead — not gated by either switch; ④ the panel's **Check** button (`GET /api/check_proxies`). Results are also cached in-process for 10 s. | `test_url` through the exit under test (default `https://www.baidu.com`); when the https attempt fails, one retry goes out over plaintext `http` through the same exit. | **Through the upstream proxy** | `test_url`, `check_proxies_on_startup`, `check_proxies_on_use`, `proxy_check_ttl` (30 s), `check_cooldown` (5 s). | Both switches off plus not clicking Check removes the startup and admission probes, but the confirmation probes still fire whenever an exit fails — there is no single master switch; point `test_url` at an address you control, or stop the proxy service. |
|
||||
| ① plugin scrapes and panel imports (full validation); ② the panel's batch validation — **Check liveness**, **Validate valid** / **Validate invalid**, and **Full check** (`POST /api/pool/update-geo/all`) at full intensity; ③ automatic re-validation, every 360 min by default and immediately on pool start with `run_on_start=true`, choosing full when `quality_assessed_at` is missing or older than `full_recheck_interval_minutes`, identity when the real IP or anonymity is missing, and liveness on other rounds. | `test_url` plus `validator.identity_echo_apis`, `validator.ip_check_apis`, `validator.plain_identity_echo_apis` and the three hardcoded plaintext probe addresses — all through the exit under test; a plugin can override the global `test_url` with its own test address. | **Through the upstream proxy** | `validator.target_url` (derived from `test_url`), `identity_echo_apis`, `ip_check_apis`, `plain_identity_echo_apis`, `check_anonymity`, `timeout_seconds`, `connect_timeout_seconds`; `auto_revalidation.enabled` / `interval_minutes` (360) / `run_on_start` / `full_recheck_interval_minutes` (1440). | `auto_revalidation.enabled=false` stops the scheduled task; a plugin's `skip_validation` skips ingest validation; do not click the batch buttons. Once the pool runs and any validation happens, though — planned or manual — this group cannot be fully switched off with one key; point the endpoint lists at addresses you own. |
|
||||
| `proxy_source_mode=api` and `access_records_real_ip_probe` true: the background top-up loop scans the roster every second and probes any exit that has no real-IP record, a record older than its roster entry, or a failure more than 600 s old; at most 3 new probes per tick, 5 s per request. (The inline comment in `config.ini` mentions gateway addresses only; in fact every api exit whose real IP is not yet known is probed.) | `validator.ip_check_apis` — its https entries — through the exit itself, trying them one after another until a 200 yields an IP. | **Through the upstream proxy** | `access_records_real_ip_probe` — code default `false`, shipped `config.ini` `true`. | Set it to `false`; or do not use the `api` source (`local` and `pool` never send it). |
|
||||
| Any client request to `127.0.0.1:1080` whose target is not on the bypass list; a failed exit is retried at the connection stage up to 2 times, and with an empty roster the request path fetches first. | The target host the client asked for; the hop is the exit currently selected (from the local file, the api source or the pool). | **Through the upstream proxy** | No switch — this is the core function; `proxy_source_mode` decides where exits come from, `bypass_whitelist_file` decides which targets skip this path (see the bypass row above). | Stop the proxy service (panel Stop, or exit the program); or do not configure clients to use this proxy. Forwarding through an upstream is the tool's job and cannot be turned off. |
|
||||
|
||||
A row marked **Direct** is sent directly from this host without a proxy, so the destination sees this
|
||||
machine's real public IP: the version check, both scraper plugins, the validation pre-flight, the host
|
||||
public-IP lookup, the SOCKS5 liveness probe, the ingest pre-screen, the api and remote-pool fetches, the
|
||||
geolocation-database download and the bypass-list branch (the panel ads load directly too, but from your
|
||||
browser rather than from ProxyCat). The remaining rows go **through the upstream proxy**, so the
|
||||
destination sees the exit's address instead — those are ProxyCat's own validation probes and the client
|
||||
traffic it forwards for you.
|
||||
|
||||
## Performance and Benchmarks
|
||||
|
||||
Throughput on the forwarding path:
|
||||
|
||||

|
||||
|
||||
The screenshot is a Yakit load test through `http://neko:[email protected]:1080`: **3000 requests at
|
||||
500 concurrency, 3000 succeeded / 0 failed, all HTTP 200, roughly 550-670 ms per request**. The
|
||||
`user:password@host:port` form in that URL also demonstrates outbound authentication (the accounts
|
||||
in the `[Users]` section).
|
||||
|
||||
> This measures **ProxyCat's own forwarding path**, not your upstream proxy's quality — a slow
|
||||
> upstream is slow through ProxyCat too. Check upstream quality in the panel under Logs → Access
|
||||
> Statistics, grouped by upstream.
|
||||
|
||||
**Loopback benchmark** (measured on the development machine):
|
||||
|
||||
| Scenario | Number |
|
||||
|---|---|
|
||||
| 2000 idle tunnels | ~148 KB RSS per connection; idle CPU < 0.5% |
|
||||
| Single-tunnel throughput | ~520 MB/s (loopback) |
|
||||
| Small-message round trip | p50 0.27 ms / p95 0.47 ms |
|
||||
| Full connect (CONNECT → upstream 200) | ~380 conn/s with a hostname exit whose first address is unreachable; ~1100 conn/s with an IP-literal exit |
|
||||
|
||||
Numbers vary by machine and are meant **for before/after comparison on the same machine only**;
|
||||
comparing these numbers across machines means nothing.
|
||||
|
||||
> One platform cost worth knowing: on Windows the Proactor event loop allocates a fixed 64 KiB read
|
||||
> buffer **per socket** (hardcoded in CPython, not settable by applications), so a tunnel with two
|
||||
> sockets carries ~128 KB of user-space buffering. At 20k tunnels that is ~2.5 GB — the dominant
|
||||
> memory term for massive concurrency on this platform.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
ProxyCat/
|
||||
├── ProxyCat.py CLI entry: starts the outbound service and the proxy pool
|
||||
├── app.py Web entry: panel + outbound service + proxy pool (the Docker CMD)
|
||||
├── README.md / README-EN.md Chinese and English READMEs
|
||||
├── API.md / API-EN.md Full API reference (panel /api/* and pool /api/pool/*)
|
||||
├── config/
|
||||
│ ├── config.ini All configuration (with Chinese inline comments)
|
||||
│ ├── getip.py Endpoint-fetching logic for the `api` source; adapt it to your API
|
||||
│ ├── ip.txt Proxy list for `local` mode
|
||||
│ ├── ads/ Panel ad slot data
|
||||
│ └── *.txt IP whitelist / blacklist and the bypass list
|
||||
├── modules/
|
||||
│ ├── proxyserver.py Outbound proxy service: inbound parsing, rotation, forwarding
|
||||
│ ├── proxy_workers.py Exit roster: picking, concurrency quotas, failure sliding window and the standby queue
|
||||
│ ├── proxypool_service.py Hosts the proxy pool inside this process (async core + sync facade)
|
||||
│ ├── proxypool_api.py Web adapter for the pool (a Flask blueprint)
|
||||
│ ├── pool_config_ini.py Mapping between the [Pool] section and the config object tree
|
||||
│ ├── modules.py Shared host base layer: config, i18n, startup banner, proxy checks
|
||||
│ ├── logging_setup.py Logging assembly and category routing
|
||||
│ ├── access_log.py Per-request recording and timing
|
||||
│ ├── access_records.py Access records (the per-request event stream)
|
||||
│ ├── domain_stats.py Domain statistics (aggregated counters)
|
||||
│ ├── spool_store.py Shared "in-memory buffer + background batch flush" skeleton
|
||||
│ ├── config_validation.py Validation and normalization for the [Server] section
|
||||
│ ├── loop_noise_filter.py Suppresses asyncio teardown noise on Windows
|
||||
│ ├── version_check.py Version check scheduling and its persisted state
|
||||
│ └── proxypool/ The proxy pool subsystem (see its README.md / README-EN.md)
|
||||
├── web/
|
||||
│ ├── templates/index.html Single-page panel
|
||||
│ └── static/ Front-end assets (bundled jQuery and Font Awesome)
|
||||
├── ProxyCat-Manual/ Detailed documentation
|
||||
│ ├── Features.md / Features-EN.md Features (CN / EN)
|
||||
│ ├── Configuration.md / Configuration-EN.md Configuration (CN / EN)
|
||||
│ ├── Operation Manual.md / Operation Manual-EN.md Operation Manual (CN / EN)
|
||||
│ ├── Investigation Manual.md / Investigation Manual-EN.md Troubleshooting (CN / EN)
|
||||
│ └── logs.md / logs-EN.md Changelog (CN / EN)
|
||||
├── assets/ Images used by the README and manuals (panel screenshots under assets/screenshots/)
|
||||
└── LICENSE GPL-2.0
|
||||
```
|
||||
@@ -0,0 +1,413 @@
|
||||
# ProxyCat 功能特性
|
||||
|
||||
> 文档导航:[返回 README](../README.md) · [详细配置](Configuration.md) · [使用手册](Operation%20Manual.md) · [报错排查手册](Investigation%20Manual.md) · [更新日志](logs.md)
|
||||
|
||||
## 出口代理服务
|
||||
|
||||
- **双协议监听**:同一个端口同时接受 HTTP 与 SOCKS5 入站请求。
|
||||
- **三类上游代理**:支持带认证的 HTTP / HTTPS / SOCKS5 代理服务器。
|
||||
- **两种轮换模式**:`cycle` 按清单顺序使用(忽略负载,前一条满了才用下一条),
|
||||
`loadbalance` 按实时负载挑压力最小的出口。
|
||||
- **三种代理来源**:`local` 读本地列表、`api` 实时取接口、`pool` 直接用内置代理池。
|
||||
- **多出口并发**:一次取回多个上游代理同时使用,请求默认按各出口的实时负载分摊
|
||||
(`local` 的 `cycle` 模式按清单顺序挑,不按负载),单个出口失败会被移出轮换并自动
|
||||
补货 —— 上游按出口 IP 限流时,并发随出口数成倍放大(见[上游出口与并发](#上游出口与并发))。
|
||||
- **访问控制**:客户端 IP 白名单 / 黑名单,同时命中时按 `ip_auth_priority` 决定放行还是拦截;
|
||||
另有绕过名单:命中的目标地址不经代理、直接连接。
|
||||
- **出口认证**:支持用户名 / 密码认证,账号表为空即关闭认证。
|
||||
|
||||
换来源用「代理配置」页顶部的来源切换按钮,改模式与寿命、调出口容量则在「出口轮换」组里完成,保存后即时生效:
|
||||
|
||||

|
||||
|
||||
上图是「维护池代理」来源下的「出口轮换」组:更换方式、出口寿命(按时间与按次数两条独立
|
||||
规则)、在用出口数与无可用出口时的排队时长。
|
||||
|
||||
## 内置代理池
|
||||
|
||||
- **插件化抓取**:抓取插件放进 `modules/proxypool/plugins/`,池服务启动时扫描加载;
|
||||
新增插件需重启池服务,已在列表中的插件改完可在面板里点「重载」热加载。
|
||||
- **先验证再入库**:端口初筛淘汰无人监听的地址,存活候选再做完整验证,确认可用的才写进库。
|
||||
- **三档验证强度**:`liveness` 只判通不通、`identity` 补身份信息、`full` 全量评估。
|
||||
自动重验证在这三档之间按策略自行选择;面板上的批量验证可手选 `liveness` / `full`,
|
||||
传 `auto` 表示仍按策略决定(`identity` 不是批量接口的可选值)。
|
||||
- **健康评分**:按延迟 25%、成功率 25%、稳定性 15%、可用时长 15%、匿名度 10%、存活时长 10%
|
||||
合成为 0-100 分,成功率维度取 Wilson 置信下界,样本少时自动向低收缩。
|
||||
- **离线归属地**:本地离线库给出中英两套地区名,不发外部查询请求。
|
||||
- **自动重验证与自动清理**:按周期补测,并按健康分、连续失败、失效保留期清理。
|
||||
- **使用反馈**:运行期真实请求的成败回流到池,连续失败达阈值即标记失效
|
||||
(见[使用反馈是怎么算的](#使用反馈是怎么算的))。
|
||||
|
||||
池的启动条件与运行机制见下文[代理池机制](#代理池机制);插件开发的完整契约见
|
||||
[modules/proxypool/README.md](../modules/proxypool/README.md)。
|
||||
|
||||
## 面板与运维
|
||||
|
||||
- **Web 管理面板**:状态总览、配置编辑、代理列表、名单管理、服务启停一屏完成;
|
||||
配置项按用途分组,调优参数收在单独的「高级选项」组,不把调优参数和常用开关混在一屏。
|
||||
- **日志中心**:按用途分为访问 / 代理 / 主程序 / 代理池四类,支持级别与关键词过滤、历史文件回看、导出、清理。
|
||||
注意日志级别与访问日志是联动的:成功访问行是 INFO,级别调到 WARNING 以上后访问日志里
|
||||
只会剩失败与错误,面板会就地给出提示。
|
||||
- **访问记录**:逐条记录每次代理请求,可按时间 / 上游 / 目标 / 结果过滤并导出 CSV / JSON。
|
||||
- **域名统计**:按「目标域名 × 上游代理」累计成败次数,可下钻查看。
|
||||
- **多语言**:面板支持中文 / 英文切换,语言是服务端设置,整站文案一起切换。
|
||||
- **凭据管理**:上游取代理接口的凭据可存多套并一键切换。
|
||||
- **容器化部署**:Docker 一键部署,内置健康检查会按实际面板端口探活。
|
||||
- **版本检查**:启动时比对一次最新版本,之后每 24 小时一次;结果落盘,24 小时内重启不重查,
|
||||
也不会因为打开面板而等待网络。
|
||||
|
||||
面板四个页签(代理配置 / 代理池 / 访问控制 / 运行日志)的逐页说明与截图见
|
||||
[使用手册](Operation%20Manual.md)。
|
||||
|
||||
### 日志与访问记录
|
||||
|
||||
日志按用途分流到 `logs/` 下的四个文件,互不混杂,各自按大小轮转:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `logs/main.log` | 主程序:启动、配置、面板地址、关闭 |
|
||||
| `logs/access.log` | 代理请求:时间、客户端、上游代理、目标域名、成败、耗时 |
|
||||
| `logs/proxy.log` | 代理生命周期:加载、切换、验证、重试 |
|
||||
| `logs/error.log` | 以上各类别中 ERROR 及以上的汇总,排障时先看这里 |
|
||||
|
||||
访问日志每行有三种结果,含义各不相同:
|
||||
|
||||
| 结果 | 含义 |
|
||||
|---|---|
|
||||
| `[成功]` | 请求确实走通了(HTTPS 隧道要等上游有数据回来才算,仅写回 200 不算) |
|
||||
| `[失败]` | 这次请求失败,原因写在行尾 |
|
||||
| `[中断]` | 请求在没有得出成败结论前就结束(如客户端未发数据就断开、隧道双方都没传过数据、请求被取消),不计入成功率与失败率 |
|
||||
|
||||
行尾的状态码是写回给客户端的 HTTP 状态码,它可能与结果不一致:`[成功]` 行记录的是上游
|
||||
原样返回的状态码,上游回 4xx/5xx 也会记为 `[成功]`;`[中断]` 行会保留已经写回的状态码
|
||||
(如 200),但结果既不算成功也不算失败;而隧道已建立但上游从不发送数据这类 `[失败]`,
|
||||
写日志时不再保留状态码,状态显示为 `-`。因此**看结果列,不要只看状态码**。
|
||||
上游代理地址中的密码一律以 `***` 替代后落盘,不会记录明文凭据。
|
||||
代理池自身的日志仍写在 `modules/proxypool/logs/proxy_pool.log`。
|
||||
|
||||
访问统计按**上游代理**汇总,展开某个代理可以看到该代理访问过的每个域名。聚合键是
|
||||
「域名 × 上游代理」而不是只看域名 —— 同一个域名经不同代理访问的结果可能完全不同,
|
||||
只看域名会把「这个代理访问不了这个站」这条最有用的信息抹平;统计结果落 SQLite 持久化,
|
||||
重启后累计值不丢。
|
||||
|
||||
## 上游出口与并发
|
||||
|
||||
ProxyCat 维护一个**有目标容量的出口池**:一直维持 `exit_count` 个上游代理同时在用,
|
||||
而不是只压一个 IP,也不是攒一批换一批。
|
||||
|
||||
**池子怎么维持**:
|
||||
|
||||
1. 调一次来源接口,取回的地址先放**候补队列**(带过期时间与长度上限)。
|
||||
2. 从候补里取候选进名册,**每轮最多取 4 条并发校验**(不是一条一条取),通过的按顺序
|
||||
进名册 —— 直到名册满 `exit_count`;校验不合格的丢弃,通过但这一轮没用上的送回候补。
|
||||
3. 某个出口被判失效时**只换掉它一个**,其余照常服务。取代理与换出口**由请求驱动**:
|
||||
有请求在过出口时,后台才去来源补货、才把到期的换新;一轮没进展不会当场空转重试,
|
||||
而是把重试间隔翻倍退避(最多 60 秒)后再试,且始终只在近期有流量时才发起。
|
||||
流量停下之后(约 10 秒宽限)就不再补货:到期的出口**被移除**而不是换新
|
||||
(`continuous` 不依赖流量,见下条),池子可能因此被抽空 —— 冷启动的第一个请求
|
||||
要等一次取货 + 校验。
|
||||
4. **每个出口各有各的寿命**(所有来源、四种运行模式都生效):入池满 `interval` 秒就到期,
|
||||
到期的**全部**被换掉(不是每隔 interval 只换一个 —— 那样最坏的出口能活
|
||||
`出口数 × interval`)。**一批同时入池的会同时到期**,所以到点是一次集中的替换;
|
||||
替换始终是「先加后减」,而且候补不够时宁可先把旧的那个留着,名册不会因此出现空窗。
|
||||
`request_interval` 是同一件事按请求数算(每个出口自己数被分配出去多少次),
|
||||
它与 `interval` 是**两条各自独立的规则,同时生效**:两个都填时谁先到就换谁,
|
||||
不是二选一。
|
||||
|
||||
**换的时机**由运行模式决定:`continuous`(持续更换)与是否空闲无关,后台一直按寿命
|
||||
换掉到期的并补满,池子恒定为 `exit_count`;`request`(触发更换)只在收到新任务时才
|
||||
取货、换出口,一个任务都没有时到期的出口**直接移除**、池子会空掉,下一个请求到达
|
||||
时才重新取货填满。本地来源的两项(`cycle` / `loadbalance`)管的是**挑谁**,换的时机
|
||||
与「触发更换」一样由请求驱动;本地换上来的是文件里的下一条,文件里没有备用条目时
|
||||
原地重开一次寿命(否则它会挂着「剩余 0」一直用下去)。
|
||||
|
||||
换的这一刻如果候补取不到货,池子会短暂缺人 —— 这时的请求**排队等**(最多
|
||||
`exit_wait_timeout` 秒,见下),而不是当场 503;等到了照常服务,超时了才回 503。
|
||||
|
||||
取几个由 `exit_count` 决定,与来源无关:每次取货按**名册缺额的 `min(缺额 × 8, 24)`** 超量
|
||||
取回、放进候补,再从候补取候选校验填充到 `exit_count`;`api` 与 `pool` 都按这个超量值取,
|
||||
`api` 返回的行中超出该上限的部分会被**截断丢弃**、不会进候补,`local` 直接用文件本身、
|
||||
以 `exit_count` 为上限。面板的「代理状态」区会逐一列出每个出口(地址、在用/上限、
|
||||
**剩余寿命**),顶部那根进度条看的是**最近到期的那个**;命令行打印「上游出口: N/目标」
|
||||
加每个出口一行,同样带各自的剩余寿命。到点而没流量时(`continuous` 除外)出口不再换新:
|
||||
到期的会被逐个移出名册,直到下一个请求到达才重新取货填满。
|
||||
|
||||
请求默认**按各出口的实时活跃数**分配(不是简单轮询)—— 隧道寿命长短不一,按请求数轮询
|
||||
会让负载倾斜;`local` 的 `cycle` 模式按清单顺序挑,不看负载。
|
||||
|
||||
**故障怎么算**:每个出口带一个**滑动窗口**(最近 10 次结果),判定看窗口里的失败占比,
|
||||
不是「连续失败几次」。窗口不记「干净」时期的成功 —— 出口在还没出现过失败之前,成功
|
||||
不进窗口,所以它的第一次失败就会把占比顶满、当场排到健康出口后面;有了失败记录之后,
|
||||
成功才进窗口参与稀释:
|
||||
|
||||
- **失败居多就先让别人上**(占比过半):分配请求时排在健康出口后面,但**仍然可用** ——
|
||||
全都可疑时照用不误。它没有冷却时间:每次成功都会记回窗口、把占比往下压,压回过半以下
|
||||
就自然回到竞争里(窗口里失败仍占多数时,一次成功还翻不过来)。**占比过半还会让它
|
||||
在一次真实失败后被独立探测一次**:探测失败就把它移出名册(此时占比可以远低于九成、
|
||||
窗口也可能不足 5 条),探测通过则继续留着。
|
||||
- **占比到九成、且窗口里至少有 5 次结果**,也会把它移出名册 —— 这是失败滑窗自己的
|
||||
淘汰阈值,不是移出名册的唯一路径。缺口由后台补货填上(取来源受 `switch_cooldown`
|
||||
限流,从候补填充不限流)。用占比而不是连续次数,是为了把「一次抖动同时掐断几条隧道」
|
||||
和「这个出口就是坏的」分开:前者留下的失败会被随后的成功稀释掉,后者一直涨。反过来,
|
||||
一次掐断的隧道足够多、把整个窗口填满失败时,它也会被当场停用 —— 那一刻它确实对几十个
|
||||
请求一条都没成功。
|
||||
- **名册里的最后一个出口永远不会被停用**。停用它等于全线 503,而本地来源还会被「刚停用」
|
||||
记忆挡住、几分钟内补不回来;让它继续挨着比没有出口强。
|
||||
- **记账不折叠,折叠只发生在日志层**:`proxy_failure_cooldown` 窗口内同一类告警只写一条,
|
||||
并记下被折叠了多少条。出口的每一次失败都如实进窗口 —— 在记账上折叠,会抹掉「这个出口
|
||||
一直在失败」这个事实,而那正是决定还要不要给它派活的依据。
|
||||
|
||||
**检测怎么做**:`check_proxies_on_use` 控制的是**填充槽位时的校验** —— 每条候补在进名册
|
||||
之前真的探一次(发一次请求),不是只看端口通不通;来源给的地址可能一分钟就失效,端口开着
|
||||
不代表还能用。它不是按请求校验:出口在服役期间失效,靠上面的失败淘汰兜底。
|
||||
|
||||
启动服务时(`ProxyCat.py` 入口或从面板启动服务)还会对当前名册做一次完整检测
|
||||
(`check_proxies_on_startup`),不通的**逐个移出**、缺的交给补货;**代理文件重载后**的
|
||||
检测只有命令行入口会做 —— `ProxyCat.py` 发现 `ip.txt` 被外部修改而重载时顺带检测,
|
||||
而在面板上保存本地代理列表只重载名册、不触发检测(`app.py` 也不监听 `ip.txt` 的变化)。
|
||||
面板上的「检测代理」按钮检测的是**源清单**(local 来源下即 `ip.txt` 里的全部条目),
|
||||
所以已经被停用的坏出口也查得到。
|
||||
|
||||
**并发怎么算**:
|
||||
|
||||
```text
|
||||
实际同时打到上游的请求数 ≈ min(max_concurrent_requests, 出口数 × max_concurrent_per_proxy)
|
||||
```
|
||||
|
||||
- `max_concurrent_per_proxy` 默认 `0`(不限制),此时并发只受 `max_concurrent_requests` 约束,
|
||||
与「只用一条上游」的时代行为一致。**上游按出口 IP 限流时**,把它调成
|
||||
单 IP 能承受的并发数(例如 20),超出部分会排队而不是把上游打挂。
|
||||
- 它数的是**活跃**连接:HTTP 请求在转发期间算,隧道建立之后只有真的在传数据才算。
|
||||
空闲隧道(浏览器保活、客户端僵死、上游半死)不占额度,所以它是一道**准入**控制
|
||||
而不是「同时在挂的上游连接数」—— 空闲隧道重新有数据时,那一刻的活跃数可以略高于
|
||||
这个值(如实反映「确实有这么多条在传」)。
|
||||
- `max_concurrent_requests` 是全局信号量,对三类请求一视同仁,按「隧道」而不是「请求」估:
|
||||
它才是「本机愿意同时挂多少条隧道」的硬上限,每出口额度之和远超它时,真正生效的是它。
|
||||
|
||||

|
||||
|
||||
上图是面板「代理配置 → 性能与并发」组:全局并发上限与单出口并发上限;
|
||||
「单出口并发上限」填 `0` 时既不限制、自动扩容也不会扩,但已经张开的扩容状态照常收回。
|
||||
|
||||
**「在用」怎么算**:面板与命令行的「在用」是上面那个活跃口径 —— 正在处理请求的连接,
|
||||
加上最近有中继数据的隧道。空闲但没断开的隧道不显示(它仍占着一条上游连接与一个名额,
|
||||
只是没有数据在流),所以请求停下之后数字会回落,不必等对端关连接。
|
||||
|
||||
**资源怎么还回来**:一次传输结束(隧道任一端关闭、响应发完)时,占用的出口名额立即归还,
|
||||
不等到连接对象被回收。**客户端失联**(断网、NAT 超时、进程被杀)的隧道则由分钟级的 TCP
|
||||
keepalive 兜底:系统默认是两小时,那意味着一条被遗弃的隧道要占着出口名额两小时,连续
|
||||
请求之下会把额度一点点占满 —— 现在 60 秒空闲开始探活、10 秒一次,失联的连接在一两分钟
|
||||
内被内核判死并释放。活着但空闲的连接(keep-alive 复用)探测会被正常应答,不受影响。
|
||||
|
||||
**连接复用**:普通 HTTP 转发走每个出口各自的 HTTP 连接池,连接的空闲回收时间由
|
||||
`client_keepalive_expiry` 控制;缓存这些连接的客户端对象另按 `client_idle_timeout`
|
||||
(面板不展示)回收。CONNECT 与 SOCKS5 隧道做不到复用 —— 一条上游连接建立隧道后
|
||||
就被该隧道独占,无法归还给别的请求。
|
||||
|
||||
> `client_max_connections`(每出口连接上限)与 `max_pool_size`(缓存多少个客户端)
|
||||
> 面板不再展示:前者按 `单出口并发上限 × 2` 推算(单出口不限并发时退回全局并发上限),
|
||||
> 后者按 `max(本值, 名册数 × 2, 出口数 × 4)` 取用。两者的值仍写在 config.ini 里,
|
||||
> 高于推算值时照常生效,只是不再需要用户理解它们。
|
||||
> `client_max_keepalive`(每出口保活连接数)也从面板撤下,默认 8 —— 空闲连接越多,
|
||||
> httpx 分配请求时的全池扫描越贵,实测调大会让吞吐掉到三分之一。
|
||||
|
||||
**高峰期自动扩容(`auto_expand_enabled`,默认开,可在面板「性能与并发」组里关掉)**:设了
|
||||
`max_concurrent_per_proxy` 时,有两条触发路径:①**立刻触发** —— 监听端口上此刻挂着的
|
||||
连接数超过「在用出口数 × 单出口额度」时,当场把出口数翻倍(上限 2 倍)并一次补齐,不用
|
||||
等水位;②**兜底** —— 八成的出口跑到这个额度并持续一秒,后台逐格加出口,一拍加一个、
|
||||
加上才作数。扩容**只加出口**(多几个上游 IP 一起分担)。名册最多到
|
||||
`max(exit_count, min(exit_count × 2, max_concurrent_requests))`:出口比并发请求还多没有
|
||||
意义,到这条线不再加出口,但**单出口额度的放宽不受这条线约束**,仍会单独推进(见下)。
|
||||
高峰过去后**一步收回**:整池利用率回落到一半以下并持续 30 秒,就先恢复单出口额度、再
|
||||
撤掉多出来的出口;还在传数据的出口会留着把请求跑完。
|
||||
|
||||
扩与收看的是**两个不同的数**:扩容问「够不够用」(多少出口顶在额度上),收回问「用掉了
|
||||
多少」(整池利用率)。拿前者判收回会出事 —— 负载摊开之后可能一个出口都不再顶额,而池子
|
||||
恰恰刚好够用,收回一格队列就会回来、下一拍又得加回去,出口数永远在浮动。
|
||||
|
||||
放宽单出口额度(最多到两倍)是**最后手段**:兜底路径下**要有证据** —— 只有这一拍真的向
|
||||
来源取过货、拿到了答复、却没能拿到一条能用的新候选时才放宽,冷却期内没去取货、取回的
|
||||
候选校验没过、取货本身失败(超时、接口报错),都只算「这一拍没结论」,下一拍再试。即时
|
||||
路径不受这条约束:监听侧连接已经挤爆、名册又已到顶时,它照样会把额度往上调(最高两
|
||||
倍)—— 加出口是把负载分给更多 IP,放宽额度是把压力压回原有 IP,后者正是
|
||||
`max_concurrent_per_proxy` 要防的。反过来,**把出口池容量调小**时,多出来的出口当场
|
||||
就会撤掉(只撤没有在飞请求的),与水位无关;自动扩容收回后多出来的出口则不会立刻丢弃 ——
|
||||
它们**用满自己的寿命**才逐个撤下。
|
||||
|
||||
它的水位是按**配置里的额度**算的,不是按放宽后的值 —— 否则「放宽」这个动作本身会把水位
|
||||
压下去,紧接着就被判成高峰已过,来回震荡。`max_concurrent_per_proxy` 为 `0`(不限)时
|
||||
这条功能不扩,但**照常收回**:不限并发不是留着膨胀状态的理由。
|
||||
|
||||
## 代理池机制
|
||||
|
||||
代理池不是独立项目 —— 它运行在 ProxyCat 进程内,与面板共用同一个 Web 端口,没有自己的
|
||||
独立界面,管理入口是面板的「代理池」页签。
|
||||
|
||||
**子模块文档**:[modules/proxypool/README.md](../modules/proxypool/README.md) —— 覆盖抓取插件开发、
|
||||
验证策略、匿名度判定、归属地解析、自动清理等实现细节,内容较深,按需查阅。
|
||||
|
||||
> `validator.max_concurrent`(池设置里的「同时验证多少个代理」)是这套子系统的资源旋钮:
|
||||
> 它同时决定本机 HTTP 连接池上限与可打开的 socket 数 —— 一次验证会并发发出十几个请求。
|
||||
> 机器或 `ulimit -n` 受限时把它调低;本机连接池排队造成的超时,**存活探测阶段**判为
|
||||
> 「本轮不下结论」、不会记到代理头上,但**存活通过之后**的身份与明文探测若再因排队超时,
|
||||
> 会记成一次不成功的探测样本,并直接拉低健康分的成功率维度。
|
||||
|
||||
### 什么时候启动
|
||||
|
||||
内置代理池**只在真的要用它当代理来源时才启动**,由 `[Server]` 的 `proxy_source_mode` 决定:
|
||||
|
||||
| `proxy_source_mode` | 池是否随服务启动 |
|
||||
|---|---|
|
||||
| `pool`(维护池) | 启动 |
|
||||
| `local`(本地文件) | 不启动 |
|
||||
| `api`(API 获取) | 不启动 |
|
||||
|
||||
配了 `pool_remote_url`(接入外部代理池)时也不启动 —— 那种情况根本不走进程内这一个。
|
||||
|
||||
用本地文件或 API 取代理时,池跑着只是白耗资源去联网抓取和验证代理,所以默认不起。
|
||||
如果在面板里把来源切换成「维护池代理」,而池当前没在跑,会**自动把它拉起来**;
|
||||
反过来切换走不会自动停,避免误停正在提供代理的池 —— 需要停的时候在「代理池」
|
||||
页签里手动停。
|
||||
|
||||
### 内置池与外部池
|
||||
|
||||
默认使用**进程内的池**,开箱即用。如果你已经有别的代理池服务,可以把 `pool_remote_url`
|
||||
指向它,让出口代理改用外部池:
|
||||
|
||||
- 它是**取用接口**,不是管理接口:ProxyCat 会对该地址发一次 GET,**响应体里的每一行都是
|
||||
一个代理地址**(例如 `http://1.2.3.4:8080`)。返回 200 且格式正确即采用,返回 404 表示暂时
|
||||
没有可用代理。填另一个 ProxyCat 实例的 `/api/pool/random?format=text&token=...` 得到单个
|
||||
出口;想要**一次取多个**,改用 `/api/pool/get?status=valid&limit=20&format=text&token=...`
|
||||
(也可以直接指向内置池的 `/api/pool/get`,此时走的是池的接口而不是进程内直调)。
|
||||
- **只在 `proxy_source_mode=pool` 时生效。**
|
||||
- 设置之后**进程内的池不会再自动启动**(它是被外部池替代的关系)。
|
||||
- **使用反馈在外部池模式下不生效** —— 池不在本进程里,反馈无从回报。
|
||||
|
||||
### 使用反馈是怎么算的
|
||||
|
||||
出口代理在**真实转发**中如果发现某个出口不通,会先把这次失败记在那个出口头上。失败占比
|
||||
过半的出口随后会被**独立探测一次**:探测失败就移出轮换(此时占比可以远低于九成、窗口也
|
||||
不足 5 条),探测通过则继续用;占比到九成、窗口里至少 5 次结果时,由失败滑窗直接淘汰
|
||||
(两条路径见[上游出口与并发](#上游出口与并发))。被探测出口的结果会回报给池;池那边连续
|
||||
失败达到 `use_feedback.invalid_threshold`(默认 3 次)就把该代理标记为失效,期间任何一次
|
||||
成功都会把计数清零。
|
||||
|
||||
> 出口的移出与池的标记失效是**两件事**:前者只影响本进程当前用哪几个出口(几秒内就会
|
||||
> 补货),后者改的是池里那条代理的可用状态(持久)。前者快而轻,后者慢而重。
|
||||
|
||||
它**刻意只动「是否可用」与「总检查次数」两个字段**,不碰失败次数、验证时间、探测计数与
|
||||
健康分 —— 那些是自动清理的判据,一旦被运行期的偶发失败污染,代理会被不可逆地删掉;
|
||||
总检查次数清零正是为了把这条记录重新排进重验证候选 —— 重验证器会把检查计数为 0 的记录
|
||||
收进候选,**不受 `auto_revalidation.only_valid_proxies` 影响**。检测分不清「代理坏了」和
|
||||
「我们自己网络抖动」,所以反馈只做温和的降级。
|
||||
|
||||
因此:**被使用反馈标记失效的代理会被列入下一轮自动重验证的候选**,重验证测通后可用状态
|
||||
自动翻回来;不想等的话,也可以在面板的「代理管理」里点「验证全部失效」立刻手动重验。
|
||||
|
||||
### 抓取插件
|
||||
|
||||
把插件文件放进 `modules/proxypool/plugins/` 后,重启池服务生效(目录只在池服务启动时扫描
|
||||
一次);已在列表中的插件改了代码,可在面板「代理池 → 代理管理」的插件列表里点「重载」热加载。
|
||||
两条最容易踩的约定:
|
||||
|
||||
- **抓取失败必须抛出异常**。返回空列表会被当作「抓取成功但这一轮没抓到」,于是错误被吞掉、
|
||||
调度周期照常推进,表现为永远抓不到东西。
|
||||
- **插件的文件名(不含扩展名)就是它的标识**,配置、调度与来源记录都用这个名字。
|
||||
|
||||
完整的插件编写契约见 [modules/proxypool/README.md](../modules/proxypool/README.md)。
|
||||
|
||||
## 对外网络请求
|
||||
|
||||
ProxyCat 会在下列场景主动向本机之外发起连接,供你核对「连去哪里、什么时候连、怎么关掉」—— 表中标**直连**的行为不经任何代理、以本机公网 IP 直接发出,标「经代理」的由出口池里的上游出口代为访问。
|
||||
|
||||
| 触发时机 | 目标地址 | 直连还是经代理 | 相关配置项 | 如何关闭 |
|
||||
|---|---|---|---|---|
|
||||
| 版本检查:后台线程抓作者站点的版本公告、解析版本号做更新提示,结果落盘 `logs/version_check.json`;启动时(`app.py` 与 `ProxyCat.py` 两个入口都会拉起该线程)距上次检查尝试满 24 小时、从未查过、或记录版本与当前版本不符即立即查一次,此后每 600 秒判断一次、至多每 24 小时发一次;24 小时按「上次检查尝试」记账,失败也算时间 | `y.shironekosan.cn`(硬编码) | **直连** | 无(地址硬编码在 `modules/version_check.py`) | 没有开关;只能在网络层屏蔽该域名(hosts / 防火墙 / DNS)。该请求走 httpx 默认客户端,系统设了 `HTTP(S)_PROXY` 环境变量时会改走环境代理 |
|
||||
| GeoNode 抓取插件分页抓公开代理列表(每页 500 条、最多 20 页):池启动后插件默认启用、尚无下次执行时间即抓一轮;此后每 60 秒扫描一次、按插件周期执行(新插件默认 60 分钟),失败按默认重试 2 次、基础等待 10 秒退避;面板插件列表里点「抓取」可立即触发 | `proxylist.geonode.com`(硬编码) | **直连** | 启停与周期存在池数据库(面板「代理池 → 代理管理」的插件列表);全局默认 `plugins.scan_interval_seconds=60`、`plugins.default_interval_minutes=60`、`plugins.execution_timeout_seconds=300`、`plugins.max_retries_on_failure=2`、`plugins.retry_base_delay_seconds=10.0`(`[Pool]`) | 面板插件列表里停用 `geonode_plugin`(写进池数据库、重启仍生效),或停止池服务;抓取地址硬编码,改地址需改代码 |
|
||||
| GitHub 抓取插件并发抓 12 个公开代理清单(6 个 http、5 个 socks5、1 个 https):与 GeoNode 插件共用同一套调度,池启动首次加载即抓一轮,之后默认每 60 分钟;面板插件列表里点「抓取」也可立即触发 | `raw.githubusercontent.com`(12 个硬编码清单地址) | **直连** | 同 `plugins.*`(`[Pool]`);启停与周期存在池数据库 | 面板插件列表里停用 `github_proxy_plugin`,或停止池服务;地址硬编码,改地址需改代码 |
|
||||
| 代理验证前的端点直连预筛:每次验证前不带任何代理直连探测各检测端点是否可达(本机 IP 直接对这些站点可见),结果缓存 300 秒;对不在缓存里的单个地址(如插件专属 `test_url`)随时补探一次 | `test_url`(默认 `https://www.baidu.com`)、`validator.identity_echo_apis`(默认 `httpbin.org`、`httpbingo.org`、`eu.httpbin.org`)及其明文派生地址、`validator.plain_identity_echo_apis`(默认 `postman-echo.com`)、3 个硬编码连通性地址(华为云 / Cloudflare / Firefox 的固定连通性探测地址);明文一组仅 `check_anonymity=true` 时探测 | **直连** | `test_url`(`[Server]`,派生 `validator.target_url`)、`validator.identity_echo_apis`、`validator.plain_identity_echo_apis`、`validator.check_anonymity`(`[Pool]`) | 无法整体关闭(是验证流程的一部分);可把 `test_url` 与两个端点列表换成自有地址;`check_anonymity=false` 可去掉明文与 3 个硬编码地址,但 `test_url` 与身份回显列表仍会被直连探测;清空 `identity_echo_apis` 会回落到内置默认(`httpbin.org`、`httpbingo.org`),不是关闭手段 |
|
||||
| 本机公网出口 IP 查询:`check_anonymity=true` 且 `validator.host_public_ip` 留空时,直连 IP 查询站点取本机公网 IP(IPv4 / IPv6 两族各自逐个试端点),作为「透明代理」匿名度判定的对比基准;1800 秒(半小时)内只查一次,成功与失败都重新计时 | `validator.ip_check_apis` 的地址(默认 9 个 IP 回显站点:`icanhazip.com`、`checkip.amazonaws.com`、`api.ip.sb`、`realip.cc`、`httpbin.org`、`api.ipify.org`、`ipinfo.io`、`ipwho.is`、`www.cloudflare.com`) | **直连** | `validator.host_public_ip`、`validator.check_anonymity`、`validator.ip_check_apis`(`[Pool]`) | 在池设置填上 `host_public_ip`(双栈逗号分隔;填了非法值会被当未配置、继续外查)→ 完全不发;或 `check_anonymity=false`;或清空 `ip_check_apis` |
|
||||
| SOCKS5 出口存活检测:本机直接 TCP 连候选出口的 host:port,做 SOCKS5 握手(带认证则先认证),再把 `test_url` 的域名交给它 CONNECT、不发送 HTTP 数据。触发分四路:`local` 来源且 `check_proxies_on_startup=true` 的启动预检;候补进名册前 `check_proxies_on_use=true` 的逐条校验(三种来源都生效);出口疑似失效、退休与静默隧道的确认探测(不受两个开关约束);面板「检测代理」。模块内另有 10 秒结果缓存 | SOCKS5 候选出口的 host:port(来自本地 `ip.txt` / api 取货 / 池 / 当前名册);CONNECT 目标是 `test_url` 的域名 | **直连** | `test_url`、`check_proxies_on_startup`、`check_proxies_on_use`(`[Server]`) | 两个开关置 false 且不点面板检测按钮,可去掉启动预检与入池校验;失效确认探测仍会发(只在出现失败出口时触发);要全停就停止代理服务 |
|
||||
| 入库前 TCP 连通性初筛:插件抓取入库、面板「导入」入库时,对每个候选代理的 ip:port 发一次 TCP 连接(只验端口是否可连),淘汰连不上的;插件开了 `skip_validation` 时这步与后续验证一起跳过、直接入库 | 候选代理的 host:port(来自抓取清单或导入内容,遍布公网) | **直连** | 无独立开关;插件级 `skip_validation` 同时跳过初筛与验证(插件设置存在池数据库、面板可改) | 禁用抓取插件、不导入即可不发生;或给插件开 `skip_validation`(代价是跳过验证直接入库) |
|
||||
| api 来源取货:`proxy_source_mode=api` 且出口池需要取货时(名册为空、有近期流量或 `continuous` 模式时的后台补货循环、面板「刷新出口」、高峰扩容),按 `api_proxy_url` 发一次 GET,响应按行解析成上游代理地址(超时 15 秒、单批上限 500 条);出厂配置为空,此时 api 模式直接报配置错、不发请求 | `api_proxy_url`(出厂为空;配置里连 `[Server]` 段都没有时回落到内置默认 `example.com/getip`) | **直连** | `api_proxy_url`(`[Server]`);`proxy_username` / `proxy_password` 只拼在取回的代理地址上,不用于这次请求 | 把 `api_proxy_url` 留空(api 模式报配置错、不联网),或把 `proxy_source_mode` 改成 `local` / `pool` |
|
||||
| 远程代理池取货:`proxy_source_mode=pool` 且 `pool_remote_url` 非空时,出口池需要补货 / 轮换就 GET 一次外部池的取用接口,响应按行当代理地址;留空则不发,改用进程内代理池(它自己会跑抓取插件与验证,见其它行) | `pool_remote_url`(出厂为空) | **直连**(httpx,10 秒超时,显式不走环境代理) | `pool_remote_url`(`[Server]`) | 清空 `pool_remote_url`(回落到进程内代理池) |
|
||||
| 离线归属地库下载:仅手动触发(面板「数据库维护」页的「更新归属地库」,或命令行运行 `geodb`;没有自动更新),从 CDN 下载 `GeoLite2-City.mmdb.gz` 与 `qqwry.ipdb` 并换入 | `cdn.jsdelivr.net`(硬编码) | **直连**(requests 默认客户端,会遵循 `HTTP(S)_PROXY` 环境变量) | 无(地址硬编码、无自动触发路径) | 不点「更新归属地库」就不会发;注意「全面检测」不是下载(它做的是全量代理验证),「重算归属地」纯离线不联网 |
|
||||
| 绕过名单目标直连:客户端经本机代理端口发请求、目标 host 命中绕过名单(支持通配符)时,不经任何代理、以本机 IP 直连目标;明文 HTTP、CONNECT 隧道、SOCKS5 三条路径都有这个分支 | 客户端指定的目标地址(本机出口 IP 对目标可见) | **直连** | `bypass_whitelist_file`(默认 `config/bypass_whitelist.txt`,当前为空文件) | 清空绕过名单里的条目(全部走上游出口);或不给客户端配置本代理 |
|
||||
| 面板广告位:浏览器打开面板时加载广告图、点击广告链接时跳转(由用户浏览器发起,ProxyCat 进程不发请求) | `config/ads/*.json` 的 `image_url` / `link_url`:当前启用的两份都指向 `github.com` 链接,其中之一另加载 `camo.githubusercontent.com`(GitHub 的图片代理) | **直连**(浏览器侧) | 无配置键;改 `config/ads/*.json` 的 `enabled` / `image_url` / `link_url` | 删除广告文件,或把对应 json 的 `enabled` 改为 `false`;`image_url` 置空则该条不加载图片 |
|
||||
| HTTP / HTTPS 出口存活检测:经被测出口访问 `test_url`(https 失败时自动降级为明文 http 再试一次,仍经同一出口)。触发与 SOCKS5 存活检测相同:启动预检;候补进名册前校验;失效、退休与静默隧道的确认探测(不受两个开关约束);面板「检测代理」逐条检测源清单。模块内另有 10 秒结果缓存 | `test_url`(默认 `https://www.baidu.com`) | 经代理 | `test_url`、`check_proxies_on_startup`、`check_proxies_on_use`、`proxy_check_ttl`、`check_cooldown`(`[Server]`) | 两个开关置 false 且不点面板检测按钮,可去掉启动预检与入池校验;失效确认探测仍会发;`test_url` 换成自有可控地址可缩小影响;停止代理服务则全停 |
|
||||
| 单代理质量验证的经代理探测组:穿候选 / 在用出口发存活(`test_url`)、身份回显、明文回显与明文连通性探测,产出出口 IP、匿名度与协议能力。触发:插件入库 / 面板导入(`full`);面板批量验证(「验证选中的」可指定档位、`auto` 按策略)与「全面检测」(`full`);自动重验证(默认每 360 分钟一轮、启动立即跑首轮;缺质量评估记录或距上次完整评估超过 1440 分钟的轮次按 `full`、缺真实 IP / 匿名度按 `identity`、其余只发存活) | 经被测出口访问 `test_url`、`validator.identity_echo_apis`、`validator.ip_check_apis`、`validator.plain_identity_echo_apis` 与 3 个固定明文探测地址(身份回显在 `full` / `identity` 档发,明文一组需开启匿名度检测,3 个固定地址仅在 `full` 且 `test_url` 非 http 时发;各默认值见 `[Pool]` 相关键) | 经代理 | `validator.target_url`(由 `test_url` 派生)、`validator.identity_echo_apis`、`validator.ip_check_apis`、`validator.plain_identity_echo_apis`、`validator.check_anonymity`、`validator.timeout_seconds`、`validator.connect_timeout_seconds`(`[Pool]`);`auto_revalidation.enabled` / `interval_minutes` / `run_on_start` / `full_recheck_interval_minutes` | `auto_revalidation.enabled=false` 停定时任务;插件 `skip_validation` 跳过入库验证;不点批量验证按钮;但池一旦运行、只要发生验证,这一组无法单键全关——可把端点列表换成自有地址 |
|
||||
| api 来源出口真实 IP 探测:`proxy_source_mode=api` 且 `access_records_real_ip_probe=true` 时,后台补货循环每 1 秒扫描名册,对没有真实 IP 记录(或记录早于入池时间、失败已满 600 秒)的出口经它自己补探(不限于 config.ini 注释所写的「出口地址是网关时」,所有未探明真实 IP 的 api 出口都会探;每次最多新开 3 个探测、单请求 5 秒超时) | 经该出口访问 `validator.ip_check_apis` 里的 https 地址(逐个试到拿到 200 并解析出 IP) | 经代理 | `access_records_real_ip_probe`(本仓库 `config.ini` 为 `true`,代码内置默认 `false`) | 置 `false`;或不用 `api` 来源(`local` / `pool` 不发) |
|
||||
| 客户端流量转发(本职功能):客户端经本机代理端口(默认 1080)发来的 HTTP / HTTPS(CONNECT)/ SOCKS5 请求转给当前选定的上游出口,由上游访问目标;失败换出口重试(连接阶段最多 2 次),名册为空时先在请求路径取货 | 客户端指定的目标地址;中转站是当前选定的上游出口(来自本地文件 / api 取货 / 池) | 经代理 | 无开关(核心功能);出口来源由 `proxy_source_mode` 决定;`bypass_whitelist_file` 决定哪些目标改走直连(见上一条) | 停止代理服务(面板停止或退出程序);不给客户端配置本代理;经上游的转发是本职功能,无法关闭 |
|
||||
|
||||
表中会暴露本机公网 IP 的是标**直连**的行:版本检查、两个抓取插件、验证端点预筛、本机公网 IP 查询、SOCKS5 存活探测、入库 TCP 初筛、api 取货、外部池取货、归属地库下载、绕过名单直连,以及由浏览器侧发出的面板广告位(对目标站点暴露浏览器所在网络的公网 IP)。标「经代理」的行由上游出口代为连接,目标站点看到的是出口 IP 而不是本机 IP —— 客户端流量转发本身就是让上游替你访问目标。想让某一条停下来的最小操作见「如何关闭」列。
|
||||
|
||||
## 性能与基准
|
||||
|
||||
转发通路的吞吐表现:
|
||||
|
||||

|
||||
|
||||
这张图是用 Yakit 经 `http://neko:[email protected]:1080` 压测的结果:**3000 次请求、
|
||||
500 并发,成功 3000 / 失败 0,全部 200,单次延迟约 550-670ms**。图中那个
|
||||
`用户名:密码@主机:端口` 的地址形式,也顺带演示了出口认证的用法(对应 `[Users]` 段的账号)。
|
||||
|
||||
> 这张图衡量的是 **ProxyCat 自身的转发链路**,不是你的上游代理质量 —— 上游慢,经 ProxyCat
|
||||
> 一样慢。上游质量请在面板的「运行日志 → 访问统计」里按上游看成功率。
|
||||
|
||||
**本机回环基准**(同一台机器上的实测值):
|
||||
|
||||
| 场景 | 数字 |
|
||||
|---|---|
|
||||
| 并发空隧道(2000 条) | 每连接 RSS 约 148 KB;空转 CPU < 0.5% |
|
||||
| 单隧道吞吐 | 约 520 MB/s(回环) |
|
||||
| 小报文往返 | p50 0.27 ms / p95 0.47 ms |
|
||||
| 完整建链(CONNECT → 上游 200) | 约 380 conn/s(出口写成域名、且域名第一个地址不通时的实测值;出口是 IP 字面量时约 1100 conn/s) |
|
||||
|
||||
数字随机器变化,**只用于同一台机器上的前后对比**;跨机器比这些数字没有意义。
|
||||
|
||||
> 一处已知的平台成本:Windows 的 Proactor 事件循环给**每个 socket** 固定分配 64 KiB
|
||||
> 读缓存(CPython 写死,应用层改不了),一条隧道两条 socket 就是约 128 KB 用户态缓冲。
|
||||
> 换算到 2 万条隧道约 2.5 GB —— 这是「海量并发」在本平台上的主要内存来源。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
ProxyCat/
|
||||
├── ProxyCat.py 命令行入口:拉起出口服务与代理池
|
||||
├── app.py Web 入口:面板 + 出口服务 + 代理池(Docker 的 CMD)
|
||||
├── README.md / README-EN.md 中英文 README
|
||||
├── API.md / API-EN.md 完整接口文档(面板 /api/* 与池 /api/pool/*)
|
||||
├── config/
|
||||
│ ├── config.ini 全部配置(含中文行内注释)
|
||||
│ ├── getip.py 代理来源为 api 时的取址逻辑,可按接口格式修改
|
||||
│ ├── ip.txt local 模式的代理列表
|
||||
│ ├── ads/ 面板广告位数据
|
||||
│ └── *.txt IP 黑白名单与绕过名单
|
||||
├── modules/
|
||||
│ ├── proxyserver.py 出口代理服务:入站解析、轮换、转发
|
||||
│ ├── proxy_workers.py 出口名册:挑选、并发额度、失败滑窗与候补队列
|
||||
│ ├── proxypool_service.py 代理池在本进程内的宿主(异步核心 + 同步门面)
|
||||
│ ├── proxypool_api.py 代理池的 Web 适配层(Flask 蓝图)
|
||||
│ ├── pool_config_ini.py [Pool] 段与配置对象树之间的映射
|
||||
│ ├── modules.py 宿主公共基础层:配置、国际化、启动横幅、代理检测
|
||||
│ ├── logging_setup.py 日志装配与分类路由
|
||||
│ ├── access_log.py 单次请求的记录与计时
|
||||
│ ├── access_records.py 访问记录(逐条流水)
|
||||
│ ├── domain_stats.py 域名统计(聚合计数)
|
||||
│ ├── spool_store.py 「内存缓冲 + 后台批量落库」的共用骨架
|
||||
│ ├── config_validation.py [Server] 段的校验与规范化
|
||||
│ ├── loop_noise_filter.py Windows 上 asyncio 收尾噪声的抑制
|
||||
│ ├── version_check.py 版本检查的调度与状态持久化
|
||||
│ └── proxypool/ 代理池子系统(见其 README.md / README-EN.md)
|
||||
├── web/
|
||||
│ ├── templates/index.html 面板单页
|
||||
│ └── static/ 前端资源(含本地化的 jQuery 与 Font Awesome)
|
||||
├── ProxyCat-Manual/ 详细文档
|
||||
│ ├── Features.md / Features-EN.md 功能特性(中 / 英)
|
||||
│ ├── Configuration.md / Configuration-EN.md 详细配置(中 / 英)
|
||||
│ ├── Operation Manual.md / Operation Manual-EN.md 使用手册(中 / 英)
|
||||
│ ├── Investigation Manual.md / Investigation Manual-EN.md 报错排查(中 / 英)
|
||||
│ └── logs.md / logs-EN.md 更新日志(中 / 英)
|
||||
├── assets/ README 与手册用图(面板截图在 assets/screenshots/)
|
||||
└── LICENSE GPL-2.0
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
[Back to README](../README-EN.md)
|
||||
|
||||
Q: Abnormal proxy addresses appear and the proxy cannot be used
|
||||
|
||||

|
||||
|
||||
A: Check whether the API endpoint returns proxies in a correct format: it must return one proxy address per line (the supported form is `protocol://user:password@host:port`; with the protocol omitted, `http` is assumed, and only http / https / socks5 are supported; lines with an invalid format are skipped). If the endpoint returns an error message instead (for example, `error000x-13` as the first line), it is treated as a configuration error and never assembled into a proxy address for use — troubleshoot on the endpoint side (whether the local machine's IP needs to be whitelisted there, for example).
|
||||
|
||||
Q: Why is my IP still unchanged after running ProxyCat?
|
||||
|
||||
A: ProxyCat is not a system-wide proxy: you need to point your proxy settings at the local listening port (1080 by default, shared by HTTP and SOCKS5) and make sure the proxy server is usable — then troubleshoot your own network step by step.
|
||||
|
||||
Q: Why do port scans and similar tasks fail to go through the proxy?
|
||||
|
||||
A: The listening port accepts both HTTP and SOCKS5 and forwards only connections in which the client speaks one of those two proxy protocols; tasks such as port scanning send raw packets directly and never perform a proxy handshake, so they cannot be forwarded by this tool.
|
||||
|
||||
Q: Why can't I find the problem I hit in this troubleshooting manual?
|
||||
|
||||
A: This manual grows as problems come up — entries are added when they are encountered. You can contact the author (send ¥50 with your question; if no solution to the problem can be found via Baidu or GPT, it is refunded in full).
|
||||
|
||||
Q: An error message such as `- ERROR - XXXXXXXXXXX` appears
|
||||
|
||||
A: Copy the error text and search it on Baidu.
|
||||
@@ -1,19 +1,21 @@
|
||||
[返回 README](../README.md)
|
||||
|
||||
Q:出现不正常代理地址,无法正常使用代理
|
||||
|
||||

|
||||
|
||||
A:检查 API 地址所提供的代理格式是否正确,是否给自己出口IP加白了。
|
||||
A:检查 API 地址所提供的代理格式是否正确:接口需逐行返回代理地址(支持 `协议://账号:密码@主机:端口`,不写协议按 `http` 处理,协议只支持 http / https / socks5;格式非法的行会被跳过);接口返回错误信息(如首行为 `error000x-13`)时会按配置错误处理,不会拼成代理地址使用,需要到接口一侧排查(例如本机 IP 是否需要加白)。
|
||||
|
||||
|
||||
|
||||
Q:为什么运行 ProxyCat 之后我的IP仍然没有改变?
|
||||
|
||||
A:本项目并非全局代理,需要指向本地监听端口,并且保证代理服务器可用,请逐步排查自己的网络。
|
||||
A:本项目并非全局代理,需要将代理设置指向本地监听端口(默认 1080,HTTP 与 SOCKS5 共用),并且保证代理服务器可用,请逐步排查自己的网络。
|
||||
|
||||
|
||||
|
||||
Q:为什么端口扫描等情况下无法正常代理?
|
||||
A:端口扫描等任务并非发起HTTP类型等数据包,本工具旨在解决 Web 方面,并不支持其他协议。
|
||||
A:本工具的监听端口同时接受 HTTP 与 SOCKS5 两种代理协议,只转发客户端按这两种协议接入的连接;端口扫描等直接发送原始数据包的任务不经过代理协议握手,无法经本工具转发。
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
# ProxyCat Operation Manual
|
||||
|
||||
> Documentation: [Back to README](../README-EN.md) · [Features](Features-EN.md) · [Configuration](Configuration-EN.md) · [Investigation Manual](Investigation%20Manual-EN.md) · [Changelog](logs-EN.md) · [API Documentation](../API-EN.md)
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Python **3.10 or later** is required (3.8 / 3.9 fail with an error at import time); the Docker image is based on Python 3.11.
|
||||
- The packaged builds under Releases are the more stable ones, though not necessarily the newest.
|
||||
- The API endpoint's response is parsed line by line: you may return several proxy addresses at once, a line without a protocol is completed as `http`, and lines with an invalid format are skipped. To attach one username / password to every fetched address, set `proxy_username` / `proxy_password` (both must be non-empty to take effect).
|
||||
- The `config/config.ini` shipped with the repository has been tuned by the author, so a few values differ from the program's built-in defaults. This manual does not repeat per-key documentation: what each key means, its default, how it takes effect and the authoritative reference are all in [Configuration](Configuration-EN.md).
|
||||
|
||||
## Installation and Deployment
|
||||
|
||||
### From Source
|
||||
|
||||
**Windows and Mac**: visit the source repository on GitHub in your browser and download it: [ProxyCat](https://github.com/honmashironeko/ProxyCat)
|
||||
|
||||
**Linux**: clone the project locally with Git:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/honmashironeko/ProxyCat.git
|
||||
```
|
||||
|
||||

|
||||
|
||||
Install the Python dependencies (**Python 3.10 or later is required**: the source leans heavily on runtime-evaluated `X | Y` union type annotations, so 3.9 and earlier raise a `TypeError` at import time):
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# Or use a mirror:
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/
|
||||
```
|
||||
|
||||
Open the `config` folder and find `config.ini`, then choose the proxy-serving method that matches the resources you have:
|
||||
|
||||
(1) If your proxy server addresses are fixed connections that never need rotating, serve them from the local `ip.txt`, one `protocol://[user:password@]host:port` per line. The http / https / socks5 protocols and username / password authentication are all supported. The format looks like this:
|
||||
|
||||
```text
|
||||
socks5://neko:[email protected]:7890
|
||||
https://neko:[email protected]:7890
|
||||
http://neko:[email protected]:7890
|
||||
socks5://127.0.0.1:7890
|
||||
https://127.0.0.1:7890
|
||||
http://127.0.0.1:7890
|
||||
...
|
||||
```
|
||||
|
||||
(2) If you fetch proxy addresses through an API, set `proxy_source_mode` to `api` in `config.ini` and fill in `api_proxy_url` (once set, `ip.txt` is no longer read). The response is parsed line by line; to attach one username / password to every fetched address, fill in `proxy_username` and `proxy_password` (both must be non-empty to take effect).
|
||||
|
||||
Once that is configured you can run the tool; for the difference between the two entry points and how to choose, see [Entry Points](#entry-points).
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
On Windows you can download Docker's official tool: [Docker Desktop](https://docs.dockerd.com.cn)
|
||||
|
||||

|
||||
|
||||
On Linux you can install Docker with one command from the script provided by the Tsinghua mirror: [Tsinghua install script](https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/)
|
||||
|
||||

|
||||
|
||||
Once installed, first check that `docker` and `docker compose` work.
|
||||
|
||||
On Windows and Linux, go into the ProxyCat folder (**first complete the basic `config.ini` changes described under From Source**) and run:
|
||||
|
||||
```bash
|
||||
# From inside the ProxyCat folder, build the image and start the container
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Compose builds the image and starts the container. The two ports are:
|
||||
|
||||
| Port | Purpose |
|
||||
|---|---|
|
||||
| `1080` | Proxy listening port (shared by HTTP and SOCKS5) |
|
||||
| `5001` | Web panel port |
|
||||
|
||||
Three directories are bind-mounted to the host so that data and configuration survive container recreation: `config/`, `logs/` and `modules/proxypool/data/`. The image itself ships no `config.ini` (it is deleted at build time), so the first start creates it inside the mounted directory.
|
||||
|
||||
### Four Things To Know Before Deploying
|
||||
|
||||
These four all show up as a symptom first and a cause second, so read them upfront:
|
||||
|
||||
**1. The panel won't open — it returns 401**
|
||||
|
||||
The panel page itself is token-guarded, so with a token set you **must pass `?token=` to open it**. The `config/config.ini` shipped in this repo has a non-empty token, so visiting `http://127.0.0.1:5001/` directly ends in a 401 after the redirect. The correct URL is:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5001/?token=honmashironeko
|
||||
```
|
||||
|
||||
**That token is public in this repository — change it before exposing the panel**: the panel offers no field for it, so edit `[Server] token` in `config.ini` directly; the change hot-reloads within about a second and **the old token stops working right away**, so remember to reopen the panel with the new one.
|
||||
|
||||
**2. The container is up, but it can't write configuration or logs**
|
||||
|
||||
The container runs as a non-root user whose UID/GID must match the owner of `config/`, `logs/` and `modules/proxypool/data/` on the host, otherwise it cannot write to them. The reason is that the `chown` inside the image does not apply to a host bind mount, and the image itself ships no `config.ini` (it is deleted at build time), so the first start has to create it inside the mounted directory. If your host UID/GID is not 1000, set them in a `.env` file next to the compose file:
|
||||
|
||||
```text
|
||||
PUID=your_uid
|
||||
PGID=your_gid
|
||||
```
|
||||
|
||||
**3. A restart loses the last batch of data**
|
||||
|
||||
On shutdown the application drains its write queue, so `stop_grace_period` has been widened to 60 seconds. Docker's 10-second default results in a `SIGKILL`, losing the last batch of validation results and access records. **Please do not lower it.**
|
||||
|
||||
**4. The container turns unhealthy after changing `web_port`**
|
||||
|
||||
The health check reads the **actual** `web_port` from `config.ini` at probe time rather than assuming 5001. So after changing the port you must restart the process and update the port mapping in `docker-compose.yml` to match.
|
||||
|
||||
### Common Docker Commands
|
||||
|
||||
```bash
|
||||
# Stop and start the service
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
|
||||
# View the logs
|
||||
docker compose logs -f proxycat
|
||||
|
||||
# Rebuild and start after changing the Dockerfile or dependencies
|
||||
docker compose up -d --build
|
||||
|
||||
# Docker ports default to 1080 and 5001: 1080 is the listening port, 5001 the web panel.
|
||||
# For other ports, change the port mapping accordingly and allow them through.
|
||||
```
|
||||
|
||||
**Which changes need a container restart**: only entries bound when the service starts, such as `port` / `web_port` — changing `web_port` requires restarting the whole application and updating the port mapping in `docker-compose.yml` to match, while `port` leaves the process alone: restarting the proxy service once from the panel binds the new port. Every other setting, whether saved from the panel or hand-edited in `config.ini`, hot-reloads and needs **no** container restart (for the full hot-reload boundaries see [Entry Points](#entry-points)).
|
||||
|
||||
## Entry Points
|
||||
|
||||
There are two entry points; pick whichever fits:
|
||||
|
||||
| Entry Point | What it starts | When to use |
|
||||
|---|---|---|
|
||||
| `python app.py` | Web panel + outbound proxy service (+ proxy pool, auto-started depending on the source) | Daily use; recommended |
|
||||
| `python ProxyCat.py` | Outbound proxy service (+ proxy pool, auto-started depending on the source), CLI only, no panel | Servers that don't need the panel |
|
||||
|
||||
The CLI entry point accepts `-c` to point at a configuration file, defaulting to `config/config.ini`:
|
||||
|
||||
```bash
|
||||
python ProxyCat.py -c config/config.ini
|
||||
# On Windows the command is python, so write it as python ProxyCat.py
|
||||
```
|
||||
|
||||
Both entry points read the same `config.ini`. On the CLI entry, `display_level` (**0-2**) controls console verbosity: `1` is a countdown progress bar, and `2` adds elapsed time and error details. **Inside a container the progress bar is replaced by periodic log lines** — a missing progress bar is not a fault.
|
||||
|
||||
> **Hot-reload boundaries** (easy to get wrong):
|
||||
>
|
||||
> - **Configuration saved from the panel applies immediately**, on both entry points.
|
||||
> - **When you hand-edit `config.ini`**, both entry points reload the `[Server]` section: `app.py` runs a background thread that polls the file every second, and `ProxyCat.py` likewise picks the change up in its status polling and reloads it — about a second in countdown mode, and up to about five seconds under `loadbalance` or when rotation is not time-based (`interval` is `0`).
|
||||
> - The `[Pool]` section is hot-reloaded as well, taking effect within about 3 seconds on both entry points.
|
||||
> - `port` and `web_port` are bound only when the service starts: changing `web_port` requires restarting the whole application (the panel only warns you when you save it); `port` leaves the process alone — restarting the proxy service once from the panel binds the new port.
|
||||
|
||||
## The Panel
|
||||
|
||||
The panel is **a single page** at `http://127.0.0.1:5001/web` (remember the `?token=`), with four tabs down the left:
|
||||
|
||||
| Tab | What you do here | What backs it |
|
||||
|---|---|---|
|
||||
| **Proxy Config** | Switch the proxy source (Local / API / Pool), change the rotation mode and interval, edit the local proxy list, save API credentials, tune the logging and statistics switches; tuning items such as concurrency sit in the **Performance** and **Advanced** groups | The `[Server]` section |
|
||||
| **Proxy Pool** | See the three sub-views below | The `[Pool]` section + `/api/pool/*` |
|
||||
| **Access Control** | User management (outbound auth accounts), IP whitelist / blacklist and their priority, the proxy bypass list | The `[Users]` section and the list files under `config/` |
|
||||
| **Logs** | Live logs by category, historical log files, access statistics and per-request detail | The logs and the two statistics databases under `logs/` |
|
||||
|
||||
The Proxy Pool tab has three sub-views:
|
||||
|
||||
| Sub-view | What you do here |
|
||||
|---|---|
|
||||
| **Proxy Management** | Inspect collected proxies (protocol / real IP / region / delay / health score / anonymity), filter, batch validate and delete, import/export, trigger a plugin run manually |
|
||||
| **Pool Settings** | Tune the pool's own runtime parameters (validation concurrency and timeouts, fetch intervals, auto re-validation, auto cleanup, usage feedback, write queue) |
|
||||
| **Database Maintenance** | Database stats and optimization, backup list and restore, offline geolocation database update and recompute |
|
||||
|
||||
Each tab is covered in turn below.
|
||||
|
||||
### Proxy Config
|
||||
|
||||

|
||||
|
||||
The screenshot shows the Proxy Config tab: the header carries the service controls (Start / Stop / Restart), the two listening addresses and the proxy status; below it you can switch between the three sources — Local / API / Pool — with the configuration groups as navigation on the left and the current group's form on the right. The screenshot has the **Pool** source selected and the **Source** group open — that group shows only the fields the current source actually uses.
|
||||
|
||||
The settings are divided into seven groups by purpose:
|
||||
|
||||
- **Source**: shows only the fields the current source uses;
|
||||
- **Listening ports**: only worth changing when a port is taken;
|
||||
- **Exit rotation**: how long an exit serves, and how many serve at once;
|
||||
- **Availability checks**: what may enter the pool;
|
||||
- **Performance**: how many requests this host can carry at once;
|
||||
- **Logging and records**: what is recorded, and for how long;
|
||||
- **Advanced**: tuning items that require understanding the internals are collected here.
|
||||
|
||||
The **Remote pool URL** (`pool_remote_url`) under Source is a **fetch endpoint, not a management API**: it sends a single GET to the address you provide and parses every line of the response body as a proxy address (say `http://1.2.3.4:8080`), performing no management operations; a 404 means no proxy is available right now. Left empty, the in-process built-in pool is used; if you switch the source to Pool while the pool is not running, it is brought up automatically (see [Using the Proxy Pool](#using-the-proxy-pool)).
|
||||
|
||||
The `mode` field in the Exit rotation group **asks two different questions depending on the source**. A local source asks *which exit to pick*: `cycle` works down the list in order (ignoring load — the next entry is used only once the previous one is full), while `loadbalance` picks whichever exit is under the least pressure. API and pool sources ask *when to rotate*: `request` (On request) is listed first and only fetches and rotates exits when a new task arrives — with no tasks at all, expired exits are removed outright and the pool empties, then the next request fetches a fresh set to fill it; `continuous` (Continuous) is decoupled from traffic, replacing expired exits in the background on lifetime and topping the pool up. When you switch sources, a value that does not belong to the new source's group is normalised to that group's first entry — **the api / pool group normalises to `request`**. For the trade-off between the two rotation rhythms, see the [FAQ](#faq).
|
||||
|
||||
The local proxy list is edited right here in this tab; saving it reloads the roster. The **Check** button below the list tests the **source list** (for the `local` source, every entry in `ip.txt`), so broken exits that have already been retired can still be re-checked.
|
||||
|
||||

|
||||
|
||||
The **Performance** group governs how many requests this host can carry at once: the global concurrency cap is the limit across all exits combined, and anything above it queues on a semaphore; a per-exit concurrency cap of `0` means unlimited, leaving concurrency bounded only by the global cap; set it to a non-zero value and total upstream concurrency becomes roughly exits × this value (still capped by the global limit). The note shown in the screenshot — "Expansion only matters when 'Concurrency per exit' is not 0: with 0 nothing expands, but an existing expansion is still released" — is exactly the switch condition for auto-expansion: with `0` nothing expands, though an existing expansion is released as usual. For why growth and release look at two different numbers, and why relaxing the per-exit limit is a last resort, see [Upstream Exits and Concurrency](Features-EN.md#upstream-exits-and-concurrency).
|
||||
|
||||
For what each option means, its default and how it takes effect (immediately / needs a restart), [Configuration](Configuration-EN.md) is the authority; the panel forms validate input and apply on save, so they are the recommended place for day-to-day changes.
|
||||
|
||||
### Proxy Pool
|
||||
|
||||
#### Proxy Management
|
||||
|
||||

|
||||
|
||||
Top to bottom, the screenshot shows: the stat cards (Total / Valid / Invalid / Availability), the plugin table (each plugin's status, interval, last and next run, re-validation policy, plus the Fetch, Test URL and Reload buttons), and the filterable proxy list (Protocol / Address / Real IP / Region / Delay / Health / Anonymity, with Import / Export / Copy API link in the top-right corner).
|
||||
|
||||
Here you can: filter by protocol / validity / anonymity / source (plus advanced filters), batch-validate and delete selected proxies, import and export proxies, and trigger a plugin run manually. The list also flags whether an anonymity level was measured or inferred — proxies with no echo evidence can only be given an inferred value.
|
||||
|
||||
#### Pool Settings
|
||||
|
||||

|
||||
|
||||
Pool Settings is divided into eight groups by purpose: Database / Proxy validation / Fetch plugins / Automatic re-validation / Automatic cleanup / Use-time feedback / Write queue / Logging, with each group's tuning items folded under Advanced. The screenshot has the **Database** group selected, which controls the automatic-backup switch, interval (hours) and retention (days) — with automatic backup off no new backups are made, and the retention period decides how far back you can restore. For each field's description, the `[Pool]` section of [Configuration](Configuration-EN.md) is the authority.
|
||||
|
||||
#### Database Maintenance
|
||||
|
||||

|
||||
|
||||
Top to bottom, the screenshot shows: database statistics (Proxies / Valid / Invalid / DB size) with the **Optimize Database** button; the backup history (time, size, the newest tagged Latest), each restorable in one click; and the geo databases at the bottom — the offline databases' availability, data files and modification time, plus the **Update Geo Databases** and **Recompute Geo** actions.
|
||||
|
||||
- **Optimize Database** rebuilds the database indexes and compacts the file; writes briefly wait while it runs.
|
||||
- **Restore** **overwrites the current database with that backup**: the pool stops first and restarts automatically once the restore finishes.
|
||||
- When the offline geo databases are available, city-level lookups in China use the Chunzhen library (`qqwry.ipdb`) and everything else uses GeoLite2 (`GeoLite2-City.mmdb`); **Update Geo Databases** needs a network download (about 104 MB, taking several minutes), while **Recompute Geo** re-resolves existing records and is enabled only when there are records pending recompute.
|
||||
|
||||
### Access Control
|
||||
|
||||

|
||||
|
||||
The screenshot has three parts: **Users** at the top left lists the outbound authentication accounts (the two shipped in the repo, `k` and `neko`; passwords can be changed and accounts added or removed); to the right is **IP Access** with the **Whitelist** and **Blacklist**, the dropdown below choosing which one wins when a client matches both, and **Save** writing the lists to disk; below is the **Proxy Bypass Whitelist** — matching target addresses connect directly instead of going through an upstream proxy, and wildcards are supported.
|
||||
|
||||
- User management edits the `[Users]` section (one `account = password` per line): **when the account table is empty the proxy requires no authentication**. The program's built-in default has no `[Users]` section and requires no authentication; the `config.ini` shipped in the repo comes with factory accounts `k = 123` and `neko = 123456`, so requests without credentials get a 407. Change accounts through the panel's user management; hand-editing the `[Users]` section of `config.ini` is also read in through the configuration hot reload (effective within about a second).
|
||||
- The whitelist / blacklist files are always read from the `config/` directory; when a client matches both lists, `ip_auth_priority` (`whitelist` allows / `blacklist` blocks) decides.
|
||||
- For the three lists' file names and matching rules, see [Configuration](Configuration-EN.md).
|
||||
|
||||
### Logs
|
||||
|
||||

|
||||
|
||||
Along the top of the screenshot are two rows of filters: category (All / Main / Proxy / Access / Pool) and level (All / Important / Info / Warning / Error / Critical); on the right you can switch between the live log and browsable historical log files, and use **Auto refresh**, **Follow**, **Export** and **Clear**. Every line in the log list carries a time, level and category.
|
||||
|
||||
- The **Access** category holds access statistics grouped by proxy and the per-request detail; see [Logs and Access Records](#logs-and-access-records).
|
||||
- **The log level and the access log are linked**: successful access lines are INFO, so at WARNING and above the access log keeps only failures and errors; the panel warns you in place when that happens.
|
||||
- Reviewing historical files, exporting and clearing all act on the log files under `logs/`.
|
||||
|
||||
The language toggle sits at the bottom of the sidebar and **changes a server-side setting**: the panel text, API error text and exported file headers all switch with it (the console banner printed by the command-line entry only appears once at startup and is not reprinted).
|
||||
|
||||
## Using the Proxy Pool
|
||||
|
||||
### When It Starts
|
||||
|
||||
The built-in proxy pool **only starts when it is actually going to be used as the proxy source**, determined by `proxy_source_mode` in `[Server]`:
|
||||
|
||||
| `proxy_source_mode` | Does the pool start with the service? |
|
||||
|---|---|
|
||||
| `pool` (the built-in pool) | Yes |
|
||||
| `local` (local file) | No |
|
||||
| `api` (API fetch) | No |
|
||||
|
||||
Setting `pool_remote_url` (using an external proxy pool) keeps it off too — that case never touches the in-process pool. Note that the URL is the external pool's **fetch endpoint**, not a management API; details are under [Proxy Config](#proxy-config).
|
||||
|
||||
With a local file or an API source, a running pool would only waste resources fetching and validating proxies from the network, so it stays off by default. If you switch the source to Pool in the panel while the pool is not running, it is **brought up automatically**; switching away does not stop it, so a pool that is currently serving proxies is not shut down by mistake — stop it manually from the Proxy Pool tab when you want it stopped.
|
||||
|
||||
### Collection Plugins
|
||||
|
||||
Drop a fetch plugin into `modules/proxypool/plugins/` and it takes effect after the pool service restarts (the directory is scanned once, at pool service startup); for a plugin already in the list, changed code can be hot-reloaded with **Reload** in the panel under Proxy Pool → Proxy Management, where you can also set a plugin's fetch interval and test address individually. Two conventions bite people most often:
|
||||
|
||||
- **A failed fetch must raise an exception.** Returning an empty list counts as "the fetch succeeded, it just found nothing this round" — the error is swallowed, the schedule advances normally, and the symptom is simply that you never collect anything.
|
||||
- **A plugin's file name (without the extension) is its identity** — configuration, scheduling and provenance records all use that name.
|
||||
|
||||
The plugin list also lets you configure a re-validation policy per plugin, including "skip ingest validation and store fetched proxies directly" — suited to proxy sources billed per request whose validity the provider guarantees; such records have no exit IP, anonymity or health score, so it is best to turn re-validation off for them at the same time, otherwise they will still be checked periodically and incur charges. For the full plugin authoring contract see the [proxy pool module documentation](../modules/proxypool/README.md).
|
||||
|
||||
### Batch Validation
|
||||
|
||||
- On the Proxy Management page you can check the selected proxies for liveness (**Check liveness**); the toolbar also carries three batch actions: **Validate valid**, **Validate invalid** and **Full check**.
|
||||
- Validation intensity comes in three levels: `liveness` only tests reachability, `identity` additionally fills in identity information, and `full` runs the complete evaluation. Batch validation on the panel can be run as `liveness` / `full` by hand; passing `auto` still follows the strategy (`identity` is not a valid choice for the batch API).
|
||||
- **Proxies marked invalid by usage feedback are queued as candidates for the next automatic re-validation round**, and a successful re-validation flips them back to valid automatically; if you don't want to wait, **Validate invalid** in Proxy Management re-tests them right away.
|
||||
- The **Check** button tests the **source list** (for the `local` source, every entry in `ip.txt`); that is a different matter from batch validation inside the pool.
|
||||
|
||||
### Import and Export
|
||||
|
||||
- **Import**: paste the proxy addresses to import; every imported proxy is **validated one by one first, and only confirmed-working ones are stored**; geolocation is looked up asynchronously after storing.
|
||||
- **Export**: the export range follows the current filter, and the panel tells you how many records will be exported before it starts.
|
||||
- **Copy API link**: builds the pool's fetch endpoint URL from the current filter and copies it to the clipboard, so external programs or another ProxyCat instance can fetch proxies on demand.
|
||||
|
||||
### Database Backup and Restore
|
||||
|
||||
- The Database group in Pool Settings controls automatic backups: the switch, the backup interval (hours) and the retention (days).
|
||||
- Under Proxy Pool → Database Maintenance you can see the backup history (time, size), and each one can be **restored**: **it overwrites the current database with that backup; the pool stops first and restarts automatically once the restore finishes**.
|
||||
- To compact the database right away, click **Optimize Database**: it rebuilds the indexes and compacts the file, and writes briefly wait while it runs.
|
||||
|
||||
## Logs and Access Records
|
||||
|
||||
### The Four Log Files
|
||||
|
||||
Logs are routed by purpose into four files under `logs/`, never mixed together, each rotating by size:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `logs/main.log` | Main program: startup, configuration, panel address, shutdown |
|
||||
| `logs/access.log` | Proxy requests: time, client, upstream proxy, target host, success or failure, duration |
|
||||
| `logs/proxy.log` | Proxy lifecycle: loading, rotation, validation, retries |
|
||||
| `logs/error.log` | Everything at ERROR and above from all the categories above — look here first when troubleshooting |
|
||||
|
||||
The pool keeps its own log at `modules/proxypool/logs/proxy_pool.log`.
|
||||
|
||||
Switches for the log level, per-file size cap and rotation keep-count are in [Configuration](Configuration-EN.md).
|
||||
|
||||
### Reading the Access Log
|
||||
|
||||
Each access log line looks like this:
|
||||
|
||||
```text
|
||||
2026-09-14 20:03:31 - INFO - [OK] client 127.0.0.1/neko upstream socks5://1.2.3.4:1088 target www.example.com:443 CONNECT 235ms status 200
|
||||
2026-09-14 20:03:19 - WARNING - [FAIL] client 127.0.0.1/neko upstream socks5://5.6.7.8:3128 target www.baidu.com:443 CONNECT 36080ms status 504 reason Connection Timeout
|
||||
```
|
||||
|
||||
The password inside an upstream proxy address is always written as `***`; plaintext credentials are never logged.
|
||||
|
||||
A line reports one of three outcomes, and they mean different things:
|
||||
|
||||
| Outcome | Meaning |
|
||||
|---|---|
|
||||
| `[OK]` | the request really went through (for an HTTPS tunnel this only counts once data comes back from the upstream — merely writing back a 200 is not enough) |
|
||||
| `[FAIL]` | the request failed, with the reason at the end of the line |
|
||||
| `[ABORT]` | the request ended before a success or failure could be concluded (the client disconnected without sending data, neither side of the tunnel ever transferred data, the request was cancelled), and it is excluded from both the success and failure rates |
|
||||
|
||||
The `status` field is the HTTP status code written back to the client. Note that it can disagree with the outcome: an `[OK]` line records the status code the upstream returned as-is, so an upstream replying 4xx/5xx is still recorded as `[OK]`; an `[ABORT]` line keeps the status code already written back (200, say), but the outcome is neither success nor failure; while a `[FAIL]` such as a tunnel that was established but the upstream never sent data keeps no status code when the line is written, and the status shows as `-`. So **read the outcome column, not just the status code**.
|
||||
|
||||
### Access Statistics and Per-Request Detail
|
||||
|
||||
Switching the Logs tab to the **Access** category shows the access statistics table below. It aggregates by **upstream proxy**, and expanding a proxy reveals every host it has accessed:
|
||||
|
||||
```text
|
||||
Exit address OK Fail Total Rate Last seen Last failure reason
|
||||
▼ socks5://13.125.44.24:80 [4] 6 0 6 100% 2026-09-14 21:26:33 --
|
||||
example.com 1 0 1 100% 2026-09-14 21:26:33 --
|
||||
www.baidu.com 2 0 2 100% 2026-09-14 21:26:32 --
|
||||
```
|
||||
|
||||
The aggregation key is **host × upstream proxy**, not the host alone — the same host accessed through different proxies can turn out completely different, and grouping by host alone would flatten the most useful fact ("this proxy cannot reach this site"). Results are persisted to SQLite, so the cumulative figures survive a restart.
|
||||
|
||||
Besides the summary there is the **per-request detail**: one row per proxy request, filterable by time / upstream / target / outcome and exportable as CSV / JSON.
|
||||
|
||||
Retention for the access statistics and detail (domain statistics kept for 30 days; detail kept for 7 days with a cap of 200000 rows; `0` means no cleanup) is configured in [Configuration](Configuration-EN.md).
|
||||
|
||||
## FAQ
|
||||
|
||||
Q: Why does my XXX tool still not change proxies after ProxyCat is running?
|
||||
|
||||
A: ProxyCat is not a global proxy tool: the XXX tool must support using a proxy and send its traffic to ProxyCat's local listening port for it to go through the proxy pool.
|
||||
|
||||
Q: Why is the proxy not rotated when the countdown ends?
|
||||
|
||||
A: When an expired exit is replaced depends on the source and the run mode: with an API / pool source in Continuous mode, the background replaces expired exits on lifetime regardless of traffic; a local source and On request mode only handle expired exits while traffic is flowing (an expired exit is removed outright when idle, and the pool refetches to fill up when the next request arrives). That way an idle process never goes online to fetch — fewer resources are wasted, and one deployment stays usable long-term.
|
||||
|
||||
Q: When I fetch proxy addresses through getip, why does the first run report None / no usable proxy address?
|
||||
|
||||
A: To avoid wasted resources: the getip approach generally means paying for static short-lived IPs, and fetching at startup would burn a lot of money for nothing. Because of that, the default On request mode does not fetch at startup — just send traffic as you normally would, and ProxyCat fetches and uses proxies automatically. (If you switch the mode to Continuous in the panel, fetching is decoupled from traffic: the background fetches on lifetime, so it calls the endpoint shortly after startup.)
|
||||
|
||||
Q: I have my own static IP addresses — how do I use them?
|
||||
|
||||
A: Write fixed proxy addresses into `config/ip.txt` in the local list format shown under [From Source](#from-source), and set `proxy_source_mode` to `local`; each line is `protocol://[user:password@]host:port`. What `api_proxy_url` expects is "the URL of a fetch endpoint that returns proxy addresses one per line" — putting a single proxy address there makes ProxyCat request it as an endpoint and fetch nothing. If your addresses come from an endpoint, fill in `proxy_username` and `proxy_password` when you need to attach credentials uniformly. (In the panel the two are combined into one **Proxy auth** field, written as `username:password`.)
|
||||
|
||||
Q: Why do I run into XXX errors? Why doesn't it work?
|
||||
|
||||
A: Start with the [Investigation Manual](Investigation%20Manual-EN.md); if it cannot be fixed, you may ask the author — before asking, please pay ¥50 as a purchase fee for his time. If your question is something Baidu can answer or is covered in the manual, the fee is not refunded; if it turns out to be a tool bug or a feature suggestion, the fee is refunded in full and you will be added to this project's list of thanks. (Far too many people were asking very simple questions that were already answered in the help, wasting an enormous amount of time, and plenty of them had a very nasty attitude — this is not what I wanted.)
|
||||
|
||||
Q: The panel won't open — it returns 401 after the redirect?
|
||||
|
||||
A: The panel page itself is token-guarded, so with a token set you must open it with `?token=`. The token in the `config.ini` shipped in this repo is a public default — change it before exposing the panel (the panel has no token field; editing the file hot-reloads in about a second and the old token stops working right away). See [Four Things To Know Before Deploying](#four-things-to-know-before-deploying).
|
||||
|
||||
Q: Do I need to restart after changing configuration?
|
||||
|
||||
A: Only entries bound when the service starts, such as `port` / `web_port`, need a restart (`web_port` a whole-application restart plus a matching port mapping; `port` just a restart of the proxy service from the panel); every other setting hot-reloads, whether saved from the panel or edited in `config.ini` by hand. For the full boundaries see [Entry Points](#entry-points).
|
||||
|
||||
Q: After raising the log level, why have the successful records disappeared from the access log?
|
||||
|
||||
A: Successful access lines are INFO, so raising the level to WARNING or above filters them out and the access log keeps only failures and errors; the panel shows a hint in place when you save the level. To keep successful records, set the level back to INFO.
|
||||
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 250 KiB |
@@ -1,41 +1,41 @@
|
||||
# ProxyCat 使用手册
|
||||
|
||||
> 文档导航:[返回 README](../README.md) · [功能特性](Features.md) · [详细配置](Configuration.md) · [报错排查手册](Investigation%20Manual.md) · [更新日志](logs.md) · [接口文档](../API.md)
|
||||
|
||||
## 重要事项
|
||||
|
||||
- Python版本最好为Python 3.11
|
||||
- Releases中为较为稳定的打包版本,不一定是最新
|
||||
- API 接口所获取的代理地址必须为 IP:PORT 格式且只提供一条地址
|
||||
- Python 需要 **3.10 及以上**(3.8 / 3.9 会在导入阶段直接报错);Docker 镜像基于 Python 3.11。
|
||||
- Releases 中为较为稳定的打包版本,不一定是最新。
|
||||
- API 接口返回的内容按行解析:可一次给出多条代理地址,未写协议的行按 http 补全,格式非法的行会被跳过;需要为取回的地址统一附加账号密码时填写 `proxy_username` / `proxy_password`(两者都非空才生效)。
|
||||
- 随仓库发布的 `config/config.ini` 已按作者调优,个别项的取值与程序内置默认不同;本手册不重复逐项配置说明,每个键的含义、默认值、生效方式与权威参考见[详细配置](Configuration.md)。
|
||||
|
||||
## 源码使用及 Docker 部署
|
||||
## 安装部署
|
||||
|
||||
### 源码手册
|
||||
### 源码部署
|
||||
|
||||
**Windows&Mac**:浏览器访问位于 Github 的源码存储库并下载:[ProxyCat](https://github.com/honmashironeko/ProxyCat)
|
||||
|
||||

|
||||
**Windows 与 Mac**:浏览器访问位于 Github 的源码存储库并下载:[ProxyCat](https://github.com/honmashironeko/ProxyCat)
|
||||
|
||||
**Linux**:通过 Git 方法拉取项目到本地
|
||||
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/honmashironeko/ProxyCat.git
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
安装 Python 依赖(**请尽量保证Python版本为3.8-3.11**)
|
||||
安装 Python 依赖(**需要 Python 3.10 及以上**:源码大量使用运行时求值的 `X | Y` 联合类型注解,3.9 及更早版本会在导入阶段就报 `TypeError`)
|
||||
|
||||
```
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
# 或使用国内源:
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/
|
||||
```
|
||||
|
||||
进入 config 文件夹内找到 config.ini 配置文件,按照自己拥有的资源选择不同的代理服务器获取方法
|
||||
进入 `config` 文件夹内找到 `config.ini` 配置文件,按照自己拥有的资源选择不同的代理服务器获取方法:
|
||||
|
||||
1️⃣如果您的代理服务器地址为固定的连接,不需要动态更换,可以使用本地 ip.txt 的方式提供格式如下所示
|
||||
(1)如果您的代理服务器地址为固定的连接,不需要动态更换,可以使用本地 `ip.txt` 的方式提供,每行一条 `协议://[账号:密码@]地址:端口`,支持 http/https/socks5 三种代理服务器地址与账号密码校验,格式如下所示
|
||||
|
||||
```
|
||||
# 支持 http/https/socks5 三种代理服务器地址,支持账号密码校验
|
||||
```text
|
||||
socks5://neko:[email protected]:7890
|
||||
https://neko:[email protected]:7890
|
||||
http://neko:[email protected]:7890
|
||||
@@ -45,171 +45,339 @@ http://127.0.0.1:7890
|
||||
...
|
||||
```
|
||||
|
||||
2️⃣如果您是通过 API 方式获取代理地址,可以在 config.ini 中修改配置(配置后不再读取 ip.txt)
|
||||
(2)如果您是通过 API 方式获取代理地址,在 `config.ini` 中把 `proxy_source_mode` 改为 `api` 并填写 `api_proxy_url`(配置后不再读取 `ip.txt`);接口返回按行解析,需要统一为取回的地址附加账号密码时填写 `proxy_username` 与 `proxy_password`(两者都非空才生效)。
|
||||
|
||||
```
|
||||
# config.ini 配置文件中将以下两条配置进行修改
|
||||
use_getip = True
|
||||
getip_url = 获取代理地址的 API 接口
|
||||
# 请注意,API 接口所获取的代理地址必须为 IP:PORT 格式且只提供一条地址,如果格式不同请到 getip.py 脚本中修改代码,如果您需要指定协议(默认为socks5)可以进入 getip.py 脚本中修改
|
||||
```
|
||||
当您配置完成之后就可以运行工具了,两个入口的区别与选择见[运行方式](#运行方式)。
|
||||
|
||||
当您配置完成之后就可以运行工具了
|
||||
### Docker 部署
|
||||
|
||||
```
|
||||
python ProxyCat.py
|
||||
python app.py (Web控制管理-推荐方式)
|
||||
```
|
||||
Windows 可以下载 Docker 官方工具:[Docker Desktop](https://docs.dockerd.com.cn)
|
||||
|
||||

|
||||
|
||||
### Docker 手册
|
||||
|
||||
Windows 可以下载 Docker 官方工具:[Docker Desktop](docs.dockerd.com.cn)
|
||||
|
||||

|
||||

|
||||
|
||||
Linux 可以通过清华大学源提供的脚本一键安装:[清华大学安装脚本](https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/)
|
||||
|
||||

|
||||

|
||||
|
||||
安装完成后请测试docker、docker-compose是否安装成功,如果安装失败请百度
|
||||
安装完成后请先测试 `docker`、`docker compose` 是否安装成功。
|
||||
|
||||
Windows&Linux 进入 ProxyCat 文件夹下(**在此之前请根据源码手册中 config.ini 配置部分完成参数修改**)运行以下命令进行部署
|
||||
Windows 与 Linux 进入 ProxyCat 文件夹下(**在此之前请先根据源码部署一节完成 `config.ini` 的基本参数修改**),运行以下命令进行部署:
|
||||
|
||||
```bash
|
||||
# 进入 ProxyCat 文件夹中并构建镜像和启动容器
|
||||
docker compose up -d --build
|
||||
```
|
||||
# 进入ProxyCat文件夹中并构建镜像和启动容器
|
||||
docker-compose up -d --build
|
||||
|
||||
# 停止服务和启动服务(每次修改完配置后需要重启服务)
|
||||
docker-compose down | docker-compose up -d
|
||||
Compose 会构建镜像并启动容器,两个端口分别是:
|
||||
|
||||
| 端口 | 用途 |
|
||||
|---|---|
|
||||
| `1080` | 代理监听端口(HTTP 与 SOCKS5 共用) |
|
||||
| `5001` | Web 面板端口 |
|
||||
|
||||
三个目录通过 bind mount 挂到宿主,数据和配置不会随容器销毁而丢失:`config/`、`logs/`、`modules/proxypool/data/`。镜像自身不带 `config.ini`(构建时删除),首次启动会在挂载目录里生成它。
|
||||
|
||||
### 部署前必须知道的四件事
|
||||
|
||||
这四条都是**先出症状、后找原因**的典型,建议先看一遍:
|
||||
|
||||
**1. 面板打不开,返回 401**
|
||||
|
||||
面板页面本身也要校验 token,所以设置了 token 时**必须带上 `?token=` 才能打开**。仓库自带的 `config/config.ini` 里 token 不是空的,直接访问 `http://127.0.0.1:5001/` 会在跳转后得到 401。正确地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5001/?token=honmashironeko
|
||||
```
|
||||
|
||||
**这个 token 是公开在仓库里的,对外使用前请务必换掉**:面板不提供 token 的编辑入口,请直接改 `config.ini` 的 `[Server] token`,改完约 1 秒内随热重载生效 —— **旧 token 随即失效**,记得换用带上新 token 的地址打开。
|
||||
|
||||
**2. 容器起来了,但写不进配置或日志**
|
||||
|
||||
容器以非 root 用户运行,其 UID/GID 必须与宿主上 `config/`、`logs/`、`modules/proxypool/data/` 三个目录的属主一致,否则容器内写不进去。原因是镜像里对挂载点的 `chown` 对宿主目录无效,而镜像自身又不带 `config.ini`(构建时删掉了),首启要在挂载目录里生成它。宿主 UID/GID 不是 1000 时,在同目录的 `.env` 里设置:
|
||||
|
||||
```text
|
||||
PUID=你的UID
|
||||
PGID=你的GID
|
||||
```
|
||||
|
||||
**3. 重启后丢了最后一批数据**
|
||||
|
||||
容器停止时应用需要排空写队列,`stop_grace_period` 已放宽到 60 秒。Docker 默认的 10 秒会被 `SIGKILL`,最后一批验证结果与访问记录会丢失。**请不要改小它。**
|
||||
|
||||
**4. 改了 `web_port` 之后容器变成 unhealthy**
|
||||
|
||||
健康检查是**按 `config.ini` 里实际的 `web_port` 现读现探**的,不是写死的 5001。所以改完端口必须重启进程,并同步 `docker-compose.yml` 里的端口映射。
|
||||
|
||||
### Docker 常用命令
|
||||
|
||||
```bash
|
||||
# 停止服务和启动服务
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
|
||||
# 查看日志信息
|
||||
docker logs proxycat
|
||||
docker compose logs -f proxycat
|
||||
|
||||
# docker端口默认为1080和5000,1080为监听端口,5000为web页面管理如需其他端口请对应修改并放行
|
||||
# 修改了 Dockerfile 或依赖后重新构建并启动
|
||||
docker compose up -d --build
|
||||
|
||||
# docker 端口默认为 1080 和 5001,1080 为监听端口,5001 为 Web 面板
|
||||
# 如需其他端口请对应修改端口映射并放行
|
||||
```
|
||||
|
||||
### 配置文件介绍
|
||||
**哪些改动需要重启容器**:只有 `port` / `web_port` 这类在服务启动时绑定的项需要重启 —— `web_port` 改了必须重启整个应用并同步 `docker-compose.yml` 里的端口映射;`port` 可以不动进程,在面板里重启一次代理服务即可按新端口生效。其余配置项无论是在面板里保存还是手工编辑 `config.ini`,都会由热重载生效,**不需要**重启容器(热加载的完整边界见[运行方式](#运行方式))。
|
||||
|
||||
```
|
||||
# 日志显示级别(默认为:1)
|
||||
# 0: 仅显示代理切换和错误信息
|
||||
# 1: 显示代理切换、倒计时和错误信息
|
||||
# 2: 显示所有详细信息
|
||||
# 仅终端管理时生效
|
||||
display_level = 1
|
||||
## 运行方式
|
||||
|
||||
# 本地服务器监听端口(默认为:1080)
|
||||
# Local server listening port (default:1080)
|
||||
port = 1080
|
||||
项目有两个入口,按需要选一个:
|
||||
|
||||
# Web 管理页面端口(默认为:5000)
|
||||
web_port = 5000
|
||||
| 入口 | 启动内容 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `python app.py` | Web 面板 + 出口代理服务(+ 代理池,按来源自动启动) | 日常使用,推荐 |
|
||||
| `python ProxyCat.py` | 出口代理服务(+ 代理池,按来源自动启动),纯命令行、无面板 | 服务器上不需要面板时 |
|
||||
|
||||
# 代理地址轮换模式:cycle 表示循环使用,loadbalance 表示负载均衡(默认为:cycle)
|
||||
# Proxy rotation mode: cycle means cyclic use, loadbalance means load balancing (default:cycle)
|
||||
mode = cycle
|
||||
命令行入口支持 `-c` 指定配置文件,默认读 `config/config.ini`:
|
||||
|
||||
# 代理地址更换时间(秒),设置为 0 时每次请求都更换 IP(默认为:300)
|
||||
# Proxy address rotation interval (seconds), when set to 0, IP changes with each request (default:300)
|
||||
interval = 300
|
||||
|
||||
# 是否使用 getip 模块获取代理地址 True or False(默认为:False)
|
||||
# Whether to use getip module to obtain proxy addresses True or False (default:False)
|
||||
use_getip = False
|
||||
|
||||
# 获取新代理地址的URL
|
||||
# URL to get new proxy address
|
||||
getip_url = http://example.com/getip
|
||||
|
||||
# 代理服务器认证用户名(如果代理服务器需要认证)
|
||||
# Proxy server authentication username (if proxy server requires authentication)
|
||||
proxy_username =
|
||||
|
||||
# 代理服务器认证密码(如果代理服务器需要认证)
|
||||
# Proxy server authentication password (if proxy server requires authentication)
|
||||
proxy_password =
|
||||
|
||||
# 代理地址列表文件(默认为:ip.txt)
|
||||
# Proxy address list file (default:ip.txt)
|
||||
proxy_file = ip.txt
|
||||
|
||||
# 是否启用代理检测功能 True or False(默认为True)
|
||||
# Whether to enable proxy detection feature True or False (default:True)
|
||||
check_proxies = True
|
||||
|
||||
# 语言设置 (cn/en)
|
||||
# Language setting (cn/en)
|
||||
language = cn
|
||||
|
||||
# IP白名单文件路径(留空则不启用白名单)
|
||||
# IP whitelist file path (leave empty to disable whitelist)
|
||||
whitelist_file = whitelist.txt
|
||||
|
||||
# IP黑名单文件路径(留空则不启用黑名单)
|
||||
# IP blacklist file path (leave empty to disable blacklist)
|
||||
blacklist_file = blacklist.txt
|
||||
|
||||
# IP认证优先级(whitelist/blacklist)
|
||||
# IP authentication priority (whitelist/blacklist)
|
||||
# whitelist: 优先判断白名单,在白名单中的IP直接放行
|
||||
# whitelist: prioritize whitelist check, IPs in whitelist are allowed directly
|
||||
# blacklist: 优先判断黑名单,在黑名单中的IP直接拒绝
|
||||
# blacklist: prioritize blacklist check, IPs in blacklist are rejected directly
|
||||
ip_auth_priority = whitelist
|
||||
|
||||
# Web 管理页面访问token,留空则无需token(默认为:honmashironeko)
|
||||
token = honmashironeko
|
||||
|
||||
# 在[Users]下面是用户管理组,"账号=密码"一行一个,留空时代理无需身份鉴别(默认为:neko=123456)
|
||||
[Users]
|
||||
neko=123456
|
||||
```bash
|
||||
python ProxyCat.py -c config/config.ini
|
||||
# Windows 上命令名为 python,写成 python ProxyCat.py 即可
|
||||
```
|
||||
|
||||
### Web 控制面板
|
||||
两个入口都会读取同一份 `config.ini`。命令行入口的 `display_level`(**0-2**)控制控制台详细程度:`1` 是带倒计时的进度条,`2` 会额外显示耗时与错误详情。**在容器里进度条会换成周期性日志行**,没有看到进度条不等于故障。
|
||||
|
||||
采用源码部署的话通过 **python app.py** 启动 Web 控制面板,并根据提示访问 Web
|
||||
> **热加载的边界**(容易踩):
|
||||
>
|
||||
> - **从面板保存的配置即时生效**,两个入口都是。
|
||||
> - **手工编辑 `config.ini` 时**,两个入口都会重载 `[Server]` 段:`app.py` 有后台线程按秒巡检配置文件,约 1 秒内生效;`ProxyCat.py` 同样在状态轮询里发现改动后重载,倒计时模式下约 1 秒,`loadbalance` 或非按时间轮换(`interval` 为 `0`)时最多约 5 秒。
|
||||
> - `[Pool]` 段同样会热加载,两个入口都会在约 3 秒内生效。
|
||||
> - `port` 与 `web_port` 只在服务启动时绑定:`web_port` 改了必须重启整个应用(面板只在保存时给出告警);`port` 可以不动进程,在面板里重启一次代理服务即可按新端口生效。
|
||||
|
||||

|
||||
## 面板使用
|
||||
|
||||

|
||||
面板是**一个单页**,地址 `http://127.0.0.1:5001/web`(记得带 `?token=`),左侧四个页签:
|
||||
|
||||

|
||||
| 页签 | 在这里做什么 | 背后由什么支撑 |
|
||||
|---|---|---|
|
||||
| **代理配置** | 切换代理来源(本地 / API / 代理池)、改轮换模式与间隔、编辑本地代理列表、保存 API 凭据、调日志与统计开关;并发等调优项在「性能」与「高级选项」组里 | `[Server]` 段配置 |
|
||||
| **代理池** | 见下方三个子视图 | `[Pool]` 段配置 + `/api/pool/*` |
|
||||
| **访问控制** | 用户管理(出口认证账号)、IP 黑白名单与优先级、代理绕过白名单 | `[Users]` 段与 `config/` 下的名单文件 |
|
||||
| **运行日志** | 按类别看实时日志、浏览历史日志文件、看访问统计与访问明细 | `logs/` 下的日志与两个统计库 |
|
||||
|
||||

|
||||
「代理池」页签下有三个子视图:
|
||||
|
||||
## 问题Q&A
|
||||
| 子视图 | 在这里做什么 |
|
||||
|---|---|
|
||||
| **代理管理** | 看抓到的代理(协议 / 真实 IP / 归属地 / 延迟 / 健康分 / 匿名度)、按条件筛选、批量验证与删除、导入导出、手动触发插件抓取 |
|
||||
| **池设置** | 调代理池自身的运行参数(验证并发与超时、抓取周期、自动重验证、自动清理、使用反馈、写队列) |
|
||||
| **数据库维护** | 数据库统计与优化、备份列表与恢复、离线归属地库的更新与重算 |
|
||||
|
||||
Q:为什么运行后我的XXX工具代理还是没换?
|
||||
下面逐页签展开。
|
||||
|
||||
A:ProxyCat 并不是全局代理工具,需要XXX工具支持使用代理,将流量发送到 ProxyCat 的本地监听端口才会经过代理池。
|
||||
### 代理配置
|
||||
|
||||

|
||||
|
||||
图中是「代理配置」页签:页头是服务控制(启动 / 停止 / 重启)、两个监听地址与代理状态;下面可在「本地代理 / API 代理 / 维护池代理」三种来源间切换,左侧是配置分组导航,右侧是当前分组的表单。截图选中的是「维护池代理」来源的「来源设置」组 —— 该组只显示当前来源用得上的字段。
|
||||
|
||||
配置项按用途分为七组:
|
||||
|
||||
- **来源设置**:只显示当前来源用得上的字段;
|
||||
- **监听端口**:端口被占用时才需要改;
|
||||
- **出口轮换**:一个出口用多久、同时用几个;
|
||||
- **可用性检测**:什么样的代理能进池;
|
||||
- **性能与并发**:本机能同时扛多少请求;
|
||||
- **日志与记录**:记什么、留多久;
|
||||
- **高级选项**:需要理解内部机制的调优项集中在这里。
|
||||
|
||||
「来源设置」里的「远程代理池地址」(`pool_remote_url`)是**取用接口**,不是管理接口:它只对填写的地址发一次 GET,把响应体里的每一行解析成一个代理地址(例如 `http://1.2.3.4:8080`),不做任何管理操作;返回 404 表示暂时没有可用代理。留空则使用进程内的内置代理池;来源切到「维护池代理」而池没在跑时,会自动把它拉起来(见[代理池使用](#代理池使用))。
|
||||
|
||||
「出口轮换」组里的 `mode` **在两种来源下问的是两件不同的事**:本地来源问「挑谁」——`cycle` 按清单顺序使用(忽略负载,前一条满了才用下一条)、`loadbalance` 谁压力小先用谁;API 与维护池来源问「什么时候换」——`request`(触发更换)排在列表前面,只在收到新任务时取货、换出口,没有任务时到期的出口直接移除、池子会空掉,下一个请求到达时才重新取货填满;`continuous`(持续更换)与有没有流量无关,后台按寿命主动换掉到期的并补满。切换来源时,若当前值不属于新来源那一组,会自动归一到该组的首项 —— **api / pool 组归一到 `request`**。两种更换节奏的取舍见[常见问题 Q&A](#常见问题-qa)。
|
||||
|
||||
本地代理列表就在这个页签里编辑,保存即重载名册;列表下方的「检测代理」检测的是**源清单**(local 来源下即 `ip.txt` 里的全部条目),所以已经被停用的坏出口也查得到。
|
||||
|
||||

|
||||
|
||||
「性能与并发」组管本机能同时扛多少请求:全局并发上限是所有出口合计的上限,超出后在信号量上排队;单出口并发上限 `0` 表示不限,此时并发只受全局并发上限约束;设成非 0 后,总上游并发约等于 `出口数 × 本值`(仍受全局上限封顶)。截图里的提示「扩容只在单出口并发上限不为 0 时有意义」正是自动扩容的开关条件 —— 填 0 时不扩,但已经张开的膨胀状态照常收回。扩容与收回为什么看两个不同的数、放宽单出口额度为什么是最后手段,见[上游出口与并发](Features.md#上游出口与并发)。
|
||||
|
||||
每个配置项的含义、默认值与生效方式(即时 / 需重启)以[详细配置](Configuration.md)为准;面板表单本身带校验,保存即生效,推荐日常在这里修改。
|
||||
|
||||
### 代理池
|
||||
|
||||
#### 代理管理
|
||||
|
||||

|
||||
|
||||
截图自上而下是:统计卡(代理总数 / 有效 / 失效 / 可用率)、插件管理表(每个插件的状态、抓取周期、上次与下次运行、重验证策略,以及「抓取」「测试地址」「重载」按钮)、带筛选的代理列表(协议、代理地址、真实 IP、地区、延迟、健康分、匿名度,右上角有导入 / 导出 / 复制取用链接)。
|
||||
|
||||
在这里可以:按协议 / 有效性 / 匿名度 / 来源筛选(还有高级筛选),对选中的代理批量验证与删除,导入与导出代理,手动触发插件抓取。列表里还提示了匿名度是实测还是推算 —— 拿不到回显证据的代理只能给推算值。
|
||||
|
||||
#### 池设置
|
||||
|
||||

|
||||
|
||||
池设置按用途分为八组:数据库 / 代理验证 / 抓取插件 / 自动重新验证 / 自动清理 / 使用反馈 / 写入队列 / 日志,每个组内的调优项折叠在「高级选项」里。截图选中的是「数据库」组,管自动备份的开关、间隔(小时)与保留天数 —— 自动备份关掉后不再生成新备份,保留天数决定能恢复到多久以前。每个字段的说明以[详细配置](Configuration.md)的 `[Pool]` 段落为准。
|
||||
|
||||
#### 数据库维护
|
||||
|
||||

|
||||
|
||||
截图自上而下是:数据库统计(代理总数 / 有效 / 失效 / 数据库体积)与「优化数据库」按钮;历史备份列表(时间、大小,最新一份带「最新」标记),每份备份都能一键「恢复」;底部的归属地库 —— 显示离线库可用状态、数据文件与修改时间,以及「更新归属地库」与「重算归属地」两个动作。
|
||||
|
||||
- 「优化数据库」会重建数据库索引并压缩文件,期间写入会短暂等待;
|
||||
- 「恢复」会**用该备份覆盖当前数据库**:代理池会先停止,恢复完成后自动重启;
|
||||
- 归属地库离线可用时,中国城市级解析用纯真库(`qqwry.ipdb`),其余用 GeoLite2(`GeoLite2-City.mmdb`);「更新归属地库」需要联网下载(约 104MB,需数分钟),「重算归属地」则对存量记录重新解析,有待重算记录时才可用。
|
||||
|
||||
### 访问控制
|
||||
|
||||

|
||||
|
||||
截图分三块:左上「用户管理」列出出口认证账号(截图中是仓库自带的 `k` 与 `neko` 两个账号,可改密码与增删用户);右上是 IP 访问控制的**白名单**与**黑名单**,底部下拉选择同时命中时的优先级,点「保存名单」落盘;下方是**代理绕过白名单**,命中的目标地址直连、不经过上游代理,支持通配符。
|
||||
|
||||
- 用户管理编辑的是 `[Users]` 段(「账号 = 密码」一行一个):**账号表为空时代理不要求身份鉴别**。程序内置默认没有 `[Users]` 段、不要求认证;仓库自带的 `config.ini` 里出厂账号是 `k = 123` 与 `neko = 123456`,所以不带认证的请求会得到 407。请通过面板的用户管理修改账号 —— 手工编辑 `config.ini` 的 `[Users]` 段同样会随配置热重载生效(约 1 秒内)。
|
||||
- 白名单 / 黑名单文件固定从 `config/` 目录读取;同时命中时按 `ip_auth_priority`(`whitelist` 放行 / `blacklist` 拦截)决定。
|
||||
- 三份名单的文件名与匹配规则见[详细配置](Configuration.md)。
|
||||
|
||||
### 运行日志
|
||||
|
||||

|
||||
|
||||
截图顶部是两排过滤:类别(全部 / 主程序 / 代理 / 访问 / 池)与级别(全部 / 重要 / 信息 / 警告 / 错误 / 严重错误);右侧可切换实时日志与历史日志文件,以及自动刷新、跟随、导出、清除日志。日志列表中的每行带时间、级别与类别。
|
||||
|
||||
- 「访问」类别下有按代理汇总的访问统计与逐条访问明细,见[日志与访问记录](#日志与访问记录);
|
||||
- **日志级别与访问日志是联动的**:成功访问行是 INFO,级别调到 WARNING 及以上后访问日志里只会剩失败与错误,面板会就地给出提示;
|
||||
- 历史文件回看、导出与清理都作用于 `logs/` 下的日志文件。
|
||||
|
||||
语言切换按钮在侧边栏底部,**它改的是服务端设置**:切换后面板文案、接口错误文案、导出文件的表头都会一起变(命令行入口的控制台横幅只在启动时打印一次,不会跟着重印)。
|
||||
|
||||
## 代理池使用
|
||||
|
||||
### 什么时候启动
|
||||
|
||||
内置代理池**只在真的要用它当代理来源时才启动**,由 `[Server]` 的 `proxy_source_mode` 决定:
|
||||
|
||||
| `proxy_source_mode` | 池是否随服务启动 |
|
||||
| --- | --- |
|
||||
| `pool`(维护池) | 启动 |
|
||||
| `local`(本地文件) | 不启动 |
|
||||
| `api`(API 获取) | 不启动 |
|
||||
|
||||
配了 `pool_remote_url`(接入外部代理池)时也不启动 —— 那种情况根本不走进程内这一个。注意它是外部池的**取用接口**而不是管理接口,细节见[代理配置](#代理配置)。
|
||||
|
||||
用本地文件或 API 取代理时,池跑着只是白耗资源去联网抓取和验证代理,所以默认不起。如果在面板里把来源切换成「维护池代理」,而池当前没在跑,会**自动把它拉起来**;反过来切换走不会自动停,避免误停正在提供代理的池 —— 需要停的时候在「代理池」页签里手动停。
|
||||
|
||||
### 插件管理
|
||||
|
||||
抓取插件放进 `modules/proxypool/plugins/` 后,重启池服务生效(目录只在池服务启动时扫描一次);已在列表中的插件改了代码,可在面板「代理池 → 代理管理」的插件列表里点「重载」热加载,也可以在那里单独设置某个插件的抓取周期与检测地址。两条最容易踩的约定:
|
||||
|
||||
- **抓取失败必须抛出异常**。返回空列表会被当作「抓取成功但这一轮没抓到」,于是错误被吞掉、调度周期照常推进,表现为永远抓不到东西。
|
||||
- **插件的文件名(不含扩展名)就是它的标识**,配置、调度与来源记录都用这个名字。
|
||||
|
||||
插件列表里还能为单个插件配置重验证策略,甚至「跳过入库验证,抓到就直接入库」——适合按请求计费、有效性由供应商保证的代理源;这类记录没有出口 IP、匿名度与健康分,建议同时关闭重验证,否则它仍会被定时检测并产生费用。完整的插件编写契约见[代理池模块文档](../modules/proxypool/README.md)。
|
||||
|
||||
### 批量验证
|
||||
|
||||
- 代理管理页可对选中的代理执行「验证有效性」,工具栏另有「验证全部有效」「验证全部失效」「全面检测」三个批量动作。
|
||||
- 验证强度分三档:`liveness` 只判通不通、`identity` 补身份信息、`full` 全量评估。面板上的批量验证可手选 `liveness` / `full`,传 `auto` 表示仍按策略决定(`identity` 不是批量接口的可选值)。
|
||||
- **被使用反馈标记失效的代理会被列入下一轮自动重验证的候选**,重验证测通后可用状态自动翻回来;不想等的话,也可以在「代理管理」里点「验证全部失效」立刻手动重验。
|
||||
- 「检测代理」按钮检测的是**源清单**(local 来源下即 `ip.txt` 里的全部条目),它和池内的批量验证是两回事。
|
||||
|
||||
### 导入导出
|
||||
|
||||
- 「导入」:粘贴要导入的代理地址,导入的代理会**先逐个验证,只有确认可用的才会入库**;归属地在入库后自动异步查询。
|
||||
- 「导出」:导出范围与当前的筛选条件一致,导出前会提示将导出多少条。
|
||||
- 「复制取用链接」:按当前筛选条件生成代理池的取用接口链接并复制到剪贴板,供外部程序或另一个 ProxyCat 实例按需取用代理。
|
||||
|
||||
### 数据库备份与恢复
|
||||
|
||||
- 池设置的「数据库」组控制自动备份:开关、备份间隔(小时)与备份保留(天)。
|
||||
- 在「代理池 → 数据库维护」里可以看到历史备份列表(时间、大小),每份都能「恢复」:**将用该备份覆盖当前数据库;代理池会先停止,恢复完成后自动重启**。
|
||||
- 需要立刻压缩数据库时点「优化数据库」:重建索引并压缩文件,期间写入会短暂等待。
|
||||
|
||||
## 日志与访问记录
|
||||
|
||||
### 四类日志文件
|
||||
|
||||
日志按用途分流到 `logs/` 下的四个文件,互不混杂,各自按大小轮转:
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `logs/main.log` | 主程序:启动、配置、面板地址、关闭 |
|
||||
| `logs/access.log` | 代理请求:时间、客户端、上游代理、目标域名、成败、耗时 |
|
||||
| `logs/proxy.log` | 代理生命周期:加载、切换、验证、重试 |
|
||||
| `logs/error.log` | 以上各类别中 ERROR 及以上的汇总,排障时先看这里 |
|
||||
|
||||
代理池自身的日志仍写在 `modules/proxypool/logs/proxy_pool.log`。
|
||||
|
||||
日志级别、单个文件大小上限与轮转保留份数等开关见[详细配置](Configuration.md)。
|
||||
|
||||
### 访问日志怎么看
|
||||
|
||||
访问日志每行形如:
|
||||
|
||||
```text
|
||||
2026-09-14 20:03:31 - INFO - [成功] 客户端 127.0.0.1/neko 上游 socks5://1.2.3.4:1088 目标 www.example.com:443 CONNECT 235ms 状态 200
|
||||
2026-09-14 20:03:19 - WARNING - [失败] 客户端 127.0.0.1/neko 上游 socks5://5.6.7.8:3128 目标 www.baidu.com:443 CONNECT 36080ms 状态 504 原因 Connection Timeout
|
||||
```
|
||||
|
||||
上游代理地址中的密码一律以 `***` 替代后落盘,不会记录明文凭据。
|
||||
|
||||
每行有三种结果,含义各不相同:
|
||||
|
||||
| 结果 | 含义 |
|
||||
| --- | --- |
|
||||
| `[成功]` | 请求确实走通了(HTTPS 隧道要等上游有数据回来才算,仅写回 200 不算) |
|
||||
| `[失败]` | 这次请求失败,原因写在行尾 |
|
||||
| `[中断]` | 请求在没有得出成败结论前就结束(如客户端未发数据就断开、隧道双方都没传过数据、请求被取消),不计入成功率与失败率 |
|
||||
|
||||
`状态` 一栏是写回给客户端的 HTTP 状态码。注意它可能与结果不一致:`[成功]` 行记录的是上游原样返回的状态码,上游回 4xx/5xx 也会记为 `[成功]`;`[中断]` 行会保留已经写回的状态码(如 200),但结果既不算成功也不算失败;而隧道已建立但上游从不发送数据这类 `[失败]`,写日志时不再保留状态码,状态显示为 `-`。因此**看结果列,不要只看状态码**。
|
||||
|
||||
### 访问统计与明细
|
||||
|
||||
在「运行日志」页签切到「访问」类别时,下方会显示访问统计表。它按**上游代理**汇总,展开某个代理可以看到该代理访问过的每个域名:
|
||||
|
||||
```text
|
||||
出口地址 成功 失败 合计 成功率 最后访问 最后失败原因
|
||||
▼ socks5://13.125.44.24:80 [4] 6 0 6 100% 2026-09-14 21:26:33 --
|
||||
example.com 1 0 1 100% 2026-09-14 21:26:33 --
|
||||
www.baidu.com 2 0 2 100% 2026-09-14 21:26:32 --
|
||||
```
|
||||
|
||||
聚合键是「域名 × 上游代理」而不是只看域名 —— 同一个域名经不同代理访问的结果可能完全不同,只看域名会把「这个代理访问不了这个站」这条最有用的信息抹平。统计结果落 SQLite 持久化,重启后累计值不丢。
|
||||
|
||||
除汇总外还有**逐条访问明细**:每次代理请求一行,可按时间 / 上游 / 目标 / 结果过滤,并导出 CSV / JSON。
|
||||
|
||||
访问统计与访问明细的保留策略(域名统计保留 30 天、明细保留 7 天且上限 200000 行,`0` 表示不清理)等配置见[详细配置](Configuration.md)。
|
||||
|
||||
## 常见问题 Q&A
|
||||
|
||||
Q:为什么运行后我的 XXX 工具代理还是没换?
|
||||
|
||||
A:ProxyCat 并不是全局代理工具,需要 XXX 工具支持使用代理,将流量发送到 ProxyCat 的本地监听端口才会经过代理池。
|
||||
|
||||
Q:为什么倒计时结束后代理没有更换?
|
||||
|
||||
A:ProxyCat 为了节约硬件资源和代理服务器资源,特意修改运行逻辑为有流量经过的时候才会更换代理,这样可以减少资源的浪费,同时可以部署一次,长期可用。
|
||||
A:出口到期后什么时候被换掉由来源与运行模式决定:API / 维护池来源的「持续更换」模式由后台按寿命主动换掉到期的出口,与有没有流量无关;本地来源与「触发更换」模式只在有流量经过时才处理到期的出口(空闲时到期的出口会被直接移除,池子在下一次请求到达时重新取货填满)。这样空闲时就不去联网取货,减少资源浪费,可以部署一次、长期可用。
|
||||
|
||||
Q:为什么我用 getip 方式获取代理地址的时候,首次运行会报 None,没有可用的代理地址?
|
||||
|
||||
A:为了防止资源浪费,通过 getip 获取的情况一般是付费购买静态短效 IP,运行就获取的话会浪费大量资源从而导致资金损耗,为避免这种情况发生,默认的「触发更换」模式首次运行不会主动获取,您只需要正常使用发包,ProxyCat 会自动获取并发送。(若在面板把模式切成「持续更换」,则与有没有流量无关,后台按寿命主动取货,启动后不久就会请求接口。)
|
||||
|
||||
Q:为什么我用 getip 方式获取代理地址的时候,首次运行会报 None ,没有可用的代理地址?
|
||||
|
||||
A:为了防止资源浪费,通过 getip 获取的情况一般是付费购买静态短效IP,运行就获取的话会浪费大量资源从而导致资金损耗,为避免这种情况发生,首次运行不会主动获取,您只需要正常使用发包,ProxyCat 会自动获取并发送。
|
||||
|
||||
|
||||
|
||||
Q:getip.py 当中的appKey和anquanma是做什么的?
|
||||
|
||||
A:这两个参数是用作自动将当前请求IP添加到服务商(请查看readme中最下面的第一个推荐)的白名单中,免去每次IP变更需重新添加的烦恼,其中anquanma(安全码)需要到个人中心配置。
|
||||
|
||||
|
||||
|
||||
Q:我自己有静态IP提供地址该怎么用?
|
||||
A:将地址填入getip_url,如果有账号密码请写在 proxy_username 、proxy_password 。
|
||||
|
||||
Q:我自己有静态 IP 提供地址该怎么用?
|
||||
|
||||
A:固定不变的代理地址请按[源码部署](#源码部署)中的本地列表格式写进 `config/ip.txt`,并把 `proxy_source_mode` 设为 `local`,每行一条 `协议://[账号:密码@]地址:端口`;`api_proxy_url` 要填的是「按行返回代理地址的取用接口 URL」,直接填一条代理地址会被当成接口去请求而取不到代理。地址若由接口返回,需要统一附加账号密码时填写 `proxy_username`、`proxy_password`。(面板上这两项合成了一行「代理认证」,写成 `用户名:密码` 即可)
|
||||
|
||||
Q:为什么我会遇到 XXX 报错?为什么不能用?
|
||||
|
||||
A:可先看[《排查手册》](https://github.com/honmashironeko/ProxyCat/blob/main/ProxyCat-Manual/Investigation%20Manual.md),无法修复的情况下可以找作者询问,提问前请先支付50元作为时间的购买费用,如果您的问题属于百度可查或手册中有的,费用将不会返还;如果属于工具BUG或功能建议,费用将全额返还并将您列入本项目的感谢名单中。(实在是太多人多在问一些非常简单且写在帮助中的问题,时间被极大的浪费了,同时有很多态度非常恶劣的人,这不是我所想要的)
|
||||
A:可先看[《排查手册》](Investigation%20Manual.md),无法修复的情况下可以找作者询问,提问前请先支付 50 元作为时间的购买费用,如果您的问题属于百度可查或手册中有的,费用将不会返还;如果属于工具 BUG 或功能建议,费用将全额返还并将您列入本项目的感谢名单中。(实在是太多人多在问一些非常简单且写在帮助中的问题,时间被极大的浪费了,同时有很多态度非常恶劣的人,这不是我所想要的)
|
||||
|
||||
Q:面板打不开,跳转后返回 401?
|
||||
|
||||
A:面板页面本身也要校验 token,设置了 token 时必须带上 `?token=` 才能打开;仓库自带的 `config.ini` 里 token 是公开的默认值,对外使用前请改掉(面板没有 token 编辑入口,改文件后约 1 秒热重载生效,旧 token 随即失效)。详见[部署前必须知道的四件事](#部署前必须知道的四件事)。
|
||||
|
||||
Q:改完配置需要重启吗?
|
||||
|
||||
A:只有 `port` / `web_port` 这类在服务启动时绑定的项需要重启(`web_port` 要重启整个应用并同步端口映射,`port` 在面板里重启一次代理服务即可);其余配置项无论是在面板保存还是手工改 `config.ini`,都会热重载生效。完整边界见[运行方式](#运行方式)。
|
||||
|
||||
Q:把日志级别调高后,为什么访问日志里的成功记录不见了?
|
||||
|
||||
A:成功访问行是 INFO 级别,级别调到 WARNING 及以上会把它们一并过滤掉,访问日志里只剩失败与错误;面板在保存级别时会就地给出提示。要保留成功记录请把级别调回 INFO。
|
||||
@@ -0,0 +1,319 @@
|
||||
[Back to README](../README-EN.md)
|
||||
|
||||
### 2026/10/05 (ProxyCat V3.0.0)
|
||||
|
||||
**New Features**
|
||||
|
||||
- Credentials for the upstream proxy-fetch endpoint are now managed from the panel: multiple named credential sets (endpoint address + username and password) can be saved and deleted, and the active one switched at any time; switching writes the configuration immediately and takes effect on the running service, so changing API providers no longer means hand-editing `config.ini` (the sets are stored in the `[api_credentials]` section)
|
||||
|
||||
- Added the "Proxy Bypass Whitelist": matching target hosts / IPs connect directly from this machine instead of going through the upstream proxy (both HTTP and SOCKS5), with wildcards such as `*.baidu.com` and `192.168.1.*` supported; the list lives in `config/bypass_whitelist.txt` and is editable directly from the panel's Access Control tab
|
||||
|
||||
- Logs → Access gained a per-request detail view: every proxy request can be filtered by time range and conditions, paged through, exported as CSV / JSON (CSV carries a BOM, so Windows Excel opens it without mojibake) and cleared, with the records stored in SQLite so they survive a restart; retention days (7 by default), the row cap (200000 by default) and the memory buffer are all configurable, and the hot path writes to memory only with a background flush to the database in batches every 5 seconds — independent of the access statistics aggregated per proxy
|
||||
|
||||
- Geolocation now resolves through local offline databases: GeoLite2 (global, ISO codes plus Chinese and English names) and the Chunzhen library (Chinese provinces and cities plus carriers), with no more calls to online geolocation services; the geo databases section under Proxy Pool → Database Maintenance lists the data files and how many records are pending recompute, and offers **Update Geo Databases** (about 104 MB, downloaded only on demand) and **Recompute Geo**; when the databases are unavailable, regions are left empty — there is no fallback to an online lookup
|
||||
|
||||
- Bilingual geolocation: Chinese and English names are both stored, the UI language decides which one is shown and the other goes into the tooltip, and sorting by region follows the UI language too; a name with no translation in the current language is flagged as showing the original text, and can be recomputed
|
||||
|
||||
- Added a command palette and global keyboard shortcuts: `Ctrl/⌘+K` opens a searchable command palette (jump between views, start and stop the service, refresh exits, batch-validate, import and export, switch theme or language and more); `1–4` switch views, `r` refreshes, `/` focuses search and `?` shows the shortcut list; in tables `↑/↓` move row focus, Space selects and `Shift` extends the selection, and typing in an input always takes precedence
|
||||
|
||||
- Progress and cancellation for batch tasks: long jobs such as imports, batch validation and plugin fetches appear under Background tasks showing progress, rate, estimated time left and per-category counts (saved / unusable / unreachable / failed) for the current stage (Checking port reachability / Validating proxies), and a running task can be cancelled in one click (its status then reads Cancelled)
|
||||
|
||||
- Added a version check module: it checks once at startup and every 24 hours after that, persisting the check time so a restart within 24 hours does not check again (at most one outbound request per 24 hours); the panel's version API only reads the cached result, so opening the panel never blocks on the network, and a failed check keeps the last successfully fetched version
|
||||
|
||||
- Added a promo / announcement slot to the panel: drop a JSON file (title, body, image, link, dwell time) into `config/ads/` and it is shown; users can dismiss it in one click and reopen it from the sidebar (the dismissal is not persisted, so it is back after a restart)
|
||||
|
||||
**UI and Interaction**
|
||||
|
||||
- The panel moved from a horizontal tab strip to a fixed left sidebar with four sections: Proxy Config, Proxy Pool, Access Control and Logs; the Proxy Pool section is split into the three sub-views Proxy Management / Pool Settings / Database Maintenance; the service and pool run states sit permanently in the sidebar, and the Chinese and English wording was filled in throughout
|
||||
|
||||
- The Proxy Config page was rearranged into grouped cards (Source, Listening ports, Exit rotation, Availability checks, Performance, Logging and records, Advanced), each option showing a short title plus a one-line hint and an ⓘ with the full text; numeric inputs take their lower and upper bounds from the server-side schema; proxy authentication was merged into a single "username:password" field, and entering a username without a colon now warns explicitly that this will not enable authentication
|
||||
|
||||
- Option presentation and the save bar: units moved to the right of the input box and titles were shortened to the short name; the save bar is greyed out while nothing has changed, gained **Discard changes**, shows a **Saved** receipt in the clean state and casts a shadow when pinned to the bottom; switching the language re-renders the form immediately
|
||||
|
||||
- Added a dark theme: light / dark, following the system's dark preference until switched by hand and remembering the choice after that; native dropdowns and scrollbars render with the theme (`color-scheme`), so dark-theme users no longer get a white flash when the page opens; the palette was redone against contrast standards, and in dark mode body text, buttons and coloured text all pass
|
||||
|
||||
- Logs and Access improvements: category tabs carry count badges, hovering the end of a line copies it, and the search box can be cleared; status codes in the access detail are coloured by 2xx / 3xx / 4xx / 5xx; the row detail drawer is divided into Connection / Quality / Timestamps and localises the timestamps; the table's right-click menu can copy an address or copy it as a curl command
|
||||
|
||||
- Dead-proxy visibility: invalid rows are dimmed throughout and carry an **Invalid** badge with a reason hint; the invalid card in the pool overview is coloured by the actual count (zero invalid proxies no longer display in red)
|
||||
|
||||
- When an exit's IP differs from the entry point (the proxy relays through another hop in between), the list shows a subtle warning-coloured hint; the various legal spellings of an IPv6 address are no longer mistaken for different addresses
|
||||
|
||||
- Table column widths can be dragged and are remembered (kept the next time the page opens), with a **Reset columns** button alongside
|
||||
|
||||
- Import and export interaction: the export dialog has three format tabs (txt / json / csv) and shows how many records will be exported; the import box counts entries live
|
||||
|
||||
- Controls and styling unified: dangerous actions such as delete and batch operations now use a custom confirmation dialog (replacing the browser's native `confirm`), and Esc closes the topmost open overlay first; select dropdowns have a custom arrow, their popups are no longer black-on-white in dark mode, and toggle switches are larger; toasts gained icons and can be clicked to dismiss; the version number in the page header now takes the real value from the API
|
||||
|
||||
- Removed Bootstrap and external JS scripts from the panel in favour of hand-written styles and a system font stack (with the Chinese fallbacks filled in)
|
||||
|
||||
- With the pool not running the panel no longer polls the pool APIs for nothing, and a failed load shows a message instead of looking like "no data"
|
||||
|
||||
- Internationalisation completed: the Pool Settings form re-renders immediately on a language switch, configuration validation errors come back in the UI language, option descriptions are bilingual (inside `config.ini` they stay Chinese), and dynamic content re-renders with the language
|
||||
|
||||
**Proxy Pool**
|
||||
|
||||
- The built-in pool went from a separately launched program shipped alongside to a subsystem inside the ProxyCat process: it no longer occupies port 8000 and the panel no longer spawns it as a child process; it shares the panel's web port, and the way in is the panel's Proxy Pool tab rather than the pool's own page; the pool's fetch and management APIs all hang off `/api/pool/*` under the panel port (still token-guarded). Pool configuration moved from `modules/proxypool/config.yaml` into the `[Pool]` section of `config/config.ini`, with old settings migrated automatically on first start (the old file is renamed `config.yaml.migrated`) — the panel and the config file now edit the same settings
|
||||
|
||||
- Upstream exit pool: instead of using one upstream proxy at a time, several exits are kept up at once (10 by default) and requests are spread across them by live load, each exit carrying its own concurrency quota and connection cap; every exit has its own lifetime — the two rules, `interval` in seconds (60 by default) and `request_interval` counted in assignments, are independent and both apply, and whichever comes first wins; a failing exit is retired by the failure share over its last 10 results rather than by a consecutive-failure count — a streak is wiped out by one chance success, whereas the share truthfully reflects how badly it has been failing recently; dead exits are replaced one by one, never as a wholesale swap, with standby candidates validated concurrently and used to fill the slots one at a time; with no usable exit at all, requests queue (30 seconds by default) before getting a 503; the panel's status cards list every exit with its remaining lifetime, and the old "manual switch" button became **Refresh exits**. The run mode is grouped by source — `local` uses `cycle` / `loadbalance` (which exit to pick), while `api` / `pool` use `continuous` (keep rotating) / `request` (rotate on demand, the shipped value — expired exits are removed outright while idle and the pool drains); switching sources normalises the mode to that group's default
|
||||
|
||||
- Elastic expansion of the exit pool: when listening-side concurrency hits the current capacity the exit count doubles on the spot and is filled in one go (capped at 2×), while the background adds exits one step at a time as a fallback, based on "the share of exits pinned at their per-exit quota"; after expansion stops, the surplus exits serve out their own lifetimes before being retired one by one, so nothing is dropped the moment it was fetched
|
||||
|
||||
- The pool's management console merged into the panel: the proxy list can be filtered and sorted by protocol, region, health score and anonymity, with batch validation / batch delete on a selection, favourites and import / export; the plugin list supports enable / disable, fetch-interval tuning, running at once and hot reload; the database can be backed up, restored and optimised, with long tasks appearing under Background tasks where they can be cancelled
|
||||
|
||||
- Pool proxies gained a quality profile: a 0-100 health score (delay 25%, success rate 25%, stability 15%, uptime 15%, anonymity 10% and longevity 10%; the success dimension uses the Wilson lower bound, so a new proxy is not dragged far down by one or two chance failures), an anonymity tier (Transparent / Anonymous / Elite), average delay and variance, and HTTPS-support probing; the health score drives sorting, filtering and automatic cleanup, the fetch API accepts `min_health_score` to return high-scoring proxies only, and the panel shows valid / invalid counts plus a hint when anonymity is a fallback value
|
||||
|
||||
- A usage-feedback loop: a bad exit found during real forwarding is confirmed by an independent probe and then reported back to the pool; once consecutive failures reach `use_feedback.invalid_threshold` (3 by default) the proxy is marked invalid and queued for the next automatic revalidation round, and a successful re-test restores it automatically; the feedback touches only "availability" and the check counter, never the health score — validation cannot tell "the proxy is broken" apart from "our own network hiccuped", and a chance failure should not irreversibly delete a good proxy
|
||||
|
||||
- Improved exit-IP and anonymity probing: a gateway-style exit is probed once for its real egress IP before being recorded (API source only, and each address costs one extra unit of proxied traffic); anonymity checking can be turned off (`check_anonymity`); this host's egress IP can be filled in by hand to remove that automatic outbound request entirely (`host_public_ip`, one entry per family on a dual-stack host); and plaintext echo endpoints the proxies can reach can be added (`plain_identity_echo_apis`), helping when anonymity keeps being judged as the fallback tier
|
||||
|
||||
- Per-plugin validation policy: each plugin can be set individually to take part in automatic revalidation or not, its revalidation interval, and whether to skip validation at ingest (meant for proxy sources billed per request whose validity the provider guarantees; skipped records have no exit IP, anonymity, delay or health score)
|
||||
|
||||
- Per-plugin test address: each plugin can specify its own address for liveness checks; leaving it empty inherits the global `test_url`, and the panel shows directly whether the global or a custom address is in use
|
||||
|
||||
- The pool's fetch API `/random` gained filters for health score, anonymity, favourite, HTTPS support and IP; the old `/best` "best recommendation" endpoint was removed and its capability folded into `/random`
|
||||
|
||||
- Batch validation tiers: **Check liveness** runs only the lightweight reachability test, **Full re-check** runs the complete quality evaluation; when no echo evidence is available for anonymity it falls back to Transparent and is explicitly marked as inferred, and selecting those rows allows a re-test
|
||||
|
||||
- Simplified import: the two switches "validate after import" and "look up geolocation automatically" were dropped; imported proxies are always validated one by one first and only confirmed-working ones are stored, keeping unvalidated records out of the pool; geolocation is looked up asynchronously after storing
|
||||
|
||||
- Scraper plugins slimmed down: six outdated plugins were removed — `代理获取1`, OpenProxy, ProxyScrape, PubProxy, Spys.me and free_proxy_list — leaving only GeoNode and GitHub
|
||||
|
||||
- Plugin scheduling fixes: a failed run still advances the next run time (previously a permanently failing plugin was picked up and retried by the scheduler round after round); a failed reload now removes the half-initialised module from `sys.modules`; plugin modules are registered under a `plugin.` prefix, so a plugin can no longer shadow a standard-library or main-program module of the same name
|
||||
|
||||
- Pool-source switching improved: no longer limited by the switch cooldown, and the redundant TCP pre-check is skipped (pool proxies have already been validated); a manual switch from the panel shows "Switching…" (after the new status cards arrived the button was renamed **Refresh exits**)
|
||||
|
||||
**Fixes**
|
||||
|
||||
- Fixed automatic revalidation failing for every single proxy: `auto_revalidator` used a constant it never imported, the `NameError` was swallowed by an `except` into a warning, and validation results never made it back into the pool; the same class of bug (`logger` undefined inside an exception branch) was fixed alongside it
|
||||
|
||||
- Fixed host egress-IP detection: IPv4 and IPv6 are each probed once (previously whichever endpoint answered first won, so the cached address family drifted and a transparent proxy was misjudged as anonymous); `validator.host_public_ip` now accepts up to two entries as well
|
||||
|
||||
- Fixed domain statistics: the switch, flush interval and retention now apply immediately after saving; clearing invalidates the batch currently being flushed, so it no longer "grows back after a clear"; retention cleanup no longer deletes combinations that are still being accessed
|
||||
|
||||
- Fixed tunnel failure attribution: direct (bypass-list) requests are no longer recorded as proxy failures; added an "upstream closed the connection without sending any data" abort reason, no longer lumped together with a client disconnecting deliberately
|
||||
|
||||
- Fixed ghost keys in the dedup cache: cleaning up invalid proxies now removes them from the dedup cache too, fixing "the plugin keeps scraping but dedup leaves 0 new proxies"; a failed batch write now degrades to per-row writes, discarding only the rows that really cannot be written (each named in the log), with an accurate count of what was discarded
|
||||
|
||||
- Database restore now writes to a temporary file and swaps it in atomically, clearing the old database's WAL sidecar files — a restore that fails halfway no longer leaves half a database behind, and a stale WAL can no longer bring deleted rows back
|
||||
|
||||
- Anonymity and HTTPS-support detection now verify the certificate: an untrusted third-party proxy can no longer forge echo responses to paint itself as elite / HTTPS-capable (both of these feed straight into the health score)
|
||||
|
||||
- Validation now confirms the test address is reachable directly first: with this machine offline or the target site blocked, a connection failure is no longer charged to the proxy
|
||||
|
||||
- Two pool hot-reload fixes: when the `[Pool]` section cannot be read the current configuration is kept and the next round retries, instead of silently applying a whole set of factory defaults; the pool log's file path, per-file size and keep-count now take effect immediately after a change (previously the handler was hung once and never re-hung, making the settings dead)
|
||||
|
||||
- Pool stop / restart protection: starting is refused while the previous stop has not finished (its thread is still alive), so two pool instances can no longer share one database; the exit path now decides on "is the thread alive" and retries after a failed stop, so the last batch of data is not lost
|
||||
|
||||
- API error feedback: invalid parameters now return 400 with a readable reason (most used to come back as a 500), and a full write queue returns 503 `queue_full`
|
||||
|
||||
- Static assets now use conditional caching: after changing CSS / JS a plain page refresh picks it up, no more `Ctrl+F5`
|
||||
|
||||
- `Ctrl+C` and termination signals now clean up connections and background threads and drain the logs and write queue before exiting, consistently across platforms (SIGINT only on Windows, with SIGTERM also registered elsewhere), with a hard 3-second timeout as a backstop
|
||||
|
||||
- Copy buttons gained a fallback: when the Clipboard API is unavailable they use a `textarea` + `execCommand`, so copying still works over plain HTTP (opening the panel by IP:port) and in older browsers
|
||||
|
||||
**Performance and Stability**
|
||||
|
||||
- Production startup now uses waitress instead of Flask's built-in development server (falling back automatically when it is not installed), for better concurrency and stability
|
||||
|
||||
- Listening connections are accepted by a dedicated thread: measured on Windows, the accept rate rose from about 825 to over 3000 connections per second, and a client disconnecting before accept completes no longer invalidates the listening socket
|
||||
|
||||
- Log file writes moved to an asynchronous queue (writing each line synchronously ate 4%-10% of short-request throughput; the in-memory ring and the console stay synchronous, so the panel's liveness is unchanged); frequent failure warnings are folded within a window and counted, so one sentence no longer floods the log under high concurrency (folding happens at the log layer only — an exit's failure accounting and scheduling are unaffected)
|
||||
|
||||
- The pool's write path was reworked: added an asynchronous SQLite connection pool (WAL, separate readers and writers) and an in-memory dedup cache for proxies (dedup lookups dropped from a full-table scan to O(1), and the defect of deduplicating by IP alone rather than ip:port was fixed); scraping now uses aiohttp; validation results and access records use "memory buffer + background batch flush", instead of every row fighting over SQLite
|
||||
|
||||
- Faster validation: a separate connection timeout (2 seconds by default, so black-hole proxies fail faster), a wall-clock budget for a whole validation batch (8 seconds by default), per-probe timeouts adapted to this machine's direct-connection latency, default concurrency raised to 400, and detection endpoints pre-screened by direct connection and raced by latency (usable endpoints are no longer truncated at 3)
|
||||
|
||||
- Stability fixes: transaction rollback in the connection pool now covers task cancellation (`CancelledError`), so a half-finished transaction is not committed by the next operation; draining the write queue has a 15-second cap and no longer drags the pool's stop past the host's timeout; chunked request-body reads have a timeout; and a client disconnecting while a success response is being written is no longer recorded as a proxy failure, nor does the client get two responses
|
||||
|
||||
- Scraping and validation sessions now carry a browser User-Agent, so public proxy-list sites (mostly behind Cloudflare) no longer reply 403 outright; a timeout caused by this machine's connection-pool queueing is no longer misread as "the proxy is slow", and queue congestion is logged (folded within a window)
|
||||
|
||||
- Windows event-loop shutdown noise suppressed: the spurious "after close complete" errors produced by a peer RST no longer flood error.log
|
||||
|
||||
**Deployment and Configuration**
|
||||
|
||||
- Configuration reworked: `config.ini` grew from 23 keys to 93 across four sections (`[Server]` / `[Users]` / `[api_credentials]` / `[Pool]`); the panel presents all of it grouped by purpose with tuning parameters collected under Advanced; configuration files from older versions can be used as they are
|
||||
|
||||
- Proxy validity checking split into two independent switches — "check at startup" and "check on use" — which can be turned off separately; the old `check_proxies` key is still honoured as a compatibility fallback for the startup check
|
||||
|
||||
- Docker deployment improvements: a slim base image running as a non-root user, with PUID / PGID aligned to the owner of the host-mounted directories (when the owner does not match, the pool cannot create its database and the panel cannot save configuration); the health check probes the actual `web_port` from `config.ini` (no more false unhealthy after a port change); a `stop_grace_period` of 60 seconds lets the application finish draining its write queue and flush threads before exiting; tzdata is installed so the `TZ` timezone setting really takes effect; and the build no longer forces a China-based pip mirror
|
||||
|
||||
- Deployment surface reduced: the default panel port moved from 5000 to 5001, the proxy pool no longer takes a second port, and a container exposes only 1080 (proxy listening) and 5001 (panel)
|
||||
|
||||
- Configuration read/write path: in web-panel mode a hand-edited `config.ini` is hot-reloaded by the second, so no restart is needed; `config.ini` and the three list files are written atomically (temporary file, then replace), so a reader never sees a half-written file
|
||||
|
||||
- Deprecated configuration keys are cleaned up automatically: at startup and on save, keys with no remaining consumer are removed — `check_proxies`, `getip_url`, `pool_autostart`, `pool_port`, `pool_proxy_url`, `pool_web_url` and `pool_proxy_count` in `[Server]`, as well as `validator.max_ip_retries`, `geoip.*` and `plugins.auto_validate_on_fetch` and the like in `[Pool]`
|
||||
|
||||
- Shipped defaults adjusted: run mode `request` (On request), exit lifetime 60 seconds, exit pool capacity 10, per-exit concurrency unlimited by default, validation concurrency 400, simultaneous-request cap 3000, 30 seconds of queueing when no exit is available, and lower check cooldowns and cache lifetimes; the proxy source is still `pool` out of the box
|
||||
|
||||
- The panel no longer shows parameters derived from other settings (connection-pool ceiling, idle recycling, max clients and the like; values raised in older configuration files still take effect for compatibility); newly added parameters — domain statistics, access records, log size / keep-count, tunnel timeout, use-time feedback and so on — are all written into `config.ini` with explanations, and the panel's option descriptions come from the same source as the `config.ini` comments
|
||||
|
||||
- The shipped `config.ini` has the maintainer's personal proxy API address and saved credentials cleared (`api_proxy_url`, `credential_sets` and `active_credential` left empty), so the released configuration no longer carries private keys
|
||||
|
||||
- Exported CSV carries a UTF-8 BOM, so Windows Excel opens it without mojibake; Windows console encoding issues no longer crash the program; configuration reading tolerates a BOM and falls back to the local encoding
|
||||
|
||||
- Added `API.md` in the repository root (2009 lines): a complete API reference for the panel's `/api/*` and the pool's `/api/pool/*` — parameters, response examples and error codes — for external programs to integrate against directly
|
||||
|
||||
### 2026/09/14
|
||||
|
||||
- Removed `[Pool] enabled`; the proxy pool now starts on demand: it comes up with the service **only when the proxy source is the pool and no remote pool address is configured**, so pulling proxies from a local file or an API no longer spends resources scraping and validating for nothing. Switching the source to the pool at runtime starts it automatically (otherwise you would get "pool mode with no pool"); switching back does not stop it, so a pool that is currently serving proxies is never shut down by mistake. The old switch's description said that turning it off made the pool source unselectable, but the code never had any such restriction; and since it only affected the next start, turning it off in the panel did nothing to the running pool — which made it look as though the switch had no effect. The key is cleared from older configuration files automatically on first start
|
||||
- Fixed plaintext HTTP forwarding (non-CONNECT), which had never worked: the `httpx` `socks` extra was missing from the dependencies, so with a socks5 upstream every request returned 502 and nothing was written to the logs
|
||||
- Fixed how a forwarded request body is detected: the client connection used to be treated as a body stream unconditionally, so a GET with no body left proxy and client waiting on each other until the read timed out; the read length now comes from `Content-Length` / `Transfer-Encoding`, and chunked bodies are de-chunked before being forwarded
|
||||
- Fixed the CLI startup batch check crashing the moment it found a valid proxy: `ProxyCat.py` was missing the `from itertools import cycle` import
|
||||
- Reworked logging: output is routed by purpose into `logs/main.log`, `logs/access.log`, `logs/proxy.log` and `logs/error.log`, each rotating on size (everything used to be mixed into `proxycat.log`, which had no rotation)
|
||||
- Added an access log: every proxy request records the time, the client (IP and authenticated account), the upstream proxy used, the target host, success or failure, the duration and the failure reason; upstream proxy passwords are masked as `***`
|
||||
- Added access statistics: success and failure counts, first and last access times and the latest failure reason are accumulated per "host × upstream proxy" and persisted to SQLite, so the history is still queryable after a restart
|
||||
- The statistics table aggregates by upstream proxy, and expanding a proxy shows the hosts it visited; the table now lives in the Access category of the Logs page instead of a separate statistics panel
|
||||
- Added category tabs, browsing of rotated historical log files, an auto-refresh pause toggle and the domain statistics table to the Logs page
|
||||
- Fixed the "Important" filter always coming back empty (IMPORTANT was equality-matched as if it were a log level name)
|
||||
- Fixed the log panel showing only the oldest 100 entries, hiding new logs
|
||||
- Fixed a panel injection risk caused by log messages not being escaped
|
||||
- Fixed dead proxies never being replaced: when an upstream accepted CONNECT and then neither sent data nor reported an error (playing dead, or reading the client's data and disconnecting outright), the old implementation never saw a failure signal — and with nothing triggering proxy failure handling, the health check never ran, so that proxy stayed in rotation and kept being picked, showing up as "every request stalls on the same bad proxy". The check now reads "the tunnel is established, the client has sent data, and the upstream has not returned a single byte": on timeout the tunnel is closed at once and the failure is reported
|
||||
- Added `[Server] tunnel_idle_timeout` (default 10 seconds): once a tunnel is established and the client has sent data, this is how long the upstream may go without sending anything back before the tunnel is declared dead. `0` disables the timeout
|
||||
- Fixed the success criterion for tunnel requests (CONNECT / SOCKS5): previously writing back `200 Connection Established` counted as success, and since upstreams that accept CONNECT but never send data are common, this produced the contradiction of "the log says success while curl reports SSL/TLS handshake failed". Success now requires data actually coming back to the client; otherwise the result is failure or aborted depending on whether the client sent any data
|
||||
- Added an `[ABORT]` outcome to the access log: a client that connects, sends nothing and disconnects (liveness probes, port scans) counts as neither success nor failure, and is excluded from both the success and failure rates
|
||||
- Fixed log level badges being squeezed until the text was clipped in a narrow window (`min-width: 0` removed the flex item's protection against shrinking below its content)
|
||||
- Fixed the Logs tab rendering outside its card container (a stray `</div>` at the end of the Access Control tab closed `.tab-wrap` early)
|
||||
- Fixed log files developing NUL holes after the logs were cleared (the file is now truncated through the handler's own stream)
|
||||
- Fixed `[Server] log_level` being a dead setting: it could be validated and saved but had no effect at runtime; it now really controls log output and takes effect immediately when saved from the panel
|
||||
|
||||
### 2025/03/23
|
||||
|
||||
- Fixed a bug that made load balancing mode unusable
|
||||
- Fixed SOCKS5 connection errors
|
||||
- Fixed rotation not triggering normally under the HTTP and SOCKS5 listeners, where an error at the target site looked exactly like a dead proxy address
|
||||
- Made proxy validation a configurable check: with it off no validity check runs, so special cases no longer cause constant rotation
|
||||
- Fixed concurrency causing mass replacements and warnings; the operation is now locked to keep it atomic
|
||||
- Fixed a large number of small logic errors and wording mistakes
|
||||
- Proxy rotation now triggers on: the time interval elapsing, a proxy going dead (automatic switch), a manual switch from the Web panel, or automatic fetching on the first request in API mode
|
||||
|
||||
### 2025/03/17
|
||||
|
||||
- Fixed faulty logic that rotated the proxy when the target site itself was erroring
|
||||
- Changed how connections are closed
|
||||
- Improved the performance of the listening server
|
||||
- Fixed several bugs
|
||||
|
||||
### 2025/03/14
|
||||
|
||||
- Fixed the `_last_used` error and corrected how connections are closed
|
||||
- Fixed rotation logic failing when reading local proxies
|
||||
- Setting the rotation interval to 0 now switches IP on every request
|
||||
|
||||
### 2025/03/03
|
||||
|
||||
- Polished the Web management interface
|
||||
- Fixed a large number of bugs
|
||||
- Added more helper scripts
|
||||
|
||||
### 2025/02/21
|
||||
|
||||
- Added the Web management interface
|
||||
|
||||
- Added multi-user mode
|
||||
|
||||
- Major overhaul of the code structure
|
||||
|
||||
- `config.ini` and related files now update dynamically without a restart
|
||||
|
||||
- Added control over the log display level
|
||||
- Added logging of connecting clients, including their IP and the account and password used
|
||||
- And a pile of other changes besides — this release was so big that I have honestly forgotten some of them
|
||||
|
||||
### 2025/02/06
|
||||
|
||||
- Docker now installs dependencies from a China-based package mirror
|
||||
- Added an upfront notice in getip mode: you are in API mode, and a proxy address will be fetched automatically when a request comes in
|
||||
- Reworked whitelisting to add entries automatically based on the request result
|
||||
|
||||
### 2025/01/14
|
||||
|
||||
- Added an automatic whitelisting mechanism for getip mode.
|
||||
- Proxy addresses with a username and password are now supported for local reads, getip fetches and validity checks.
|
||||
- Tidied up the code structure: merged some code and removed some leftovers.
|
||||
|
||||
### 2025/01/07
|
||||
|
||||
- Introduced a connection pool to improve performance.
|
||||
- Improved some error handling and logging.
|
||||
- Optimised the proxy rotation mechanism.
|
||||
|
||||
### 2025/01/03
|
||||
|
||||
- Centralised the configuration parameters in the config file for easier maintenance.
|
||||
- Fixed several known bugs and improved stability and concurrency.
|
||||
|
||||
### 2025/01/02
|
||||
|
||||
- Reworked the software structure to be cleaner and easier to use.
|
||||
- Added blacklist/whitelist-based authentication.
|
||||
- In GetIP mode, a proxy is fetched only after the first request arrives, so a plain start no longer wastes money.
|
||||
- Changed how the language is configured: no more separate builds — it now comes from a parameter in `config.ini`.
|
||||
- Updated the configuration info panel so the address can be copied and used directly even with no username or password set.
|
||||
- Added Docker deployment.
|
||||
|
||||
### **2024/10/23**
|
||||
|
||||
- Reworked the code structure, splitting parts of the code into separate files.
|
||||
- If the proxy server suddenly goes dead while traffic is flowing, a new one is requested and swapped in automatically, and the rotation timer is reset.
|
||||
|
||||
### 2024/09/29
|
||||
|
||||
- Removed the rarely used single-cycle mode in favour of a custom mode, letting the rotation logic be tailored to your needs.
|
||||
- Made proxy validity checks asynchronous for speed.
|
||||
- Dropped SOCKS4 proxy support, which caused the most problems.
|
||||
- Beautified the logging system.
|
||||
- Improved the exception-handling logic.
|
||||
- Added validation of the proxy address format.
|
||||
|
||||
### 2024/09/10
|
||||
|
||||
- Improved concurrency: the next request can be issued before a response has been received, raising throughput.
|
||||
- Added load balancing mode, which sends requests to random proxy addresses concurrently to improve throughput.
|
||||
- Made the proxy validity check asynchronous for efficiency.
|
||||
|
||||
### 2024/09/09
|
||||
|
||||
- Added an option to validate the proxy addresses in `ip.txt` on first start and use only the valid ones.
|
||||
- Downgraded some functions to support older Python versions.
|
||||
|
||||
### 2024/09/03
|
||||
|
||||
- Added a local SOCKS5 listener to accommodate more software.
|
||||
- Swapped out some functions to support older Python versions.
|
||||
- Beautified the console output.
|
||||
|
||||
### 2024/08/31
|
||||
|
||||
- Major restructuring of the project.
|
||||
- Beautified the output, which now keeps showing the time until the next proxy rotation.
|
||||
- `Ctrl+C` now stops the program.
|
||||
- Moved heavily to asynchronous requests, raising concurrency: measured at **1000** concurrent connections with **5000** packets in total, about **50** packets were lost — roughly **99%** stable — while **500** concurrent connections lost none.
|
||||
- Dropped runtime command-line parameters in favour of reading a local `ini` config file, which is far easier to use.
|
||||
- Added local no-authentication mode, accommodating more software's proxy setups.
|
||||
- Added a version check that reports version information automatically.
|
||||
- Added identity authentication for proxy addresses, local reads only; most APIs need whitelisting anyway, so the same was not provided for them.
|
||||
- Added an option to refresh via `getip` only when a new request arrives, reducing IP consumption.
|
||||
- Added automatic detection of the proxy protocol, to accommodate more providers.
|
||||
- Added HTTPS and SOCKS4 proxy support; HTTP, HTTPS, SOCKS5 and SOCKS4 are now all covered.
|
||||
- Replaced `asyncio.timeout()` with `asyncio.wait_for()` for compatibility with older Python versions.
|
||||
|
||||
### 2024/08/25
|
||||
|
||||
- Blank lines in `ip.txt` are now skipped automatically.
|
||||
- Replaced `httpx` with a concurrency pool for better performance.
|
||||
- Added a buffer dictionary to cut latency for repeat requests to the same site.
|
||||
- The change-IP-per-request logic now picks a proxy at random.
|
||||
- Used more efficient structures and algorithms to optimise request handling.
|
||||
|
||||
### 2024/08/24
|
||||
|
||||
- Adopted an asynchronous design to raise concurrency and reduce timeouts.
|
||||
- Wrapped duplicated code for reuse.
|
||||
|
||||
### 2024/08/23
|
||||
|
||||
- Changed the concurrency logic.
|
||||
- Added identity authentication.
|
||||
- Added an IP retrieval API for permanent IP changes.
|
||||
- Added change-IP-on-every-request.
|
||||
@@ -1,3 +1,118 @@
|
||||
[返回 README](../README.md)
|
||||
|
||||
### 2026/10/05(ProxyCat V3.0.0)
|
||||
|
||||
**新增功能**
|
||||
|
||||
- 上游取代理的接口凭据改由面板管理:可保存、删除多套命名凭据(接口地址 + 账号密码),随时切换当前启用的那一套;切换后立即写入配置并对运行中的服务生效,换代理商接口不用再手改 `config.ini`(凭据存于 `[api_credentials]` 段)
|
||||
- 新增「代理绕过白名单」:命中的目标域名/IP 直接由本机发出、不经过上游代理(HTTP 与 SOCKS5 都生效),支持 `*.baidu.com`、`192.168.1.*` 这类通配符;名单存 `config/bypass_whitelist.txt`,面板「访问控制」页可直接编辑
|
||||
- 「运行日志 → 访问」新增「逐条明细」视图:每次代理请求可按时间范围与条件筛选、分页、导出 CSV/JSON(CSV 带 BOM,Windows 版 Excel 打开不乱码)、清空,记录落 SQLite 重启不丢;保留天数(默认 7 天)、行数上限(默认 20 万)与内存缓冲可配置,热路径只写内存、后台每 5 秒批量落库,与按代理汇总的访问统计互不影响
|
||||
- 归属地解析改为本地离线库:GeoLite2(全球,ISO 代码 + 中英名)与纯真库(中国省市 + 运营商),不再调用在线归属地接口;面板「代理池 → 数据库 → 归属地库」显示数据文件清单与待重算条数,可「更新归属地库」(约 104MB,按需下载)与「重算归属地」;库不可用时归属地为空,不回落在线查询
|
||||
- 归属地双语:中英两套名称按界面语言显示,另一套进 tooltip,按地区排序也跟随界面语言;译名缺失时标注「原文」并可重算
|
||||
- 新增命令面板与全局快捷键:`Ctrl/⌘+K` 打开可搜索的命令面板(视图跳转、启停服务、刷新出口、批量校验、导入导出、切主题切语言等);`1–4` 切视图、`r` 刷新、`/` 聚焦搜索、`?` 快捷键说明;表格里 `↑/↓` 漫游、空格选中、`Shift` 扩选,输入态自动让路
|
||||
- 批量任务进度与取消:导入、批量验证、插件抓取等长任务进入「后台任务」,按当前阶段(初筛端口连通性 / 验证代理)显示进度、速率、预计剩余时间与分类计数(入库/不可用/初筛淘汰/失败),运行中的任务可一键取消(状态显示「已取消」)
|
||||
- 新增版本检查模块:启动时查一次、之后每 24 小时一次,检查时间落盘,24 小时内重启不会重复检查(对外最多 24 小时发一次请求);面板的版本接口只读缓存结果,打开面板不会因等网络而卡住;检查失败保留上一次成功拿到的版本
|
||||
- 面板新增推广/公告位:把 JSON(标题、正文、图片、链接、停留时长)放进 `config/ads/` 即可展示,用户可一键关闭,也能从侧栏重新打开(关闭状态不落盘,重启后恢复显示)
|
||||
|
||||
**界面与交互**
|
||||
|
||||
- 面板从横排页签改为左侧固定侧边栏的四个区:代理配置、代理池、访问控制、运行日志;代理池下再分「代理管理 / 池设置 / 数据库维护」三个子视图;服务与池的启停状态常驻侧栏,中英文文案一并补齐
|
||||
- 代理配置页重排为分组卡片(来源、监听端口、出口轮换、可用性检测、性能与并发、日志与记录、高级),每项「短标题 + 一行提示 + ⓘ 全文」;数值输入的上下限随服务端 schema 下发;代理认证并为一行「用户名:密码」,填了用户名却没有冒号时会明确提示这样不会启用认证
|
||||
- 参数呈现与保存栏:计量单位移到输入框右侧、标题只留短名;保存栏未改动时置灰、新增「放弃修改」、干净态显示「已保存」回执、吸底时带阴影;切语言即时刷新表单
|
||||
- 新增深色主题:浅色/深色两档,未手动切换过时跟随系统深色偏好,切换后记住选择;原生下拉与滚动条随主题渲染(`color-scheme`),深色用户打开页面不再先闪一帧白底;配色按对比度标准重做,深色下正文、按钮与彩色文字全部达标
|
||||
- 日志与访问页增强:日志分类标签带数量角标、行尾悬停复制、清空搜索;访问明细的状态码按 2xx/3xx/4xx/5xx 着色;行详情抽屉按「连接 / 质量 / 时间」分段并本地化时间戳;表格右键菜单可复制地址、复制为 curl 命令
|
||||
- 失效代理可见性:失效行整行压淡并挂「失效」徽章与原因提示;池概览的失效卡按实际数量着色(失效数为 0 时不再顶着红字)
|
||||
- 出口 IP 与入口不同(代理中间还转了一手)时,列表里以警示色轻提示;IPv6 的多种合法写法不会被误判为不同
|
||||
- 表格列宽可拖拽调整并记住(下次打开保持),另加「重置列宽」按钮
|
||||
- 导入导出交互:导出弹窗三种格式选项卡(txt/json/csv)并显示将导出条数;导入框实时计数
|
||||
- 控件与样式统一:删除/批量等危险操作改用自绘确认框(替换浏览器原生 `confirm`)、Esc 优先关闭已打开的浮层;下拉框自绘箭头、深色下弹层不再白底黑字、拨动开关加大;通知提示加图标并可点击关闭;页头常驻的版本号改为取接口真实值
|
||||
- 面板移除 Bootstrap 与外部 JS 脚本,改用自绘样式与系统字体栈(中文回退补齐)
|
||||
- 池未运行时面板不再空刷池接口,加载失败会给提示而不是显示成「没有数据」
|
||||
- 国际化补全:池设置表单切语言即时刷新、配置校验错误用界面语言返回、设置项说明中英双语(`config.ini` 内固定中文)、动态内容随语言重渲染
|
||||
|
||||
**代理池**
|
||||
|
||||
- 内置代理池从「随包附带、单独启动的程序」变成 ProxyCat 进程内的子系统:不再占 8000 端口、不再由面板拉起子进程,与面板共用同一个 Web 端口,管理入口从「打开池自己的页面」换成面板的「代理池」页签;池的取用与管理接口统一挂到面板端口下的 `/api/pool/*`(仍受 token 保护)。池配置从 `modules/proxypool/config.yaml` 迁进 `config/config.ini` 的 `[Pool]` 段,首次启动自动搬运旧设置(原文件改名 `config.yaml.migrated`),面板与配置文件改的是同一份设置
|
||||
- 上游出口池:不再一次只用一个上游代理,而是同时维持多个出口(默认 10 个),按实时负载把请求摊到各出口,每个出口有独立并发额度与连接上限;每个出口有自己的寿命——按秒的 `interval`(默认 60 秒)与按分配次数的 `request_interval` 两条规则各自独立、同时生效,谁先到谁换;出口故障按最近 10 次结果的失败占比淘汰而不是连续失败次数——连续计数会被一次偶发成功清零,占比则如实反映最近一段时间的失败水平;失效的出口单独补上、不整批替换,候补并发校验后逐槽填满;一个可用出口都没有时请求排队等待(默认 30 秒)再回 503;面板状态卡逐个列出出口及其剩余寿命,原「手动切换」按钮改为「刷新出口」。运行模式按来源分组——`local` 用 `cycle`/`loadbalance`(挑选出口),`api`/`pool` 用 `continuous`(持续更换)/`request`(触发更换,出厂值;闲时到期的出口直接移除、池子会空),换来源时自动归一到该组默认值
|
||||
- 出口池弹性扩容:监听端并发顶到当前容量时立刻把出口数翻倍并一次补齐(上限 2 倍),后台再按「顶到单出口额度的出口占比」兜底逐格加;停止扩容后多出来的出口用满自己的寿命才逐个撤下,不会刚取回来就丢
|
||||
- 代理池管理台并入面板:代理列表可按协议、归属地、健康分、匿名度筛选与排序,勾选批量验证/批量删除、收藏、导入导出;插件列表可启停、调抓取周期、立即执行、热重载;数据库可备份、恢复、优化,长任务进入「后台任务」并支持取消
|
||||
- 池里的代理升级出质量画像:0-100 健康分(延迟 25%、成功率 25%、稳定性 15%、可用时长 15%、匿名度 10%、存活时长 10% 加权;成功率取 Wilson 置信下界,新代理不会因一两次偶然失败被大幅拉低)、匿名度等级(透明/匿名/高匿)、平均延迟与波动、HTTPS 支持探测;健康分用于排序、筛选与自动清理,取用接口可带 `min_health_score` 只要高分代理,面板展示有效/失效数与匿名度兜底提示
|
||||
- 使用反馈闭环:运行期真实转发发现的坏出口,经独立探测确认后回报给池,池内连续失败达到 `use_feedback.invalid_threshold`(默认 3 次)就标记失效、列入下一轮自动重验证,测通后自动恢复;反馈只改「是否可用」与检查计数、不碰健康分——检测分不清「代理坏了」和「本机网络抖动」,不该让偶发失败不可逆地删掉一个好代理
|
||||
- 出口 IP 与匿名度的探测增强:网关型出口经它探测一次真实出口 IP 再记账(仅 API 来源,每个地址多消耗一次代理流量);匿名度可关(`check_anonymity`);本机出口 IP 可手填以完全免掉自动探测那次对外请求(`host_public_ip`,双栈可各填一条);可补充代理够得到的明文回显接口(`plain_identity_echo_apis`),改善匿名度总被判成兜底档的情况
|
||||
- 插件验证策略:每个插件可单独设置是否参与自动重验证、重验证周期,以及入库时是否跳过验证(面向按请求计费、供应商保证有效性的代理源;跳过的记录没有出口 IP、匿名度、延迟与健康分)
|
||||
- 插件测试地址:每个插件可单独指定存活检测用的地址,留空时继承全局 `test_url`,面板上直接显示当前用的是「全局」还是「已指定」
|
||||
- 池取用接口 `/random` 新增按健康分、匿名度、是否收藏、是否支持 HTTPS 与 IP 筛选;原 `/best`「最优推荐」接口移除,能力并入 `/random`
|
||||
- 批量验证分档:「验证有效性」只做轻量存活检测,「验证全部内容」做完整质量评估;匿名度拿不到回显证据时按透明兜底并明确标注为「推算的」,勾选后可重测
|
||||
- 导入流程简化:去掉「导入后自动验证」「自动查询归属地」两个开关,导入的代理一律先逐个验证、只有确认可用的才入库,避免池里混进未验证记录;归属地在入库后自动异步查询
|
||||
- 抓取插件瘦身:移除「代理获取1」、OpenProxy、ProxyScrape、PubProxy、Spys.me、free_proxy_list 六个过时插件,只保留 GeoNode 与 GitHub
|
||||
- 插件调度修复:执行失败也推进下次运行时间(此前永久失败的插件会被调度器每轮反复抓起来重试);重载失败时把半初始化的模块从 `sys.modules` 摘掉;插件模块改用 `plugin.` 前缀注册,避免插件顶掉同名的标准库或主程序模块
|
||||
- 池来源的切换优化:不再受切换冷却限制、跳过冗余的 TCP 预检(池内代理已经过验证),面板手动切换时会显示「切换中…」(新状态卡上线后该按钮改称「刷新出口」)
|
||||
|
||||
**修复**
|
||||
|
||||
- 修复自动重验证对每一条代理都失败:`auto_revalidator` 用了未导入的常量,`NameError` 被 `except` 吞成一条警告,验证结果永远写不回池子;同类问题(异常分支里的 `logger` 未定义)一并修掉
|
||||
- 修复主机出口 IP 判定:IPv4 与 IPv6 各探一次(此前取第一个应答的端点,缓存下的地址族会漂移,导致透明代理被误判成匿名);`validator.host_public_ip` 也支持最多填两条
|
||||
- 修复域名统计:开关/落库间隔/保留期保存后即时生效;清空之后正在落库的那批增量会被作废,不会「清了又长回来」;保留期清理不再误删仍在被访问的组合
|
||||
- 修复隧道失败归因:直连(绕过代理)请求不再被记成代理的故障;新增「上游未发送任何数据即关闭连接」这一中断原因,不再与客户端主动断开混为一谈
|
||||
- 修复去重缓存幽灵键:清理失效代理时同步移除去重缓存,修掉「插件一直在抓、去重后 0 个新代理」;批量写库整批失败改为逐条降级,只丢弃真正写不进去的行并逐条记名,丢弃条数有准确计数
|
||||
- 数据库恢复改为「先写临时文件再原子替换」并清掉旧库的 WAL 边车文件,避免恢复中途失败留下半个库、或旧 WAL 把已删除的行带回来
|
||||
- 匿名度与 HTTPS 能力检测改为校验证书:不可信的第三方代理无法再靠伪造回显把自己打成高匿/支持 HTTPS(这两项直接参与健康分)
|
||||
- 验证前先确认测试地址直连可达:本机断网或目标站被墙时不再把连不上算成代理失败
|
||||
- 池配置热更新两处修复:`[Pool]` 段读不出来时保持当前配置、下轮再试,不再静默套用一整套出厂默认值;池日志的文件路径、单文件大小、保留份数改完即时生效(此前挂过一次 handler 就永远不重挂,是死配置)
|
||||
- 池停止与重启保护:上一次停止未完成(线程仍活)时拒绝再次启动,避免两个池实例共用同一个数据库;退出路径的判据改用「线程是否存活」,停止失败后仍会重试、最后一批数据不会丢
|
||||
- API 错误反馈:参数不合法返回 400 并带可读原因(此前多数报 500),写队列满返回 503 `queue_full`
|
||||
- 静态资源改为协商缓存:改完 CSS/JS 刷新页面即生效,不用再 `Ctrl+F5`
|
||||
- `Ctrl+C` 与终止信号会先清理连接与后台线程、排空日志与写队列再退出,跨平台行为一致(Windows 仅 SIGINT,非 Windows 另注册 SIGTERM),并留 3 秒硬超时兜底
|
||||
- 复制按钮增加回退方案:Clipboard API 不可用时改用 `textarea` + `execCommand`,非 HTTPS(按 IP:端口 访问面板)与旧浏览器下也能复制
|
||||
|
||||
**性能与稳定性**
|
||||
|
||||
- 生产启动改用 waitress 替代 Flask 内置开发服务器(未安装时自动回退),并发与稳定性更好
|
||||
- 监听连接改由独立线程 accept:Windows 实测接受速率从约 825 连接/秒提升到 3000+ 连接/秒,客户端在 accept 完成前断开也不再导致监听套接字失效
|
||||
- 日志文件写入改为异步队列(每条日志的同步写会吃掉短请求 4%–10% 的吞吐;内存环与控制台仍同步,面板实时性不变);高频故障告警按窗口折叠加计数,高并发下同一句话不再刷屏(折叠只发生在日志层,出口的失败记账与调度不受影响)
|
||||
- 池的写入链路重构:新增 SQLite 异步连接池(WAL、读写分离)与代理去重内存缓存(去重查询从全表扫描降为 O(1),并修正只按 IP、不按 ip:port 去重的缺陷);抓取请求改用 aiohttp;验证结果与访问记录改为「内存缓冲 + 后台批量落库」,不再逐条争抢 SQLite
|
||||
- 验证提速:连接超时单独设(默认 2 秒,黑洞代理更快失败)、整波验证用墙钟预算(默认 8 秒)、按本机直连耗时自适应单次超时、并发默认提到 400、检测端点直连预筛并按延迟竞速(可用端点不再按 3 个截断)
|
||||
- 稳定性修复:连接池事务回滚覆盖任务取消(CancelledError),避免半截事务被下一个操作提交;写队列排空加 15 秒上限,不再把池的停止拖过宿主超时;请求体分块读取加超时;写出成功响应时客户端断开不再记成代理故障、也不会给客户端发两份响应
|
||||
- 抓取与验证会话带浏览器 User-Agent,公共代理列表站点(多在 Cloudflare 后)不再直接 403;本机连接池排队超时不再被误判成「代理慢」,排队拥堵有日志(按窗口折叠)
|
||||
- Windows 事件循环关闭噪声抑制:对端 RST 产生的「关闭已完成之后」的假错误不再刷 error.log
|
||||
|
||||
**部署与配置**
|
||||
|
||||
- 配置体系重构:`config.ini` 从 23 个键扩到 93 个键、四个配置段(`[Server]` / `[Users]` / `[api_credentials]` / `[Pool]`),面板按用途分组呈现全部配置,调优参数收进「高级选项」;旧版本配置可直接沿用
|
||||
- 代理有效性检测拆成两个独立开关:「启动时批量检测」与「使用时校验」,可分别关闭;旧的 `check_proxies` 键仍作为启动检测的兼容回退
|
||||
- Docker 部署增强:改用 slim 基础镜像并以非 root 用户运行,PUID/PGID 与宿主挂载目录属主对齐(属主不匹配时池建不了库、面板存不了配置);健康检查按 `config.ini` 里实际的 `web_port` 探测(改过端口不再误判 unhealthy);`stop_grace_period` 60 秒保证应用把写队列与落库线程收尾完再退;另装 tzdata 让 TZ 时区设置真正生效;构建不再强制国内 pip 镜像
|
||||
- 部署面收敛:默认面板端口从 5000 改为 5001,代理池不再占第二个端口,一个容器只暴露 1080(代理监听)与 5001(面板)
|
||||
- 配置读写链路:Web 面板模式下手改 `config.ini` 也会被按秒热重载,改完不用重启;`config.ini` 与三份名单文件改为原子写(写临时文件再替换),读者不会再读到写了一半的内容
|
||||
- 废弃配置键自动清理:启动/保存时清除 `[Server]` 的 `check_proxies`、`getip_url`、`pool_autostart`、`pool_port`、`pool_proxy_url`、`pool_web_url`、`pool_proxy_count`,以及 `[Pool]` 的 `validator.max_ip_retries`、`geoip.*`、`plugins.auto_validate_on_fetch` 等已无消费方的键
|
||||
- 新装默认值调整:运行模式默认 `request`(触发更换)、出口寿命 60 秒、出口池容量 10、单出口并发默认不限、并发验证 400、同时处理请求上限 3000、无可用出口排队 30 秒,检测冷却与缓存时间下调;代理来源出厂仍为 `pool`
|
||||
- 面板不再展示可由其它项推算的参数(连接池上限、空闲回收、最大客户端数等,旧配置里调大过的值仍兼容生效);新增的域名统计、访问记录、日志大小/份数、隧道超时、使用期反馈等参数全部写进 `config.ini` 并附说明,设置项说明与 `config.ini` 注释同源
|
||||
- 交付的 `config.ini` 清掉维护者个人的 API 取代理地址与已保存凭据(`api_proxy_url`、`credential_sets`、`active_credential` 置空),发布配置不再携带私有 key
|
||||
- 导出 CSV 带 UTF-8 BOM,Windows 版 Excel 直接打开不乱码;Windows 控制台编码问题不再让程序崩溃;配置读取容忍 BOM 与本地编码回退
|
||||
- 新增根目录 `API.md`(2009 行):把面板 `/api/*` 与代理池 `/api/pool/*` 的参数、响应示例与错误码整理成完整接口文档,供外部程序直接对接
|
||||
|
||||
### 2026/09/14
|
||||
|
||||
- 移除 `[Pool] enabled`,代理池改为按需启动:**代理来源为「维护池」且未配置远程池地址时**才随服务启动,用本地文件或 API 取代理时不再白耗资源去抓取和验证。运行期把来源切成「维护池」时会自动把池拉起来(否则会得到一个「没有池的池模式」);反向不自动停,避免误停正在提供代理的池。原开关的说明写着「关闭后代理来源无法选择维护池」,但代码里从来没有这条限制;它又只影响下次启动、在面板里改完当前运行中的池不会停,看起来就像没生效。旧配置里的这个键会在首次启动时自动清除
|
||||
- 修复明文 HTTP(非 CONNECT)转发一直不可用:依赖缺 `httpx` 的 `socks` extra,上游为 socks5 时请求一律 502,且不留任何日志
|
||||
- 修复转发请求体时的判定:此前无条件把客户端连接当作请求体流,没有请求体的 GET 会让代理与客户端互等到超时;现在按 `Content-Length` / `Transfer-Encoding` 决定读取长度,分块请求体会先解块再转发
|
||||
- 修复命令行的启动批量检测在检测出有效代理时崩溃:`ProxyCat.py` 缺少 `from itertools import cycle` 导入
|
||||
- 重构日志系统:日志按用途分流到 `logs/main.log`、`logs/access.log`、`logs/proxy.log`、`logs/error.log`,各自按大小轮转(原先全部混写在无轮转的 `proxycat.log`)
|
||||
- 新增访问日志:记录每个代理请求的时间、客户端(IP 与认证账号)、使用的上游代理地址、目标域名、成功或失败、耗时与失败原因;上游代理的密码以 `***` 脱敏
|
||||
- 新增访问统计:按「域名 × 上游代理」累计成功/失败次数、首末访问时间与最后失败原因,落 SQLite 持久化,重启后仍可查历史
|
||||
- 访问统计表按上游代理汇总,展开某个代理即可看到它访问过的域名明细;表格并入「运行日志」页的「访问」类别,不再单列一块统计面板
|
||||
- 新增「运行日志」页的分类标签、历史轮转文件浏览、自动刷新暂停开关与域名统计表
|
||||
- 修复「重要」筛选恒为空的错误(IMPORTANT 此前被当作日志级别名做等值匹配)
|
||||
- 修复日志面板只显示最旧 100 条、看不到新日志的错误
|
||||
- 修复日志消息未转义导致的面板注入风险
|
||||
- 修复坏代理不会被换掉的问题:上游收下 CONNECT 后既不回数据也不报错(装死,或读走客户端数据后直接断开)时,旧实现等不到任何失败信号 —— 不触发代理故障处理就永远不会做健康检查,于是这个代理一直留在轮换里被反复选中,表现为「每次都卡在同一个坏代理上」。现在按「隧道已建立、客户端发过数据、上游一个字节都没回」判定,超时立即收掉隧道并上报故障
|
||||
- 新增 `[Server] tunnel_idle_timeout`(默认 10 秒):隧道建立且客户端发过数据后,上游多久不回数据就判定这条隧道已死。0 表示不判超时
|
||||
- 修复隧道类请求(CONNECT / SOCKS5)的成功判据:此前「写回 200 Connection Established」就算成功,而上游收下 CONNECT 却不回数据的代理很常见,于是出现「日志说成功、curl 报 SSL/TLS handshake failed」的矛盾。现在要等上游确实有数据回到客户端才算成功,否则按「客户端是否发过数据」判为失败或中断
|
||||
- 访问日志新增「[中断]」结果:客户端连上却没发数据就断开(探活、端口扫描)既不算成功也不算失败,不计入成功率与失败率
|
||||
- 修复 Web 面板在窄窗口下日志级别标签被压到裁字的问题(`min-width: 0` 取消了 flex 项不得窄于内容的保护)
|
||||
- 修复运行日志页签渲染在卡片容器之外的问题(「访问控制」页签末尾多了一个 `</div>`,提前关闭了 `.tab-wrap`)
|
||||
- 修复清除日志后日志文件产生 NUL 空洞的错误(改用 handler 自身的流截断)
|
||||
- 修复 `[Server] log_level` 为死配置的错误:此前可校验可保存但运行期不生效,现真实作用于日志输出并支持面板保存后即时生效
|
||||
|
||||
### 2025/03/23
|
||||
|
||||
- 修复负载均衡模式无法调用的BUG
|
||||
@@ -8,7 +123,7 @@
|
||||
- 修复大量细节逻辑、描述错误
|
||||
- 当前代理切换触发条件为:时间间隔到期切换、代理失效自动切换、Web手动切换、API下首次请求自动获取
|
||||
|
||||
### 2025/03/17
|
||||
### 2025/03/17
|
||||
|
||||
- 修复目标站点本身错误时会触发代理切换的错误逻辑
|
||||
- 修改连接关闭方式
|
||||
@@ -35,7 +150,7 @@
|
||||
|
||||
- 代码结构大改
|
||||
|
||||
- config.in及相关文件动态更新不需要重启
|
||||
- config.ini及相关文件动态更新不需要重启
|
||||
|
||||
- 增加日志显示级别控制
|
||||
- 增加记录连接人信息日志,包括连接的IP和使用的账号密码
|
||||
@@ -50,7 +165,7 @@
|
||||
### 2025/01/14
|
||||
|
||||
- 增加 getip 方式下自动添加白名单机制。
|
||||
- 支持带有账号密码的本地读取、getip获取、有效性校验的代理地址。、
|
||||
- 支持带有账号密码的本地读取、getip获取、有效性校验的代理地址。
|
||||
- 整理代码结构,合并一些代码、删除一些多余代码。
|
||||
|
||||
### 2025/01/07
|
||||
|
||||
@@ -1,96 +1,178 @@
|
||||
from wsgiref import headers
|
||||
from modules.modules import ColoredFormatter, load_config, DEFAULT_CONFIG, check_proxies, check_for_updates, get_message, load_ip_list, print_banner, logos
|
||||
import threading, argparse, logging, asyncio, time, socket, signal, sys, os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from modules.proxyserver import AsyncProxyServer
|
||||
"""
|
||||
模块名称:ProxyCat
|
||||
功能描述:代理轮换服务的命令行入口,解析 -c/--config 配置,装配日志与信号处理,创建池服务与出口代理服务并管理其启停,常驻热重载配置与代理文件并刷新控制台状态。
|
||||
职责边界:负责:命令行参数解析、日志与信号装配、池与出口服务的创建启停、热重载与状态显示;
|
||||
不负责:代理切换与转发(见 modules/proxyserver.py)、池抓取与验证(见 proxypool 包);
|
||||
配置项定义与校验由 modules.modules、modules.config_validation 与 pool_config_ini 负责。
|
||||
关键依赖:modules.modules、modules.logging_setup、modules.proxyserver、modules.proxypool_service、
|
||||
modules.pool_config_ini、modules.version_check;第三方 colorama、tqdm。
|
||||
已知限制:
|
||||
1. 热重载的代理文件监控与启动预检都只覆盖 local 来源,API 与池模式只能靠运行期反馈判断代理可用性。
|
||||
2. 控制台日志级别固定为 WARNING;关键状态与版本检查结果直接写终端(print / tqdm.write),不依赖日志流展示。
|
||||
3. 端口被占用时进程以退出码 1 退出;运行期改 port 只记日志、不重绑监听,热重载时该变更在 main 与 proxy 两个日志类别各记一条,换端口须重启进程。
|
||||
4. 倒计时进度条只在按时间切换模式下绘制,其余模式仅在出口名册变化时打印一次出口清单。
|
||||
5. 状态监控线程为守护线程,单轮异常被捕获后循环继续,线程卡死时无外部机制能发现。
|
||||
6. SIGTERM 只在非 Windows 平台注册,Windows 仅依赖 SIGINT 触发收尾。
|
||||
7. 判断轮换模式是否变化时,须把配置中的 mode 按来源归一后再与 server.mode 比较,直接比较原始配置值会把归一后相同的模式误判为已切换。
|
||||
"""
|
||||
|
||||
from modules.modules import load_config, get_message, print_banner, port_in_use, strip_deprecated_server_keys, migrate_pool_proxy_count, migrate_getip_url, enable_safe_console_output, normalize_rotation_mode, list_files_snapshot, reload_ip_lists_if_changed
|
||||
from modules.logging_setup import setup_logging, shutdown_logging
|
||||
import threading, argparse, logging, asyncio, time, os, signal, sys
|
||||
from modules.proxyserver import AsyncProxyServer, run_server
|
||||
from modules import pool_config_ini
|
||||
from modules.proxypool_service import ProxyPoolService
|
||||
from modules.version_check import VersionChecker
|
||||
from colorama import init, Fore, Style
|
||||
from itertools import cycle
|
||||
from tqdm import tqdm
|
||||
import base64
|
||||
from configparser import ConfigParser
|
||||
|
||||
init(autoreset=True)
|
||||
|
||||
def setup_logging():
|
||||
log_format = '%(asctime)s - %(levelname)s - %(message)s'
|
||||
formatter = ColoredFormatter(log_format)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logging.basicConfig(level=logging.INFO, handlers=[console_handler])
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
def update_status(server):
|
||||
def reload_server_config(server, new_config, ip_file, last_ip_modified_time):
|
||||
old_source_mode = server.proxy_source_mode
|
||||
old_mode = server.mode
|
||||
old_port = int(server.config.get('port', '1080'))
|
||||
old_revision = server.roster_revision()
|
||||
|
||||
new_source_mode = new_config.get('proxy_source_mode', 'local').lower()
|
||||
server.apply_config_sync(
|
||||
new_config,
|
||||
mode_changed=(
|
||||
old_mode
|
||||
!= normalize_rotation_mode(new_config.get('mode', 'cycle'), new_source_mode)),
|
||||
source_mode_changed=old_source_mode != new_source_mode,
|
||||
reset_switch_timers=True,
|
||||
)
|
||||
|
||||
mode_changed = old_source_mode != server.proxy_source_mode or old_mode != server.mode
|
||||
if mode_changed:
|
||||
last_ip_modified_time = (
|
||||
os.path.getmtime(server.proxy_file) if os.path.exists(server.proxy_file) else 0
|
||||
)
|
||||
elif server.proxy_file != ip_file:
|
||||
last_ip_modified_time = 0
|
||||
|
||||
if old_port != server.port:
|
||||
logging.info(get_message('port_changed', server.language, old_port, server.port))
|
||||
|
||||
if server.roster_revision() != old_revision:
|
||||
logging.info(get_message('proxy_workers_ready', server.language, len(server.proxies)))
|
||||
|
||||
return server.proxy_file, last_ip_modified_time, int(server.config.get('display_level', '1'))
|
||||
|
||||
|
||||
def print_version_result(payload, language='cn'):
|
||||
if not payload or payload.get('status') != 'success':
|
||||
return
|
||||
if payload.get('is_latest'):
|
||||
line = get_message('latest_version', language)
|
||||
else:
|
||||
line = get_message(
|
||||
'new_version_available', language,
|
||||
payload.get('latest_version', ''), payload.get('current_version', ''),
|
||||
)
|
||||
tqdm.write(f"{Fore.CYAN}{line}{Style.RESET_ALL}")
|
||||
|
||||
|
||||
def _exit_remaining(exit_info, mode, language):
|
||||
parts = []
|
||||
if mode == 'time' and exit_info['expires_in'] is not None:
|
||||
parts.append(f"{int(round(exit_info['expires_in']))}{get_message('seconds', language)}")
|
||||
if mode in ('time', 'request_count') and exit_info['requests_left'] is not None:
|
||||
parts.append(get_message('exit_left_requests', language, exit_info['requests_left']))
|
||||
if not parts:
|
||||
return get_message('exit_left_none', language)
|
||||
return ' · '.join(parts)
|
||||
|
||||
|
||||
def format_exit_status(server):
|
||||
language = server.language
|
||||
mode = server.switch_countdown_mode()
|
||||
exits = server.active_workers()
|
||||
summary_key = ('exits_summary_expanded' if server.elastic_active
|
||||
else 'exits_summary')
|
||||
lines = [get_message(summary_key, language, len(exits), server.exit_count)]
|
||||
for exit_info in exits:
|
||||
capacity = exit_info['capacity']
|
||||
load = (f"{exit_info['active']}/{capacity}" if capacity
|
||||
else get_message('exit_load_no_cap', language, exit_info['active']))
|
||||
lines.append(get_message(
|
||||
'exit_line', language, exit_info['url'], load,
|
||||
_exit_remaining(exit_info, mode, language),
|
||||
))
|
||||
return lines
|
||||
|
||||
|
||||
def update_status(server, config_file):
|
||||
def print_proxy_info():
|
||||
status = f"{get_message('current_proxy', server.language)}: {server.current_proxy}"
|
||||
logging.info(status)
|
||||
for line in format_exit_status(server):
|
||||
logging.info(line)
|
||||
print(line)
|
||||
|
||||
def reload_server_config(new_config):
|
||||
old_use_getip = server.use_getip
|
||||
old_mode = server.mode
|
||||
old_port = int(server.config.get('port', '1080'))
|
||||
|
||||
server.config.update(new_config)
|
||||
server._update_config_values(new_config)
|
||||
|
||||
if old_use_getip != server.use_getip or old_mode != server.mode:
|
||||
server._handle_mode_change()
|
||||
|
||||
if old_port != server.port:
|
||||
logging.info(get_message('port_changed', server.language, old_port, server.port))
|
||||
def roster_changed():
|
||||
revision = server.roster_revision()
|
||||
if getattr(server, 'last_roster_revision', None) == revision:
|
||||
return False
|
||||
server.last_roster_revision = revision
|
||||
return True
|
||||
|
||||
config_file = 'config/config.ini'
|
||||
ip_file = server.proxy_file
|
||||
last_config_modified_time = os.path.getmtime(config_file) if os.path.exists(config_file) else 0
|
||||
last_ip_modified_time = os.path.getmtime(ip_file) if os.path.exists(ip_file) else 0
|
||||
display_level = int(server.config.get('display_level', '1'))
|
||||
is_docker = os.path.exists('/.dockerenv')
|
||||
|
||||
list_snapshot = list_files_snapshot(server)
|
||||
|
||||
while True:
|
||||
try:
|
||||
list_snapshot = reload_ip_lists_if_changed(server, list_snapshot)
|
||||
|
||||
if os.path.exists(config_file):
|
||||
current_config_modified_time = os.path.getmtime(config_file)
|
||||
if current_config_modified_time > last_config_modified_time:
|
||||
logging.info(get_message('config_file_changed', server.language))
|
||||
new_config = load_config(config_file)
|
||||
reload_server_config(new_config)
|
||||
ip_file, last_ip_modified_time, display_level = reload_server_config(
|
||||
server, new_config, ip_file, last_ip_modified_time
|
||||
)
|
||||
last_config_modified_time = current_config_modified_time
|
||||
continue
|
||||
|
||||
if os.path.exists(ip_file) and not server.use_getip:
|
||||
|
||||
if os.path.exists(ip_file) and server.proxy_source_mode == 'local':
|
||||
current_ip_modified_time = os.path.getmtime(ip_file)
|
||||
if current_ip_modified_time > last_ip_modified_time:
|
||||
logging.info(get_message('proxy_file_changed', server.language))
|
||||
server._reload_proxies()
|
||||
server.reload_local_proxies(run_check=True)
|
||||
last_ip_modified_time = current_ip_modified_time
|
||||
continue
|
||||
|
||||
if display_level == 0:
|
||||
if not hasattr(server, 'last_proxy') or server.last_proxy != server.current_proxy:
|
||||
if roster_changed():
|
||||
print_proxy_info()
|
||||
server.last_proxy = server.current_proxy
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if server.mode == 'loadbalance':
|
||||
if display_level >= 1:
|
||||
if display_level >= 1 and roster_changed():
|
||||
print_proxy_info()
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
if server.switch_countdown_mode() != 'time':
|
||||
if display_level >= 1 and roster_changed():
|
||||
print_proxy_info()
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
time_left = server.time_until_next_switch()
|
||||
if time_left == float('inf'):
|
||||
if display_level >= 1:
|
||||
print_proxy_info()
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
if not hasattr(server, 'last_proxy') or server.last_proxy != server.current_proxy:
|
||||
if roster_changed():
|
||||
print_proxy_info()
|
||||
server.last_proxy = server.current_proxy
|
||||
server.previous_proxy = server.current_proxy
|
||||
|
||||
total_time = int(server.interval)
|
||||
elapsed_time = total_time - int(time_left)
|
||||
|
||||
|
||||
if display_level >= 1:
|
||||
if elapsed_time > total_time:
|
||||
if hasattr(server, 'progress_bar'):
|
||||
@@ -103,7 +185,7 @@ def update_status(server):
|
||||
delattr(server, 'last_update_time')
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
|
||||
if is_docker:
|
||||
if not hasattr(server, 'last_update_time') or \
|
||||
(time.time() - server.last_update_time >= (5 if display_level == 1 else 1) and elapsed_time <= total_time):
|
||||
@@ -120,10 +202,10 @@ def update_status(server):
|
||||
bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} ' + get_message('seconds', server.language),
|
||||
colour='green'
|
||||
)
|
||||
|
||||
|
||||
server.progress_bar.n = min(elapsed_time, total_time)
|
||||
server.progress_bar.refresh()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
if display_level >= 2:
|
||||
logging.error(f"Status update error: {e}")
|
||||
@@ -131,313 +213,92 @@ def update_status(server):
|
||||
logging.error(get_message('status_update_error', server.language))
|
||||
time.sleep(1)
|
||||
|
||||
async def handle_client_wrapper(server, reader, writer, clients):
|
||||
task = asyncio.create_task(server.handle_client(reader, writer))
|
||||
clients.add(task)
|
||||
try:
|
||||
await task
|
||||
except Exception as e:
|
||||
logging.error(get_message('client_handle_error', server.language, e))
|
||||
finally:
|
||||
clients.remove(task)
|
||||
|
||||
async def run_server(server):
|
||||
try:
|
||||
await server.start()
|
||||
except asyncio.CancelledError:
|
||||
logging.info(get_message('server_closing', server.language))
|
||||
except Exception as e:
|
||||
if not server.stop_server:
|
||||
logging.error(f"Server error: {e}")
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
async def run_proxy_check(server):
|
||||
if server.config.get('check_proxies', 'False').lower() == 'true':
|
||||
logging.info(get_message('proxy_check_start', server.language))
|
||||
valid_proxies = await check_proxies(server.proxies, server.test_url)
|
||||
if valid_proxies:
|
||||
server.proxies = valid_proxies
|
||||
server.proxy_cycle = cycle(valid_proxies)
|
||||
server.current_proxy = next(server.proxy_cycle)
|
||||
logging.info(get_message('valid_proxies', server.language, valid_proxies))
|
||||
else:
|
||||
logging.error(get_message('no_valid_proxies', server.language))
|
||||
else:
|
||||
logging.info(get_message('proxy_check_disabled', server.language))
|
||||
|
||||
class ProxyCat:
|
||||
def __init__(self):
|
||||
cpu_count = os.cpu_count() or 1
|
||||
self.executor = ThreadPoolExecutor(
|
||||
max_workers=min(32, cpu_count + 4),
|
||||
thread_name_prefix="proxy_worker",
|
||||
thread_name_format="proxy_worker_%d"
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.set_default_executor(self.executor)
|
||||
|
||||
if hasattr(loop, 'set_task_factory'):
|
||||
loop.set_task_factory(None)
|
||||
|
||||
socket.setdefaulttimeout(30)
|
||||
if hasattr(socket, 'TCP_NODELAY'):
|
||||
socket.TCP_NODELAY = True
|
||||
if hasattr(socket, 'SO_KEEPALIVE'):
|
||||
socket.SO_KEEPALIVE = True
|
||||
|
||||
if hasattr(socket, 'SO_REUSEADDR'):
|
||||
socket.SO_REUSEADDR = True
|
||||
if hasattr(socket, 'SO_REUSEPORT') and os.name != 'nt':
|
||||
socket.SO_REUSEPORT = True
|
||||
|
||||
if os.name != 'nt':
|
||||
import resource
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||
try:
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
|
||||
except ValueError:
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, soft))
|
||||
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_NPROC)
|
||||
try:
|
||||
resource.setrlimit(resource.RLIMIT_NPROC, (hard, hard))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self.running = True
|
||||
self.tasks = set()
|
||||
self.max_tasks = 20000
|
||||
self.task_semaphore = asyncio.Semaphore(self.max_tasks)
|
||||
|
||||
signal.signal(signal.SIGINT, self.handle_shutdown)
|
||||
signal.signal(signal.SIGTERM, self.handle_shutdown)
|
||||
self.config = load_config('config/config.ini')
|
||||
self.language = self.config.get('language', 'cn').lower()
|
||||
|
||||
self.users = {}
|
||||
config = ConfigParser()
|
||||
config.read('config/config.ini', encoding='utf-8')
|
||||
if config.has_section('Users'):
|
||||
self.users = dict(config.items('Users'))
|
||||
self.auth_required = bool(self.users)
|
||||
|
||||
self.proxy_pool = {}
|
||||
self.max_connections = 1000
|
||||
self.connection_timeout = 30
|
||||
self.read_timeout = 60
|
||||
|
||||
async def start_server(self):
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
if hasattr(socket, 'SO_REUSEPORT') and os.name != 'nt':
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
sock.bind((self.config.get('SERVER', 'host'), int(self.config.get('SERVER', 'port'))))
|
||||
|
||||
server = await asyncio.start_server(
|
||||
self.handle_client,
|
||||
sock=sock
|
||||
)
|
||||
|
||||
logging.info(get_message('server_running', self.language,
|
||||
self.config.get('SERVER', 'host'),
|
||||
self.config.get('SERVER', 'port')))
|
||||
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
except Exception as e:
|
||||
logging.error(get_message('server_start_error', self.language, e))
|
||||
sys.exit(1)
|
||||
|
||||
def handle_shutdown(self, signum, frame):
|
||||
logging.info(get_message('server_shutting_down', self.language))
|
||||
self.running = False
|
||||
self.executor.shutdown(wait=True)
|
||||
sys.exit(0)
|
||||
|
||||
async def handle_client(self, reader, writer):
|
||||
task = asyncio.current_task()
|
||||
self.tasks.add(task)
|
||||
try:
|
||||
if self.auth_required:
|
||||
auth_header = headers.get('proxy-authorization')
|
||||
if not auth_header or not self._authenticate(auth_header):
|
||||
writer.write(b'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="Proxy"\r\n\r\n')
|
||||
await writer.drain()
|
||||
return
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
self.executor,
|
||||
self._handle_proxy_request,
|
||||
reader,
|
||||
writer
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(get_message('client_process_error', self.language, e))
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except:
|
||||
pass
|
||||
self.tasks.remove(task)
|
||||
|
||||
def _authenticate(self, auth_header):
|
||||
if not self.users:
|
||||
return True
|
||||
|
||||
try:
|
||||
scheme, credentials = auth_header.split()
|
||||
if scheme.lower() != 'basic':
|
||||
return False
|
||||
|
||||
decoded_auth = base64.b64decode(credentials).decode()
|
||||
username, password = decoded_auth.split(':')
|
||||
|
||||
return username in self.users and self.users[username] == password
|
||||
except:
|
||||
return False
|
||||
|
||||
async def _handle_proxy_request(self, reader, writer):
|
||||
try:
|
||||
request_line = await reader.readline()
|
||||
if not request_line:
|
||||
return
|
||||
|
||||
method, target, version = request_line.decode().strip().split(' ')
|
||||
|
||||
if method == 'CONNECT':
|
||||
await self._handle_connect(target, reader, writer)
|
||||
else:
|
||||
await self._handle_http(method, target, version, reader, writer)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(get_message('request_handling_error', self.language, e))
|
||||
try:
|
||||
writer.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
async def _handle_connect(self, target, reader, writer):
|
||||
host, port = target.split(':')
|
||||
port = int(port)
|
||||
|
||||
try:
|
||||
remote_reader, remote_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=self.connection_timeout
|
||||
)
|
||||
|
||||
writer.write(b'HTTP/1.1 200 Connection Established\r\n\r\n')
|
||||
await writer.drain()
|
||||
|
||||
await self._create_pipe(reader, writer, remote_reader, remote_writer)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(get_message('proxy_forward_error', self.language, e))
|
||||
writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n')
|
||||
await writer.drain()
|
||||
|
||||
async def _handle_http(self, method, target, version, reader, writer):
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
url = urlparse(target)
|
||||
host = url.hostname
|
||||
port = url.port or 80
|
||||
|
||||
remote_reader, remote_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=self.connection_timeout
|
||||
)
|
||||
|
||||
path = url.path + ('?' + url.query if url.query else '')
|
||||
request = f'{method} {path} {version}\r\n'
|
||||
remote_writer.write(request.encode())
|
||||
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if line == b'\r\n':
|
||||
break
|
||||
remote_writer.write(line)
|
||||
remote_writer.write(b'\r\n')
|
||||
|
||||
await self._create_pipe(reader, writer, remote_reader, remote_writer)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(get_message('proxy_forward_error', self.language, e))
|
||||
writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n')
|
||||
await writer.drain()
|
||||
|
||||
async def _create_pipe(self, client_reader, client_writer, remote_reader, remote_writer):
|
||||
try:
|
||||
pipe1 = asyncio.create_task(self._pipe(client_reader, remote_writer))
|
||||
pipe2 = asyncio.create_task(self._pipe(remote_reader, client_writer))
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[pipe1, pipe2],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(get_message('data_transfer_error', self.language, e))
|
||||
finally:
|
||||
try:
|
||||
remote_writer.close()
|
||||
await remote_writer.wait_closed()
|
||||
except:
|
||||
pass
|
||||
|
||||
async def _pipe(self, reader, writer):
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(8192)
|
||||
if not data:
|
||||
break
|
||||
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.error(get_message('data_transfer_error', self.language, e))
|
||||
|
||||
def monitor_resources(self):
|
||||
import psutil
|
||||
process = psutil.Process(os.getpid())
|
||||
|
||||
while self.running:
|
||||
mem_info = process.memory_info()
|
||||
logging.debug(f"Memory usage: {mem_info.rss / 1024 / 1024:.2f} MB, "
|
||||
f"Connections: {len(self.tasks)}")
|
||||
time.sleep(60)
|
||||
|
||||
if __name__ == '__main__':
|
||||
setup_logging()
|
||||
parser = argparse.ArgumentParser(description=logos())
|
||||
parser.add_argument('-c', '--config', default='config/config.ini', help='配置文件路径')
|
||||
enable_safe_console_output()
|
||||
|
||||
parser = argparse.ArgumentParser(description='ProxyCat - 代理轮换工具')
|
||||
parser.add_argument('-c', '--config', default=os.path.join(BASE_DIR, 'config', 'config.ini'), help='配置文件路径')
|
||||
args = parser.parse_args()
|
||||
config = load_config(args.config)
|
||||
server = AsyncProxyServer(config)
|
||||
|
||||
setup_logging(config, BASE_DIR, console_level=logging.WARNING)
|
||||
|
||||
migrate_pool_proxy_count(args.config)
|
||||
migrate_getip_url(args.config)
|
||||
strip_deprecated_server_keys(args.config)
|
||||
pool_config_ini.ensure_pool_section(
|
||||
args.config,
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
'modules', 'proxypool', 'config.yaml',
|
||||
),
|
||||
)
|
||||
sections = pool_config_ini.read_ini_sections(args.config)
|
||||
pool_config = pool_config_ini.build_pool_config(
|
||||
sections.get('Server', {}),
|
||||
sections.get(pool_config_ini.POOL_SECTION, {}),
|
||||
strict=False,
|
||||
)
|
||||
pool_service = ProxyPoolService(
|
||||
pool_config,
|
||||
config_ini_path=args.config,
|
||||
language_provider=lambda: server.language,
|
||||
)
|
||||
|
||||
server = AsyncProxyServer(config, pool_service=pool_service)
|
||||
print_banner(config)
|
||||
asyncio.run(check_for_updates(config.get('language', 'cn').lower()))
|
||||
if not config.get('use_getip', 'False').lower() == 'true':
|
||||
asyncio.run(run_proxy_check(server))
|
||||
else:
|
||||
logging.info(get_message('api_mode_notice', server.language))
|
||||
|
||||
status_thread = threading.Thread(target=update_status, args=(server,), daemon=True)
|
||||
|
||||
version_language = config.get('language', 'cn')
|
||||
version_checker = VersionChecker(
|
||||
os.path.join(BASE_DIR, 'logs', 'version_check.json'),
|
||||
language_provider=lambda: version_language,
|
||||
on_result=lambda payload: print_version_result(payload, version_language),
|
||||
)
|
||||
if not version_checker.start():
|
||||
print_version_result(version_checker.result_payload(), version_language)
|
||||
|
||||
def _handle_signal(sig, frame):
|
||||
server.stop_server = True
|
||||
logging.info(get_message('server_closing', config.get('language', 'cn')))
|
||||
raise KeyboardInterrupt
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
if os.name != 'nt':
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
|
||||
async def main():
|
||||
proxy_port = int(config.get('port', '1080'))
|
||||
if port_in_use(proxy_port):
|
||||
logging.error(get_message('port_in_use', config.get('language', 'cn'), proxy_port))
|
||||
sys.exit(1)
|
||||
|
||||
if pool_config_ini.should_autostart_pool(config):
|
||||
logging.info(get_message('pool_starting', server.language))
|
||||
if pool_service.start():
|
||||
logging.info(get_message('pool_start_success', server.language))
|
||||
else:
|
||||
logging.error(get_message('pool_start_failed', server.language,
|
||||
pool_service.last_error))
|
||||
else:
|
||||
logging.info(get_message('pool_autostart_skipped', server.language))
|
||||
proxy_mode = config.get('proxy_source_mode', 'local').lower()
|
||||
if proxy_mode == 'local':
|
||||
await server.run_startup_check()
|
||||
else:
|
||||
notice = 'api_mode_notice' if proxy_mode == 'api' else 'pool_mode_notice'
|
||||
logging.info(get_message(notice, server.language))
|
||||
await run_server(server)
|
||||
|
||||
status_thread = threading.Thread(
|
||||
target=update_status, args=(server, args.config), daemon=True
|
||||
)
|
||||
status_thread.start()
|
||||
|
||||
cleanup_thread = threading.Thread(target=lambda: asyncio.run(server.cleanup_clients()), daemon=True)
|
||||
cleanup_thread.start()
|
||||
|
||||
|
||||
try:
|
||||
asyncio.run(run_server(server))
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logging.info(get_message('user_interrupt', server.language))
|
||||
finally:
|
||||
pool_service.stop()
|
||||
version_checker.stop()
|
||||
shutdown_logging()
|
||||
@@ -1,57 +1,139 @@
|
||||

|
||||
|
||||
<p align="center">
|
||||
<a href="/README-EN.md">English</a>
|
||||
·
|
||||
<a href="/README.md">简体中文</a>
|
||||
·
|
||||
<a href="/README-EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Development Background](#development-background)
|
||||
- [Features](#features)
|
||||
- [Installation and Usage](#installation-and-usage)
|
||||
- [Project Background](#project-background)
|
||||
- [Key Features](#key-features)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Quick Deployment](#quick-deployment)
|
||||
- [Documentation](#documentation)
|
||||
- [Disclaimer](#disclaimer)
|
||||
- [Changelog](#changelog)
|
||||
- [License](#license)
|
||||
- [Development Plan](#development-plan)
|
||||
- [Special Thanks](#special-thanks)
|
||||
- [Sponsor](#sponsor)
|
||||
- [Proxy Recommendations](#proxy-recommendations)
|
||||
|
||||
## Development Background
|
||||
## Project Background
|
||||
|
||||
During penetration testing, it's often necessary to hide or change IP addresses to bypass security devices. However, tunnel proxies in the market are expensive, typically costing $3-6 per day, which is unaffordable for many. The author noticed that short-term IPs offer high cost-effectiveness, with each IP costing just a few cents, averaging $0.03-0.4 per day.
|
||||
During penetration testing you often need to hide or change your IP address to get past security devices. But tunnel proxies on the market are expensive — typically ¥3-6 per day — which puts them out of reach for many. The author noticed instead that short-lived IPs offer outstanding value: a few cents each, averaging ¥0.03-0.4 per day.
|
||||
|
||||
Therefore, **ProxyCat** was born! This tool aims to transform short-term IPs (lasting from 1 to 60 minutes) into fixed IPs for other tools to use, creating a proxy pool server that can be used permanently after one deployment.
|
||||
And so **ProxyCat** was born! The tool turns short-lived IPs (valid for 1-60 minutes) into fixed IPs that other tools can use, giving you a proxy pool server that is deployed once and then works indefinitely.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
## Key Features
|
||||
|
||||
- **Dual Protocol Listening**: Supports HTTP/SOCKS5 protocol listening, compatible with more tools.
|
||||
- **Triple Proxy Types**: Supports HTTP/HTTPS/SOCKS5 proxy servers with authentication.
|
||||
- **Flexible Switching Modes**: Supports sequential, random, and custom proxy selection for optimized traffic distribution.
|
||||
- **Dynamic Proxy Acquisition**: Get available proxies in real-time through GetIP function, supports API interface calls.
|
||||
- **Proxy Protection**: When using GetIP method, proxies are only fetched upon receiving requests, not at initial startup.
|
||||
- **Automatic Proxy Detection**: Automatically checks proxy validity at startup, removing invalid ones.
|
||||
- **Smart Proxy Switching**: Only obtains new proxies during request execution, reducing resource consumption.
|
||||
- **Invalid Proxy Handling**: Automatically validates and switches to new proxies when current ones fail.
|
||||
- **Authentication Support**: Supports username/password authentication and IP blacklist/whitelist management.
|
||||
- **Real-time Status Display**: Shows proxy status and switching times for dynamic monitoring.
|
||||
- **Dynamic Configuration**: Updates configuration without service restart.
|
||||
- **Web UI Interface**: Provides web management interface for convenient operation.
|
||||
- **Docker Deployment**: One-click Docker deployment with unified web management.
|
||||
- **Bilingual Support**: Supports Chinese and English language switching.
|
||||
- **Flexible Configuration**: Customize ports, modes, and authentication through config.ini.
|
||||
- **Version Check**: Automatic software update checking.
|
||||
- **Dual-protocol listening**: a single port accepts both HTTP and SOCKS5 inbound requests; upstreams may be authenticated HTTP / HTTPS / SOCKS5 proxies. [Details](ProxyCat-Manual/Features-EN.md#outbound-proxy-service)
|
||||
- **Three proxy sources**: `local` reads a local list, `api` fetches from an endpoint live, `pool` uses the built-in proxy pool directly — switching between them on the panel takes effect immediately.
|
||||
- **Many exits serving at once**: several upstream exits are kept in service simultaneously, and requests are spread by each exit's live load — so when an upstream rate-limits per exit IP, concurrency scales with the number of exits. [Upstream Exits and Concurrency](ProxyCat-Manual/Features-EN.md#upstream-exits-and-concurrency)
|
||||
- **Retirement by failure share**: an exit's health is judged by the failure share in its sliding window, not by a count of consecutive failures — which is what tells "one network hiccup" apart from "this exit is simply broken".
|
||||
- **Automatic expansion at peak**: exits are added automatically when load climbs, and released in one step once the peak passes; growth and release look at two different numbers, so the exit count never oscillates.
|
||||
- **Built-in proxy pool**: plugin-based collection, verify-before-storing, three validation intensities, health scoring, offline geolocation, automatic revalidation and cleanup. [Built-in Proxy Pool](ProxyCat-Manual/Features-EN.md#built-in-proxy-pool)
|
||||
- **Real usage feedback**: bad exits found while forwarding flow back into the pool; once consecutive failures reach the threshold the entry is marked invalid, and a passing revalidation restores it automatically.
|
||||
- **Access control and outbound authentication**: client IP whitelist / blacklist judged by priority, a bypass list that connects matching targets directly, and username / password authentication on the outbound side.
|
||||
- **Web management panel**: four tabs — Proxy Config, Proxy Pool, Access Control and Logs — putting configuration, lists, logs and service start/stop on one screen. [Panel and Operations](ProxyCat-Manual/Features-EN.md#panel-and-operations)
|
||||
- **Containerized deployment**: one-command Docker build and start; the container runs as non-root, and the health check probes the actual panel port.
|
||||
|
||||
## Tool Usage
|
||||
## Screenshots
|
||||
|
||||
[ProxyCat Operation Manual](../main/ProxyCat-Manual/Operation%20Manual.md)
|
||||
The panel is a single page with four tabs down the left side: Proxy Config, Proxy Pool, Access Control and Logs.
|
||||
|
||||
## Error Troubleshooting
|
||||
| Proxy Config · Source | Proxy Pool · Proxy Management |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Pick the proxy source (Local / API / Pool) and fill in whatever the current source needs — here, the remote pool URL for the built-in pool (leave it empty to use the in-process pool). | Every collected proxy at a glance: protocol, real IP, region, delay, health score and anonymity. |
|
||||
|
||||
[ProxyCat Investigation Manual](../main/ProxyCat-Manual/Investigation%20Manual.md)
|
||||
| Access Control | Logs |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Outbound authentication accounts, the IP whitelist / blacklist and the proxy bypass list all live here. | Live logs and past log files, plus access statistics and per-request detail. |
|
||||
|
||||
| Dark Theme · Exit Rotation | Dark Theme · Proxy Management |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| The dark theme: the layout is identical to the light one. | The same Proxy Management view in dark mode, sharing data and server-side settings with the light one. |
|
||||
|
||||
The panel offers two themes, dark and light; until you switch manually it follows the system's dark preference. The interface also switches between Chinese and English — the language is a server-side setting, so one flip updates the whole site's copy and the headers of exported files together.
|
||||
|
||||
## Quick Deployment
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Compose builds the image and starts the container. Two ports are exposed:
|
||||
|
||||
| Port | Purpose |
|
||||
|---|---|
|
||||
| `1080` | Proxy listening port (shared by HTTP and SOCKS5) |
|
||||
| `5001` | Web panel port |
|
||||
|
||||
Three directories are bind-mounted to the host so your data and configuration survive container recreation: `config/`, `logs/` and `modules/proxypool/data/`.
|
||||
|
||||
### Running from Source
|
||||
|
||||
**Python 3.10 or later is required**: the source makes heavy use of runtime-evaluated `X | Y` union type annotations, and 3.9 or earlier raises a `TypeError` at import time.
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
# On Windows the command is `python`
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
`app.py` brings up the web panel and the outbound proxy service together. The `config/config.ini` shipped in this repo sets `proxy_source_mode=pool` (the program's built-in default is `local`), so starting from this configuration **starts the built-in proxy pool automatically along with the service** — exits are available right out of the box.
|
||||
|
||||
### Verifying the Deployment
|
||||
|
||||
Two commands confirm the service is really working:
|
||||
|
||||
```bash
|
||||
# 1. The panel is listening: expect 302 (redirect to /web)
|
||||
curl -i http://127.0.0.1:5001/
|
||||
|
||||
# 2. The proxy is forwarding: expect 200, with the request actually going through an upstream.
|
||||
# The [Users] section of the shipped config has accounts, so requests must be authenticated;
|
||||
# without credentials you get a 407.
|
||||
curl -x http://neko:[email protected]:1080 https://www.baidu.com -I
|
||||
```
|
||||
|
||||
### Must-Read for Your First Deployment
|
||||
|
||||
These four are all classic "symptom first, cause later" cases; the full explanations are in the operation manual's [Four Things To Know Before Deploying](ProxyCat-Manual/Operation%20Manual-EN.md#four-things-to-know-before-deploying):
|
||||
|
||||
1. **The panel won't open, and returns 401**: with a token set you **must pass `?token=` to open it**; the token shipped in this repo is public, so change `[Server] token` in `config.ini` before exposing the panel — the change takes effect within about a second, and the old token stops working immediately.
|
||||
2. **The container is up but can't write configuration or logs**: the container runs as a non-root user, so its UID/GID must match the owner of `config/`, `logs/` and `modules/proxypool/data/` on the host; if they differ, set `PUID` / `PGID` in `.env`.
|
||||
3. **A restart loses the last batch of data**: on shutdown the application drains its write queue, and `stop_grace_period` has been raised to 60 seconds — Docker's 10-second default `SIGKILL`s it before the drain finishes, **so please don't lower it**.
|
||||
4. **The container turns unhealthy after changing `web_port`**: the health check reads the actual `web_port` from `config.ini` at probe time, so after changing it you must restart the process and update the port mapping in `docker-compose.yml`.
|
||||
|
||||
### Network Behaviour
|
||||
|
||||
Besides forwarding client traffic, ProxyCat itself also makes outbound requests, many of them **direct** (sent from this machine's real IP) — for example the version check, the pool's host public-IP lookup, the GeoNode and GitHub scraping plugins and the offline geolocation-database update. These requests can all be turned off or pointed at addresses of your own (the version check has no dedicated switch and must be blocked at the network layer); for each one's trigger, destination and how to stop it, see [Features](ProxyCat-Manual/Features-EN.md#outbound-network-requests). Traffic forwarded through a proxy is not on this list — that is the client's own traffic.
|
||||
|
||||
## Documentation
|
||||
|
||||
The README keeps only the basic introduction and quick deployment; everything else lives in the documents below:
|
||||
|
||||
| Document | Contents |
|
||||
|---|---|
|
||||
| [Features](ProxyCat-Manual/Features-EN.md) | Outbound proxy service, built-in proxy pool, panel and operations, upstream exits and concurrency, outbound network requests, performance benchmarks and project structure |
|
||||
| [Configuration](ProxyCat-Manual/Configuration-EN.md) | Every option in `[Server]` / `[Users]` / `[api_credentials]` / `[Pool]`: defaults, descriptions and how changes take effect |
|
||||
| [Operation Manual](ProxyCat-Manual/Operation%20Manual-EN.md) | Installation and deployment, entry points, a page-by-page tour of the panel, using the proxy pool, logs and access records, troubleshooting Q&A |
|
||||
| [Investigation Manual](ProxyCat-Manual/Investigation%20Manual-EN.md) | How to diagnose and handle common errors |
|
||||
| [Changelog](ProxyCat-Manual/logs-EN.md) | What changed in every release |
|
||||
| [API Reference](API-EN.md) | Parameters, response examples and error codes for the panel's `/api/*` and the pool's `/api/pool/*` |
|
||||
| [Proxy Pool Module Docs](modules/proxypool/README-EN.md) | Implementation details: scrape plugin development, validation strategy, anonymity detection, geolocation resolution, automatic cleanup |
|
||||
|
||||
The Chinese documents are the same files without the `-EN` suffix (e.g. `ProxyCat-Manual/Features.md`); the Chinese entry point is [README.md](README.md).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -62,21 +144,27 @@ Therefore, **ProxyCat** was born! This tool aims to transform short-term IPs (la
|
||||
- You have no right to download, install, or use this tool unless you have read and accepted all terms.
|
||||
- Your download, installation, and usage actions indicate your acceptance of this agreement.
|
||||
|
||||
## Changelog
|
||||
## License
|
||||
|
||||
[Changelog Records](../main/ProxyCat-Manual/logs.md)
|
||||
This project is licensed under the **GNU General Public License v2.0 (GPL-2.0)**; the full text is in
|
||||
[LICENSE](LICENSE).
|
||||
|
||||
- It is a **copyleft license**: if you distribute a modified version or a derivative work, it must also
|
||||
be licensed under GPL-2.0 with source available.
|
||||
- The software comes with **no warranty** — which is also why the disclaimer above reads the way it does.
|
||||
|
||||
## Development Plan
|
||||
|
||||
- [x] Add detailed logging to record all IP identities connecting to ProxyCat, supporting multiple users.
|
||||
- [x] Add Web UI for a more powerful and user-friendly interface.
|
||||
- [x] Built-in proxy pool: plugin-based collection, validation, scoring, geolocation and automatic cleanup.
|
||||
- [ ] Develop babycat module that can run on any server or host to turn it into a proxy server.
|
||||
- [ ] Add request blacklist/whitelist to specify URLs, IPs, or domains to be forcibly dropped or bypassed.
|
||||
- [ ] Add request blacklist/whitelist to specify URLs, IPs, or domains to be forcibly dropped or bypassed (the bypass list is already supported; the rest is pending).
|
||||
- [ ] Package to PyPi for easier installation and use.
|
||||
|
||||
If you have good ideas or encounter bugs during use, please contact the author through:
|
||||
If you have good ideas or run into bugs during use, please contact the author through:
|
||||
|
||||
WeChat Official Account: **樱花庄的本间白猫**
|
||||
WeChat Official Account: **樱花庄的本间白猫** (Honma Shironeko of Sakurasou)
|
||||
|
||||
## Special Thanks
|
||||
|
||||
@@ -94,15 +182,13 @@ In no particular order, thanks to all contributors who helped with this project:
|
||||
|
||||
Open source development isn't easy. If you find this tool helpful, consider sponsoring the author's development!
|
||||
|
||||
---
|
||||
| Rank | ID | Amount (CNY) |
|
||||
| :--: | :-----------------: | :----------: |
|
||||
| 1 | **陆沉** | 18302.7 |
|
||||
| 2 | **柯林斯.民间新秀** | 696 |
|
||||
| 3 | **北** | 170 |
|
||||
| [Sponsor List](https://github.com/honmashironeko/Thanks-for-sponsorship) | Every sponsorship is a motivation for the author! | (´∀`)♡ |
|
||||
| Rank | ID | Amount (CNY) |
|
||||
| :--: | :--: | :----------: |
|
||||
| 1 | **陆沉** | 1266.62 |
|
||||
| 2 | **柯林斯.民间新秀** | 696 |
|
||||
| 3 | **北** | 170 |
|
||||
| [Sponsor List](https://github.com/honmashironeko/Thanks-for-sponsorship) | Every sponsorship is a motivation for the author! | (´∀`)♡ |
|
||||
|
||||
---
|
||||

|
||||
|
||||
## Proxy Recommendations
|
||||
|
||||
@@ -1,87 +1,180 @@
|
||||

|
||||

|
||||
|
||||
<p align="center">
|
||||
<a href="/README-EN.md">English</a>
|
||||
·
|
||||
<a href="/README.md">简体中文</a>
|
||||
·
|
||||
<a href="/README-EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## 目录
|
||||
|
||||
- [开发缘由](#开发缘由)
|
||||
- [功能特点](#功能特点)
|
||||
- [安装与使用](#安装与使用)
|
||||
- [免责申明](#免责申明)
|
||||
- [更新日志](#更新日志)
|
||||
- [项目背景](#项目背景)
|
||||
- [核心特性](#核心特性)
|
||||
- [外观展示](#外观展示)
|
||||
- [快速部署](#快速部署)
|
||||
- [文档导航](#文档导航)
|
||||
- [免责声明](#免责声明)
|
||||
- [开源协议](#开源协议)
|
||||
- [开发计划](#开发计划)
|
||||
- [特别鸣谢](#特别鸣谢)
|
||||
- [赞助开源](#赞助开源)
|
||||
- [特别感谢](#特别感谢)
|
||||
- [赞助](#赞助)
|
||||
- [代理推荐](#代理推荐)
|
||||
|
||||
## 开发缘由
|
||||
## 项目背景
|
||||
|
||||
在渗透过程中,经常需要隐藏或更换IP地址以绕过安全设备。然而,市面上的隧道代理价格高昂,普遍在20-40元/天,这对于许多人来说难以接受。笔者注意到,短效IP的性价比很高,一个IP只需几分钱,平均每天0.2-3元。
|
||||
做渗透测试时,经常需要隐藏或更换 IP 来绕过安全设备。但市面上的隧道代理价格高昂,普遍在 3-6 元/天,
|
||||
对很多人来说难以负担。而作者注意到,短效 IP 的性价比极高,每个 IP 只要几分钱,平均 0.03-0.4 元/天。
|
||||
|
||||
综上所述,**ProxyCat** 应运而生!本工具旨在将持续时间仅有1分钟至60分钟不等的短效IP转变为固定IP供其他工具使用,形成代理池服务器,部署一次即可永久使用。
|
||||
于是 **ProxyCat** 诞生了!本工具旨在将短效 IP(有效期 1-60 分钟)转换为固定 IP 供其他工具使用,
|
||||
打造一个部署一次即可永久使用的代理池服务器。
|
||||
|
||||

|
||||
|
||||
## 功能特点
|
||||
## 核心特性
|
||||
|
||||
- **两种协议监听**:支持 HTTP/SOCKS5 协议监听,兼容更多工具。
|
||||
- **三种代理地址**:支持 HTTP/HTTPS/SOCKS5 代理服务器及身份鉴别。
|
||||
- **灵活切换模式**:支持顺序、随机及自定义代理选择,优化流量分配。
|
||||
- **动态获取代理**:通过 GetIP 函数即时获取可用代理,支持 API 接口调用。
|
||||
- **代理保护机制**:在使用 GetIP 方式获取代理时,首次运行不会直接请求获取,将会在收到请求的时候才获取。
|
||||
- **自动代理检测**:启动时自动检测代理有效性,剔除无效代理。
|
||||
- **智能切换代理**:仅在请求运行时获取新代理,减少资源消耗。
|
||||
- **失效代理切换**:代理失效后自动验证切换新代理,确保不中断服务。
|
||||
- **身份认证支持**:支持用户名/密码认证和黑白名单管理,提高安全性。
|
||||
- **实时状态显示**:展示代理状态和切换时间,实时掌握代理动态。
|
||||
- **动态更新配置**:无需重启服务,动态检测配置并更新。
|
||||
- **Web UI界面**:提供 Web 管理界面,操作管理更加便捷。
|
||||
- **Docker部署**:Docker 一键部署,Web 统一管理。
|
||||
- **中英文双语**:支持中文英文一键切换。
|
||||
- **配置灵活**:通过 config.ini 文件自定义端口、模式和认证信息等。
|
||||
- **版本检测**:自动检查软件更新,保证版本最新。
|
||||
- **双协议监听**:同一个端口同时接受 HTTP 与 SOCKS5 入站请求;上游支持带认证的 HTTP / HTTPS / SOCKS5 代理。[详情](ProxyCat-Manual/Features.md#出口代理服务)
|
||||
- **三种代理来源**:`local` 读本地列表、`api` 实时取接口、`pool` 直接用内置代理池,面板上切换即时生效。
|
||||
- **多出口并发**:一直维持多个上游出口同时服务,请求按各出口的实时负载分摊,上游按出口 IP 限流时并发随出口数成倍放大。[上游出口与并发](ProxyCat-Manual/Features.md#上游出口与并发)
|
||||
- **失败占比淘汰**:出口故障看滑动窗口里的失败占比,而不是连续失败次数 —— 把「一次网络抖动」和「这个出口就是坏的」区分开。
|
||||
- **高峰期自动扩容**:负载顶上来了自动加出口,高峰过去一步收回;扩容与收回看的是两个不同的数,避免出口数来回震荡。
|
||||
- **内置代理池**:插件化抓取、先验证再入库、三档验证强度、健康评分、离线归属地,自动重验证与自动清理。[内置代理池](ProxyCat-Manual/Features.md#内置代理池)
|
||||
- **真实使用反馈**:运行期转发发现的坏出口会回流到池,连续失败达阈值即标记失效,重验证测通后自动恢复。
|
||||
- **访问控制与出口认证**:客户端 IP 白名单 / 黑名单按优先级判定,绕过名单让指定目标直连;出口支持账号密码认证。
|
||||
- **Web 管理面板**:代理配置、代理池、访问控制、运行日志四个页签,配置、名单、日志与服务启停一屏完成。[面板与运维](ProxyCat-Manual/Features.md#面板与运维)
|
||||
- **容器化部署**:Docker 一键构建启动,容器以非 root 运行,健康检查按实际面板端口探活。
|
||||
|
||||
## 工具使用
|
||||
## 外观展示
|
||||
|
||||
[ProxyCat操作手册](../main/ProxyCat-Manual/Operation%20Manual.md)
|
||||
面板是一个单页,左侧四个页签:代理配置、代理池、访问控制、运行日志。
|
||||
|
||||
## 报错排查
|
||||
| 代理配置 · 来源设置 | 代理池 · 代理管理 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| 在来源设置里选择代理来源(本地代理 / API代理 / 维护池代理),并填写当前来源需要的参数;图中为维护池代理的远程代理池地址(留空即用内置池)。 | 抓到的代理一览:协议、真实 IP、归属地、延迟、健康分与匿名度。 |
|
||||
|
||||
[ProxyCat排查手册](../main/ProxyCat-Manual/Investigation%20Manual.md)
|
||||
| 访问控制 | 运行日志 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| 出口认证账号、IP 黑白名单与代理绕过名单都在这里。 | 实时日志、历史文件回看,以及访问统计与逐条明细。 |
|
||||
|
||||
## 免责申明
|
||||
| 深色主题 · 出口轮换 | 英文界面 · 代理管理 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| 深色主题,布局与浅色完全相同。 | 英文界面,与中文界面共用同一份数据与服务端设置。 |
|
||||
|
||||
- 如果您下载、安装、使用、修改本工具及相关代码,即表明您信任本工具。
|
||||
- 在使用本工具时造成对您自己或他人任何形式的损失和伤害,我们不承担任何责任。
|
||||
- 如您在使用本工具的过程中存在任何非法行为,您需自行承担相应后果,我们将不承担任何法律及连带责任。
|
||||
- 请您务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款,并选择接受或不接受。
|
||||
- 除非您已阅读并接受本协议所有条款,否则您无权下载、安装或使用本工具。
|
||||
- 您的下载、安装、使用等行为即视为您已阅读并同意上述协议的约束。
|
||||
面板提供深色 / 浅色两档主题,未手动切换过时跟随系统的深色偏好;界面支持中文 / 英文切换,
|
||||
语言是服务端设置,切换后整站文案与导出文件的表头一起变。
|
||||
|
||||
## 更新日志
|
||||
## 快速部署
|
||||
|
||||
[更新日志记录](../main/ProxyCat-Manual/logs.md)
|
||||
### Docker 部署(推荐)
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Compose 会构建镜像并启动容器,两个端口分别是:
|
||||
|
||||
| 端口 | 用途 |
|
||||
|---|---|
|
||||
| `1080` | 代理监听端口(HTTP 与 SOCKS5 共用) |
|
||||
| `5001` | Web 面板端口 |
|
||||
|
||||
三个目录通过 bind mount 挂到宿主,数据和配置不会随容器销毁而丢失:`config/`、`logs/`、
|
||||
`modules/proxypool/data/`。
|
||||
|
||||
### 源码运行
|
||||
|
||||
需要 **Python 3.10 及以上**:源码大量使用运行时求值的 `X | Y` 联合类型注解,3.9 及更早版本
|
||||
会在导入阶段就报 `TypeError`。
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Windows 上命令名为 python
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
`app.py` 会同时拉起 Web 面板与出口代理服务。仓库自带的 `config/config.ini` 出厂
|
||||
`proxy_source_mode=pool`(程序内置默认是 `local`),因此按这份配置启动时,**内置代理池会随
|
||||
服务自动启动**,开箱即有出口可用。
|
||||
|
||||
### 验证部署是否成功
|
||||
|
||||
两条命令就能确认服务真的在工作:
|
||||
|
||||
```bash
|
||||
# 1. 面板在监听:期望 302(跳转到 /web)
|
||||
curl -i http://127.0.0.1:5001/
|
||||
|
||||
# 2. 代理在转发:期望 200,且请求确实经过了上游代理
|
||||
# 仓库自带配置的 [Users] 段有账号,请求须带认证,不带会得到 407
|
||||
curl -x http://neko:[email protected]:1080 https://www.baidu.com -I
|
||||
```
|
||||
|
||||
### 首次部署必读
|
||||
|
||||
这四条都是**先出症状、后找原因**的典型,完整说明见[使用手册的「部署前必须知道的四件事」](ProxyCat-Manual/Operation%20Manual.md#部署前必须知道的四件事):
|
||||
|
||||
1. **面板打不开,返回 401**:设置了 token 时**必须带上 `?token=` 才能打开**;仓库自带的 token 是公开的,对外使用前务必改掉 `config.ini` 的 `[Server] token` —— 改完约 1 秒内生效,旧 token 随即失效。
|
||||
2. **容器起来了,却写不进配置或日志**:容器以非 root 运行,UID/GID 必须与宿主 `config/`、`logs/`、`modules/proxypool/data/` 三个目录的属主一致,不同就在 `.env` 里设置 `PUID` / `PGID`。
|
||||
3. **重启后丢了最后一批数据**:停止时应用要排空写队列,`stop_grace_period` 已放宽到 60 秒,用 Docker 默认的 10 秒会在排空前被 `SIGKILL`,**请不要改小它**。
|
||||
4. **改了 `web_port` 之后容器变成 unhealthy**:健康检查按 `config.ini` 里实际的 `web_port` 现读现探,改完必须重启进程并同步 `docker-compose.yml` 的端口映射。
|
||||
|
||||
### 网络行为提示
|
||||
|
||||
ProxyCat 除转发客户端流量外,自身还会主动发起一些对外请求,其中多条是**直连**的(以本机真实 IP 发出),例如版本检查、
|
||||
代理池的本机公网 IP 查询、GeoNode 与 GitHub 抓取插件和离线归属地库更新。这些请求都可以关闭或改用自有地址(版本检查没有专属开关,需从网络层屏蔽);
|
||||
逐条的触发时机、目的地与关闭方式见[功能特性](ProxyCat-Manual/Features.md#对外网络请求)。经代理转发的流量不在此列——那是客户端自己的流量。
|
||||
|
||||
## 文档导航
|
||||
|
||||
README 只保留基本介绍与快速部署,其余内容都在下列文档里:
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [功能特性](ProxyCat-Manual/Features.md) | 出口代理服务、内置代理池、面板与运维、上游出口与并发机制、对外网络请求、性能基准与项目结构 |
|
||||
| [详细配置](ProxyCat-Manual/Configuration.md) | `[Server]` / `[Users]` / `[api_credentials]` / `[Pool]` 全部配置项:默认值、说明与生效方式 |
|
||||
| [使用手册](ProxyCat-Manual/Operation%20Manual.md) | 安装部署、运行方式、面板逐页讲解、代理池使用、日志与访问记录、常见问题 Q&A |
|
||||
| [报错排查手册](ProxyCat-Manual/Investigation%20Manual.md) | 常见报错的判断与处理 |
|
||||
| [更新日志](ProxyCat-Manual/logs.md) | 各版本的改动记录 |
|
||||
| [接口文档](API.md) | 面板 `/api/*` 与代理池 `/api/pool/*` 的参数、响应示例与错误码 |
|
||||
| [代理池模块文档](modules/proxypool/README.md) | 抓取插件开发、验证策略、匿名度判定、归属地解析、自动清理等实现细节 |
|
||||
|
||||
**上表每一份文档都有英文版** —— 与中文版同名、加 `-EN` 后缀(如 `ProxyCat-Manual/Features-EN.md`、`API-EN.md`),英文入口见 [README-EN.md](README-EN.md)。
|
||||
|
||||
## 免责声明
|
||||
|
||||
- 您下载、安装、使用、修改本工具及相关代码,即表示您信任本工具。
|
||||
- 使用本工具对自己或他人造成的任何形式的损失和伤害,我们不承担责任。
|
||||
- 您使用本工具进行的任何非法行为,由您本人承担全部责任。
|
||||
- 请仔细阅读并完全理解各项条款,尤其是免责条款。
|
||||
- 如果您没有阅读并接受全部条款,您无权下载、安装或使用本工具。
|
||||
- 您的下载、安装和使用行为即表示您接受本协议。
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 **GNU General Public License v2.0(GPL-2.0)**,完整条款见 [LICENSE](LICENSE)。
|
||||
|
||||
- 这是**传染性许可**:分发本项目的修改版或衍生作品时,必须同样以 GPL-2.0 授权并提供源码。
|
||||
- 本软件**不提供任何担保**,这也是上面免责声明的由来。
|
||||
|
||||
## 开发计划
|
||||
|
||||
- [ ] **增加请求切换IP配置**:支持设置随机范围内的请求次数来切换IP,提升对抗阈值类防御的能力。
|
||||
- [ ] **babycat模块**:通过部署babycat子模块,可以在任意服务器或主机上快速搭建代理服务器,并通过Web端实现统一管理,简化操作流程。(针对红队进行开发强化)
|
||||
- [ ] **爬虫代理池**:利用爬虫抓取免费代理地址,构建代理池。该代理池能够持续维护可用的高质量代理资源,支持负载均衡下的随机调用,同时允许用户指定IP归属地,满足不同场景需求。
|
||||
- [ ] **机场协议支持**:接入机场协议后,可将每个节点作为代理地址使用,扩展代理服务器的功能和灵活性。
|
||||
- [ ] **域名/IP黑白名单**:提供目标域名或IP的黑白名单配置功能,类似VPN规则模式,实现伪全局代理效果,确保特定流量按需转发。
|
||||
- [ ] **版本自动升级**:内置版本自动升级模块,确保软件始终运行在最新版本,减少手动维护的工作量,同时提升安全性与兼容性。
|
||||
- [x] 添加详细日志,记录所有连接 ProxyCat 的 IP 身份,支持多用户。
|
||||
- [x] 添加 Web UI,提供更强大易用的界面。
|
||||
- [x] 内置代理池:插件化抓取、验证、评分、归属地与自动清理。
|
||||
- [ ] 开发 babycat 模块,可在任意服务器或主机上运行,将其变成代理服务器。
|
||||
- [ ] 请求黑白名单:指定 URL、IP 或域名强制丢弃或绕过(绕过名单已支持,其余待补)。
|
||||
- [ ] 打包到 PyPi,方便安装使用。
|
||||
|
||||
如果您有好的创意,或在使用过程中遇到bug,请通过以下方式联系作者反馈!
|
||||
如果您有好的想法或在使用中遇到 BUG,欢迎通过以下方式联系作者:
|
||||
|
||||
微信公众号:**樱花庄的本间白猫**
|
||||
|
||||
## 特别鸣谢
|
||||
## 特别感谢
|
||||
|
||||
本排名不分先后,感谢为本项目提供帮助的师傅们。
|
||||
排名不分先后,感谢所有为本项目提供帮助的贡献者:
|
||||
|
||||
- [AabyssZG (曾哥)](https://github.com/AabyssZG)
|
||||
- [ProbiusOfficial (探姬)](https://github.com/ProbiusOfficial)
|
||||
@@ -90,28 +183,22 @@
|
||||
- chars6
|
||||
- qianzai(千载)
|
||||
- ziwindlu
|
||||
- yuzhegan
|
||||
- 摘星怪
|
||||
|
||||
## 赞助开源
|
||||
## 赞助
|
||||
|
||||
开源不易,如果您觉得工具不错,或许可以试着赞助一下作者的开发哦~
|
||||
开源不易,如果觉得本工具有帮助,欢迎赞助作者开发!
|
||||
|
||||
---
|
||||
| 排名 | ID | 赞助金额(元) |
|
||||
| :--: | :-----------------: | :------------: |
|
||||
| 1 | **陆沉** | 18302.7 |
|
||||
| 2 | **乡村牛公子** | 976 |
|
||||
| 3 | **柯林斯.民间新秀** | 696 |
|
||||
| [赞助榜单](https://github.com/honmashironeko/Thanks-for-sponsorship) | 您的每一份赞助都是作者源源不断的动力! | (´∀`)♡ |
|
||||
| 排名 | ID | 金额 (CNY) |
|
||||
| :--: | :--: | :--------: |
|
||||
| 1 | **陆沉** | 1266.62 |
|
||||
| 2 | **柯林斯.民间新秀** | 696 |
|
||||
| 3 | **北** | 170 |
|
||||
| [赞助名单](https://github.com/honmashironeko/Thanks-for-sponsorship) | 每一份赞助都是作者的动力! | (´∀`)♡ |
|
||||
|
||||
---
|
||||

|
||||
|
||||
## 代理推荐
|
||||
|
||||
- [第一家便宜大碗代理购买,用邀请码注册得5000免费IP+10元优惠券](https://h.shanchendaili.com/invite_reg.html?invite=fM6fVG)
|
||||
- [各大运营商流量卡](https://172.lot-ml.com/ProductEn/Index/0b7c9adef5e9648f)
|
||||
- [国外匿名代理](https://www.ipmart.io?source=Shironeko)
|
||||
|
||||

|
||||
- [第一家平价代理服务商 - 使用邀请码可获 5000 免费 IP + 10 元优惠券](https://h.shanchendaili.com/invite_reg.html?invite=fM6fVG)
|
||||
- [各类运营商数据套餐](https://172.lot-ml.com/ProductEn/Index/0b7c9adef5e9648f)
|
||||
- [点击这里购买](https://www.ipmart.io?source=Shironeko)
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 172 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "example-provider",
|
||||
"enabled": true,
|
||||
"title": "广告赞助",
|
||||
"body": "良好的发展少不了资金的支持,如果您觉得ProxyCat对您有帮助,欢迎赞助我们。",
|
||||
"image_url": "",
|
||||
"link_url": "https://github.com/honmashironeko/ProxyCat",
|
||||
"link_text": "了解更多",
|
||||
"display_time": 10
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "example-provider",
|
||||
"enabled": true,
|
||||
"title": "示例广告ProxyCat",
|
||||
"body": "提供稳定可靠的代理服务,支持多种协议和平台,满足您的网络需求。",
|
||||
"image_url": "https://camo.githubusercontent.com/710ce9d95a20828dd7b27be704578bbf2c3a9c60be6efc8e08d57a3c940e159e/68747470733a2f2f736f6369616c6966792e6769742e63692f686f6e6d61736869726f6e656b6f2f50726f78794361742f696d6167653f637573746f6d5f6465736372697074696f6e3d254534254238253830254536254143254245254536253934254146254536253843253831254535254134253941254535253844253846254538254145254145254537253941253834254539253941254137254539253831253933254534254242254133254537253930253836254536254231254130254534254238254144254539253937254234254534254242254236254536253943253844254535253841254131254546254243253843254535254145253945254537253845254230254534254244253845254536253838253930254536253943254143254534254242254133254537253930253836254537253941253834254538253837254141254535253841254138254538254244254145254536253844254132266465736372697074696f6e3d3126666f6e743d42697474657226666f726b733d31266973737565733d31266c616e67756167653d31266c6f676f3d6874747073253341253246253246617661746172732e67697468756275736572636f6e74656e742e636f6d25324675253246313339303434303437253346762533443425323673697a6525334431303830266e616d653d31266f776e65723d31267061747465726e3d436972637569742b426f6172642670756c6c733d31267374617267617a6572733d31267468656d653d4461726b",
|
||||
"link_url": "https://github.com/honmashironeko/ProxyCat",
|
||||
"link_text": "了解更多",
|
||||
"display_time": 10
|
||||
}
|
||||
@@ -1,23 +1,270 @@
|
||||
[Server]
|
||||
# 控制台详细程度:0 最简、2 最详(2 与 3 的行为逐条相同,填 3 会被归一成 2);
|
||||
# 2 会在启动横幅后附上各级别说明
|
||||
display_level = 1
|
||||
# 代理监听端口,HTTP 与 SOCKS5 共用(1-65535)
|
||||
port = 1080
|
||||
web_port = 5000
|
||||
mode = cycle
|
||||
interval = 300
|
||||
use_getip = false
|
||||
getip_url = http://example.com/getip
|
||||
# Web 面板端口(1-65535)
|
||||
web_port = 5001
|
||||
# 运行模式。这个键在不同来源下问的是**两件不同的事**,取值也不同:
|
||||
# local 来源(本地代理文件)—— 问「挑谁」:
|
||||
# cycle 按清单顺序使用(忽略负载,前一条满了才用下一条)
|
||||
# loadbalance 谁压力小先用谁
|
||||
# api / pool 来源 —— 问「什么时候换」:
|
||||
# continuous 持续更换:与有没有流量无关,后台按寿命主动换掉到期的并补满
|
||||
# request 触发更换:只在收到新任务时取货、换出口;没有任务时到期的出口直接移除,
|
||||
# 池子会空掉,下一个请求到达时重新取货填满
|
||||
# 换来源时若当前值不属于新那一组,会自动归一到该组的默认值(列表中第一个)
|
||||
mode = request
|
||||
# 每个出口的寿命上限(秒):入池满这么久就被换掉。每个出口各自计时(就是「自己入池 +
|
||||
# interval」,一批同时入池的因此同时到期),到期的全部更换,不是每隔这么久只换一个。
|
||||
# 所有来源、四种运行模式都生效(本地来源也按寿命轮换,换上来的是文件里的下一条;
|
||||
# 文件里没有备用条目时原地重开一次寿命)。**换的时机**由运行模式决定:持续更换在后台
|
||||
# 主动换,触发更换只在收到新任务时换、空闲时到期的直接移除。
|
||||
# 它与下面的 request_interval 是**两条各自独立的规则,同时生效**,两个都填时谁先到谁换,
|
||||
# 不是二选一。0 表示不按时间计
|
||||
interval = 60
|
||||
# 代理来源:local(读 proxy_file)/ api(取 api_proxy_url)/ pool(用代理池)
|
||||
proxy_source_mode = pool
|
||||
# 代理来源为 api 时,取代理地址的接口
|
||||
api_proxy_url =
|
||||
# 拼接到取回的代理地址上的认证账号;与 proxy_password 都非空才生效,
|
||||
# 不用于接口本身(api_proxy_url)的认证
|
||||
proxy_username =
|
||||
# 拼接到取回的代理地址上的密码,与 proxy_username 成对使用
|
||||
proxy_password =
|
||||
# 代理来源为 local 时的代理列表文件名;只取文件名,固定从 config/ 目录读取
|
||||
proxy_file = ip.txt
|
||||
check_proxies = true
|
||||
test_url =
|
||||
# 代理存活检测的目标地址;代理池的 validator.target_url 也由它派生
|
||||
test_url = https://www.baidu.com
|
||||
# 界面与提示语言:cn / en
|
||||
language = cn
|
||||
# IP 白名单文件名;只取文件名,固定从 config/ 目录读取
|
||||
whitelist_file = whitelist.txt
|
||||
# IP 黑名单文件名;只取文件名,固定从 config/ 目录读取
|
||||
blacklist_file = blacklist.txt
|
||||
# 同时命中黑白名单时的判定优先级:whitelist(放行)/ blacklist(拦截)
|
||||
ip_auth_priority = whitelist
|
||||
# Web 面板访问令牌,经 ?token= 校验;留空表示面板不校验
|
||||
token = honmashironeko
|
||||
# 每个出口的寿命上限(分配出去多少次后更换):也是每个出口各数各的。数的是「分配次数」,
|
||||
# 一次请求用到它算一次,重试换到另一个出口会在那边再算一次。它与上面的 interval 是
|
||||
# **两条各自独立的规则,同时生效**,两个都填时谁先到谁换。0 表示不按请求数计
|
||||
request_interval = 0
|
||||
# 启动时是否先检测一遍代理可用性
|
||||
check_proxies_on_startup = true
|
||||
# 把候选填进出口池之前是否先校验一次(发一次真实请求);不合格的丢掉换下一条。
|
||||
# 不是按请求校验:出口在服役期间失效靠失败淘汰兜底
|
||||
check_proxies_on_use = true
|
||||
# 远程代理池的管理接口地址;留空表示使用进程内的代理池
|
||||
pool_remote_url =
|
||||
# 批量检测代理的并发数上限(1-1000)
|
||||
check_concurrency = 100
|
||||
# 日志级别:DEBUG / INFO / WARNING / ERROR / CRITICAL
|
||||
log_level = INFO
|
||||
# 单个日志文件的大小上限(字节),超出后轮转
|
||||
log_max_bytes = 10485760
|
||||
# 轮转后保留的历史文件份数;至少 1(0 会让日志永不轮转)
|
||||
log_backup_count = 3
|
||||
# 是否把每次代理访问写进访问日志(logs/access.log)
|
||||
log_access_enabled = true
|
||||
# 绕过名单文件名,其中的目标地址不做拦截判断;只取文件名,固定从 config/ 目录读取
|
||||
bypass_whitelist_file = bypass_whitelist.txt
|
||||
# 重取出口批次后的冷却时间(秒);冷却内不再重取(补货也受它限流)。对三种来源一视同仁
|
||||
switch_cooldown = 1
|
||||
# 代理可用性检测结果的缓存有效期(秒)
|
||||
proxy_check_ttl = 30
|
||||
# 两次可用性检测之间的冷却时间(秒)
|
||||
check_cooldown = 5
|
||||
# 故障告警的折叠窗口(秒):同一类告警在窗口内只写一条日志,并记下被折叠了多少条。
|
||||
# 高并发下失败是成批来的,不折叠日志会被同一句话刷满。折叠**只发生在日志层**,
|
||||
# 出口的记账与调度不受影响(那套看的是近期失败占比,不靠计时器)。
|
||||
proxy_failure_cooldown = 3
|
||||
# 明文 HTTP 转发累计多少字节就施加一次背压(等客户端把数据读走),最小 1024。
|
||||
# 它不控制切块大小 —— 响应体按上游交付的原生块(通常 64 KiB)转发,不做重切;
|
||||
# 隧道中继固定 64 KiB,也不受这一项影响
|
||||
buffer_size = 8192
|
||||
# 隧道建立后「客户端发过数据、上游一个字节都没回」持续多久判为上游装死(秒);
|
||||
# 0 表示不判。这是发现「收下 CONNECT 后不回数据」的唯一手段,但上游只是排队很慢
|
||||
# 时也会命中 —— 上游在高并发下响应普遍超过这个值,就把它调大
|
||||
tunnel_idle_timeout = 10
|
||||
# 同时处理的请求数上限,超出后在信号量上排队
|
||||
max_concurrent_requests = 3000
|
||||
# 连接池中缓存的客户端数量上限,达到上限时淘汰最久未使用的。**面板不再展示这一项**:
|
||||
# 实际取值是 max(本值, 名册数 × 2, 出口数 × 4) —— 按当前名册与扩容上限推算,
|
||||
# 低于推算值时本值被忽略(低于它每个出口的客户端会互相淘汰,连接复用就没了)。
|
||||
# 留在这里只为了兼容老配置里调大过的值
|
||||
max_pool_size = 500
|
||||
# 单个上游代理的活跃连接上限;0 表示不限制。数的是「正在用」的连接:请求转发期间算,
|
||||
# 隧道建立后只有真的在传数据才算,空闲隧道不占额度(所以它是一道准入控制,不是同时在
|
||||
# 挂的上游连接数的硬上限)。总上游并发 ≈ 出口数 × 该值(受上面一项封顶)
|
||||
max_concurrent_per_proxy = 0
|
||||
# 出口池的目标容量:保持这么多个上游出口在用。**换的时机由运行模式决定**:持续更换
|
||||
# (continuous)与流量无关、后台一直补满;触发更换(request,本仓库出厂值)与本地来源
|
||||
# 由请求驱动 —— 空闲约 10 秒后不再补货,到期的出口被移除而不是换新,池子可能空掉
|
||||
# (池子空着或缺口挂着,下一个请求就要在请求路径上干等一次取货 + 校验)。取回后并发
|
||||
# 校验候补、逐槽填到满,某个出口失效就单独补上它,不整批替换。pool / local 按它取用
|
||||
# (local 把它当上限,文件不够就用多少算多少);api 取回几条由接口自己决定,这里只决定
|
||||
# 要用几条。调小它,名册当场收回多余的(不等寿命);每个出口一个客户端,调很大要留意
|
||||
# 连接与内存开销
|
||||
exit_count = 10
|
||||
# 高峰期自动扩容出口池,两条触发路径:
|
||||
# ① 立刻 —— 监听端口上此刻挂着的连接数超过「在用出口数 × 单出口额度」时,当场把出口数
|
||||
# 翻倍(上限 2 倍)并一次补齐,不用等水位。5 个出口 × 100 的额度撞上 1000 并发,
|
||||
# 靠的就是这一条。
|
||||
# ② 兜底 —— 后台按「顶到单出口额度的出口占比」(八成持续 1 秒)逐格加出口。
|
||||
# 收回看「整池利用率」(一半以下持续 30 秒),阈值与时长固定在代码里、不开放配置。
|
||||
# 只在取回过货、拿到答复、却没有可用新候选时才放宽单出口额度(最多到 2 倍)—— 取货失败
|
||||
# 或超时不算;名册上限 = max(exit_count, min(exit_count × 2, max_concurrent_requests)),
|
||||
# 到上限就停。停止扩容后,多出来的出口**用满自己的寿命**才逐个撤下,不会刚取回来就丢掉。
|
||||
# max_concurrent_per_proxy 为 0(不限)时不扩,但已经张开的膨胀状态照常收回 —— 那时
|
||||
# 出口层面不会排队
|
||||
auto_expand_enabled = true
|
||||
# 一个可用出口都没有时(刚启动、换来源、出口全被停用、取代理失败),请求排队等
|
||||
# 多久(秒);等到有出口就照常服务,超时才回 503。填 0 表示不等、当场回 503
|
||||
exit_wait_timeout = 30
|
||||
# 每个上游出口的 HTTP 客户端连接上限。**面板不再展示这一项**:实际取值是
|
||||
# max(本值, 单出口并发上限 × 2),单出口不限并发时退回全局并发上限 —— 设小了会把
|
||||
# 请求闷进 httpx 连接池排队。留在这里只为了兼容老配置里调大过的值
|
||||
client_max_connections = 1000
|
||||
# 每个上游出口保持的保活连接数。**不要调大**:httpx 的连接池在分配请求时会对
|
||||
# 空闲连接做一遍全池扫描(httpcore 1.0.9 的实现),空闲连接越多,每个请求花在这
|
||||
# 上面的 CPU 越多 —— 实测池里空闲连接几十条时它就能吃掉单核的一半以上,吞吐掉到
|
||||
# 三分之一。压小这一项(连接用完就关、用时再建)实测在有无上游延迟两种场景下都
|
||||
# 更快;真要让单个出口扛更多并发,调 client_max_connections 而不是这一项
|
||||
client_max_keepalive = 8
|
||||
# 空闲回收(秒):保活连接空闲这么久就失效;装这些连接的客户端按同一个时间回收
|
||||
# (它不该比连接活得短)。调小会让刚预热好的连接被丢掉、连接复用变差
|
||||
client_keepalive_expiry = 30
|
||||
# 客户端的连接池空闲多久后回收(秒)。**面板不再展示这一项**:由上面的
|
||||
# client_keepalive_expiry 推出,取 max(它, 30)。留在这里只为了兼容老配置
|
||||
client_idle_timeout = 300
|
||||
# 是否按「域名 × 上游代理」累计访问统计(面板「运行日志」页的访问统计)
|
||||
domain_stats_enabled = true
|
||||
# 访问统计的落库间隔(秒)
|
||||
domain_stats_flush_interval = 15
|
||||
# 访问统计的保留天数;0 表示不清理
|
||||
domain_stats_retention_days = 30
|
||||
# 是否逐条记录每一次代理请求(面板「运行日志」页的访问明细)
|
||||
access_records_enabled = true
|
||||
# 逐条记录的落库间隔(秒)
|
||||
access_records_flush_interval = 5
|
||||
# 逐条记录的保留天数;0 表示不按时间清理
|
||||
access_records_retention_days = 7
|
||||
# 逐条记录的行数上限,超出后裁掉最旧的;0 表示不限制
|
||||
access_records_max_rows = 200000
|
||||
# 逐条记录的内存缓冲条数,超出后丢最旧的并计数;调小需重启生效
|
||||
access_records_buffer_size = 20000
|
||||
# 出口地址是网关时,经它探测一次真实出口 IP(仅 API 来源;每个地址多消耗一次代理流量)。
|
||||
# 代理池来源直接用池里已验证的出口 IP,本地代理不探测
|
||||
access_records_real_ip_probe = true
|
||||
|
||||
[Users]
|
||||
neko = 123456
|
||||
# 出口代理的认证账号:键为用户名、值为密码;整段为空表示代理不要求认证。
|
||||
# 请通过面板的用户管理修改 —— 直接编辑本段不会自动同步到运行中的服务。
|
||||
k = 123
|
||||
neko = 123456
|
||||
|
||||
[api_credentials]
|
||||
# 可用的取 IP 凭据集合,JSON 数组,每项为 {"name", "url", "username", "password"};
|
||||
# 由面板维护,手改本行必须保持为合法 JSON,否则整份凭据都读不出来。
|
||||
credential_sets = []
|
||||
# 当前启用的凭据名,须与 credential_sets 中某一项的 name 完全一致;不一致时视为未选用
|
||||
active_credential =
|
||||
|
||||
[Pool]
|
||||
# 代理资源池配置。修改后保存即可热生效,无需重启;
|
||||
# 也可以通过 Web 界面的「池设置」页面修改 —— 两边是同一份配置。
|
||||
# ==================================================================
|
||||
|
||||
# ── 数据库 ──────────────────────────────────────────────────
|
||||
# 代理池所有数据存放的文件(SQLite 格式)。填相对路径时以 modules/proxypool/ 目录为起点。改这一项保存后不会立即生效,需重启池服务 —— 库在启动时就已经打开了
|
||||
database.path = data/proxies.db
|
||||
# 同时查询数据库的连接数上限,写入固定只占 1 个连接。调大能让并发查询更顺畅,也会多占用一些文件句柄;池子不大时保持默认即可。改这一项保存后不会立即生效,需重启池服务
|
||||
database.pool_max_readers = 4
|
||||
# 是否按下面的间隔自动备份数据库;保存后立即生效(关掉即停,重新打开即起)
|
||||
database.backup_enabled = true
|
||||
# 自动备份的间隔(小时)
|
||||
database.backup_interval_hours = 24
|
||||
# 备份文件保留多少天,超期自动删除
|
||||
database.backup_retention_days = 7
|
||||
|
||||
# ── 代理验证 ────────────────────────────────────────────────
|
||||
# 是否检查代理的匿名度。匿名度靠「目标站点实际收到了哪些请求头」判定,只在经代理发一次明文请求时才测得出来;关掉后不再发这类请求、也不再判匿名度,已经测出来的等级原样保留
|
||||
validator.check_anonymity = true
|
||||
# 本机公网出口 IP,判定「透明代理」时的对比基准。双栈主机两族各填一个、用逗号隔开(IPv4 与 IPv6 各至多一个),两族都会参与比对;只填一个也能用。留空时 ProxyCat 会**不经过代理**直接访问 IP 查询站点获取(每 30 分钟最多一次,成功与失败都重新计时);在这里填上你自己的出口 IP 可以完全免掉这次对外请求。留空或拿不到时无法判定匿名度,那一项会保持原样
|
||||
validator.host_public_ip =
|
||||
# 验证一个代理最多等多少秒,超过就判为不可用。所有探测请求都按这个上限
|
||||
validator.timeout_seconds = 5
|
||||
# 连接代理最多等多少秒。失效的代理大多卡在连不上这一步,调小它可以明显加快整批验证;填 0 表示不单独限制,只受上面的总超时约束
|
||||
validator.connect_timeout_seconds = 2.0
|
||||
# 同时验证多少个代理。每次验证连的是不同主机,可以放心调高;同时它也决定本机连接池上限与可打开的 socket 数(一次验证会并发发出十几个请求),受限环境应调低;如果测试站点开始限流(表现为大量代理突然超时),也调低一些
|
||||
validator.max_concurrent = 400
|
||||
# 用来探测出口 IP 和匿名度的接口,多个用英文逗号分隔,必须是 https。这类接口要能返回 origin 与 headers,一次请求就能同时得出出口 IP、匿名度和 HTTPS 支持情况。连不上的会被自动跳过;可用的按直连延迟排序后逐个补发竞速,不按条数截断,多写的留作替补
|
||||
validator.identity_echo_apis = https://httpbin.org/anything, https://httpbingo.org/anything, https://eu.httpbin.org/anything
|
||||
# 补充的明文回显接口,多个用英文逗号分隔,必须是 http。匿名度靠「目标实际收到了哪些请求头」判定,明文路径是它的标准观测面,默认用的是上面几个接口的明文地址。如果代理够不到那些站点、导致匿名度总被判成兜底档,可以把代理能访问的回显站点填在这里
|
||||
validator.plain_identity_echo_apis = http://postman-echo.com/get
|
||||
# 只返回 IP 的接口,多个用英文逗号分隔,必须是 https。上面的接口都拿不到结果时用它们兜底,同样按直连延迟逐个补发竞速,不按条数截断
|
||||
validator.ip_check_apis = https://icanhazip.com, https://checkip.amazonaws.com, https://api.ip.sb/ip, https://realip.cc/simple, https://httpbin.org/ip, https://api.ipify.org, https://ipinfo.io/json, https://ipwho.is/, https://www.cloudflare.com/cdn-cgi/trace
|
||||
|
||||
# ── 抓取插件 ────────────────────────────────────────────────
|
||||
# 多久检查一次有没有插件该抓取了(秒)。它只决定检查的频率,不改变各插件自己的抓取周期
|
||||
plugins.scan_interval_seconds = 60
|
||||
# 新插件默认多久抓取一次(分钟)。每个插件都可以单独调整自己的周期
|
||||
plugins.default_interval_minutes = 60
|
||||
# 单个插件一次抓取最多跑多少秒,超时就中断
|
||||
plugins.execution_timeout_seconds = 300
|
||||
# 插件请求一次网页最多等多少秒;目标站点打不开时按这个时间等待
|
||||
plugins.request_timeout_seconds = 30
|
||||
# 最多同时运行几个插件
|
||||
plugins.max_parallel_plugins = 3
|
||||
# 插件抓取失败后额外重试几次。填 2 表示最多一共尝试 3 次
|
||||
plugins.max_retries_on_failure = 2
|
||||
# 插件失败后重试前等待的秒数。每多试一次等待翻倍(第 1 次等 1 倍、第 2 次 2 倍、第 3 次 4 倍),避免短时间内反复打同一个站点
|
||||
plugins.retry_base_delay_seconds = 10.0
|
||||
|
||||
# ── 自动重新验证 ────────────────────────────────────────────
|
||||
# 是否定时重新检查已入库的代理还能不能用
|
||||
auto_revalidation.enabled = true
|
||||
# 默认多久重新检查一遍(分钟)。每个插件可以在「插件管理」里单独设置自己的周期
|
||||
auto_revalidation.interval_minutes = 360
|
||||
# 只检查当前可用的代理,跳过已经判定失效的
|
||||
auto_revalidation.only_valid_proxies = true
|
||||
# 启动后马上先检查一遍,而不是等满一个周期
|
||||
auto_revalidation.run_on_start = true
|
||||
# 多久做一次完整检测(分钟)。日常检查只看代理还通不通;出口 IP、匿名度、协议支持这些信息很少变,隔较长时间重新测一遍即可
|
||||
auto_revalidation.full_recheck_interval_minutes = 1440
|
||||
|
||||
# ── 自动清理 ────────────────────────────────────────────────
|
||||
# 是否自动删除质量太差的代理。删除不可恢复,拿不准时先关掉它只做检查
|
||||
auto_cleanup.enabled = true
|
||||
# 健康分低于这个值的代理会被删除,同时还要满足下一条的次数要求
|
||||
auto_cleanup.min_health_score = 10.0
|
||||
# 至少检查过多少次才允许按评分淘汰,避免刚入库的代理因为样本太少被误删
|
||||
auto_cleanup.min_checks_before_cleanup = 3
|
||||
# 连续失败这么多次就删除
|
||||
auto_cleanup.max_failures = 3
|
||||
# 判定失效后最多再保留几天,超过就删除
|
||||
auto_cleanup.max_age_days_if_invalid = 3
|
||||
|
||||
# ── 使用期反馈 ──────────────────────────────────────────────
|
||||
# 代理在实际转发中不通时,是否把它记为一次失败并在连续失败达阈值后标记失效。检测分不清「代理坏了」和「我们自己网络抖动」,阈值就是用来吃掉后者的;关掉后池只信自己的验证结果
|
||||
use_feedback.enabled = true
|
||||
# 连续失败这么多次就标记失效,期间任何一次成功都会清零
|
||||
use_feedback.invalid_threshold = 3
|
||||
|
||||
# ── 写入队列 ────────────────────────────────────────────────
|
||||
# 攒够多少条记录才一次性写进数据库
|
||||
write_queue.batch_size = 100
|
||||
# 一批记录最多尝试写几次(含第一次)。填 0 或 1 都表示失败就放弃
|
||||
write_queue.max_retries = 3
|
||||
# 等待写入的任务最多排多少个。排满后新任务会被拒绝,以免占用过多内存。这一项保存后不会立即生效,需重启服务(队列容量在创建时固定,中途改会丢掉已排队的数据)
|
||||
write_queue.max_queue_size = 10000
|
||||
|
||||
# ── 日志 ────────────────────────────────────────────────────
|
||||
# 代理池的日志文件。填相对路径时以 modules/proxypool/ 目录为起点
|
||||
logging.file_path = logs/proxy_pool.log
|
||||
# 单个日志文件的大小上限(字节)。写满后自动换一个新文件,旧文件继续保留
|
||||
logging.max_bytes = 10485760
|
||||
# 最多保留几个历史日志文件
|
||||
logging.backup_count = 5
|
||||
@@ -1,47 +1,123 @@
|
||||
from modules.modules import get_message, load_config
|
||||
import requests
|
||||
import time
|
||||
"""
|
||||
模块名称:config.getip
|
||||
功能描述:按 config.ini 的 api_proxy_url 请求一次,每次调用重新读配置;把响应每一行当作代理地址,清洗去重后补全协议与凭证,返回可直接使用的代理 URL 列表,不做缓存。
|
||||
职责边界:负责:读取接口地址与凭证、发起请求、逐行清洗去重、协议推断与凭证拼接、把失败统一翻译成 RuntimeError(响应无可用地址时原样抛 ProxyResponseError)。
|
||||
不负责:代理可用性验证与轮换(modules.proxyserver)、配置读写与校验(modules.modules)、失败日志落盘(由调用方记录)。
|
||||
关键依赖:requests;modules.modules(load_config、get_message、parse_proxy_url、format_proxy_url);tqdm(可选)。
|
||||
已知限制:
|
||||
1. 请求超时固定 15 秒,不可通过调用参数调整。
|
||||
2. 凭证仅在用户名与密码同时非空时注入,缺一则保留地址行自带的认证。
|
||||
3. 统一抛 RuntimeError;ProxyResponseError(空响应、清洗后无地址)为无前缀中文消息,其余形如「类型: 详情」(request/config/unknown)。
|
||||
4. api_proxy_url 为空或响应首行为 error000x-13 时按 config 类错误抛出;配置文件缺失时回落默认值(请求 example.com),不算 config 错误。
|
||||
5. 畸形行会被跳过并记一条 warning,不会让整批失败;未写协议的行按 http 补全。
|
||||
6. 单次返回条数受 _MAX_BATCH=500 封顶,limit 只在该上限内进一步裁剪(传 0 返回空列表)。
|
||||
"""
|
||||
|
||||
def newip():
|
||||
config = load_config()
|
||||
import os
|
||||
from modules.modules import format_proxy_url, get_message, load_config, parse_proxy_url
|
||||
import requests
|
||||
import logging
|
||||
|
||||
_GETIP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BASE_DIR = os.path.dirname(_GETIP_DIR)
|
||||
|
||||
_MAX_BATCH = 500
|
||||
|
||||
_ERROR_CODE_SENTINEL = "error000x-13"
|
||||
|
||||
|
||||
def _console_write(message: str) -> None:
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
tqdm.write(message)
|
||||
except Exception:
|
||||
print(message)
|
||||
|
||||
|
||||
class ProxyResponseError(RuntimeError):
|
||||
pass
|
||||
|
||||
def _clean_line(raw_line, username, password):
|
||||
text = raw_line.strip()
|
||||
if not text:
|
||||
return None
|
||||
if '://' not in text:
|
||||
text = f"http://{text}"
|
||||
|
||||
try:
|
||||
protocol, auth, host, port = parse_proxy_url(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not protocol or not host:
|
||||
return None
|
||||
|
||||
if username and password:
|
||||
auth = f"{username}:{password}"
|
||||
return format_proxy_url(protocol, host, port, auth)
|
||||
|
||||
|
||||
def newip_list(limit=None):
|
||||
config = load_config(os.path.join(_BASE_DIR, 'config', 'config.ini'))
|
||||
language = config.get('language', 'cn')
|
||||
|
||||
def handle_error(error_type, details=None):
|
||||
error_msg = 'whitelist_error' if error_type == 'whitelist' else 'proxy_file_not_found'
|
||||
print(get_message(error_msg, language, str(details)))
|
||||
raise ValueError(f"{error_type}: {details}")
|
||||
error_msg = {
|
||||
'request': 'proxy_get_error',
|
||||
'config': 'proxy_config_error',
|
||||
'unknown': 'proxy_get_error',
|
||||
}.get(error_type, 'proxy_file_not_found')
|
||||
_console_write(get_message(error_msg, language, str(details)))
|
||||
raise RuntimeError(f"{error_type}: {details}")
|
||||
|
||||
try:
|
||||
url = config.get('getip_url', '')
|
||||
url = config.get('api_proxy_url', '')
|
||||
username = config.get('proxy_username', '')
|
||||
password = config.get('proxy_password', '')
|
||||
|
||||
|
||||
if not url:
|
||||
raise ValueError('getip_url')
|
||||
|
||||
def get_proxy():
|
||||
response = requests.get(url)
|
||||
raise ValueError('api_proxy_url')
|
||||
|
||||
def fetch_body():
|
||||
response = requests.get(url, timeout=15)
|
||||
response.raise_for_status()
|
||||
return response.text.split("\r\n")[0]
|
||||
|
||||
proxy = get_proxy()
|
||||
if proxy == "error000x-13":
|
||||
appKey = ""
|
||||
anquanma = ""
|
||||
whitelist_url = f"https://sch.shanchendaili.com/api.html?action=addWhiteList&appKey={appKey}&anquanma={anquanma}"
|
||||
requests.get(whitelist_url).raise_for_status()
|
||||
time.sleep(1)
|
||||
proxy = get_proxy()
|
||||
|
||||
if username and password:
|
||||
return f"socks5://{username}:{password}@{proxy}"
|
||||
return f"socks5://{proxy}"
|
||||
|
||||
return response.text
|
||||
|
||||
lines = fetch_body().replace('\r\n', '\n').replace('\r', '\n').split('\n')
|
||||
entries = [line.strip() for line in lines if line.strip()]
|
||||
|
||||
if not entries:
|
||||
raise ProxyResponseError("接口返回内容为空,没有可用的代理地址")
|
||||
|
||||
if entries[0] == _ERROR_CODE_SENTINEL:
|
||||
raise ValueError(get_message('getip_api_error_code', language))
|
||||
|
||||
proxies = []
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
proxy = _clean_line(entry, username, password)
|
||||
if proxy is None:
|
||||
logging.warning(f"跳过形态非法的代理地址: {entry[:100]}")
|
||||
continue
|
||||
if proxy in seen:
|
||||
continue
|
||||
seen.add(proxy)
|
||||
proxies.append(proxy)
|
||||
|
||||
if not proxies:
|
||||
raise ProxyResponseError(f"接口返回的 {len(entries)} 行里没有一个可用的代理地址")
|
||||
|
||||
cap = _MAX_BATCH if limit is None else max(0, min(int(limit), _MAX_BATCH))
|
||||
return proxies[:cap]
|
||||
|
||||
except requests.RequestException as e:
|
||||
handle_error('request', e)
|
||||
except ValueError as e:
|
||||
handle_error('config', e)
|
||||
except ProxyResponseError:
|
||||
raise
|
||||
except Exception as e:
|
||||
handle_error('unknown', e)
|
||||
|
||||
|
||||
def newip():
|
||||
return newip_list(1)[0]
|
||||
@@ -1,2 +1 @@
|
||||
http://127.0.0.1:7890
|
||||
socks5://127.0.0.1:7890
|
||||
http://127.0.0.1:7897
|
||||
@@ -0,0 +1 @@
|
||||
http://1.2.3.4:8080
|
||||
@@ -1,13 +1,24 @@
|
||||
version: '3'
|
||||
services:
|
||||
proxycat:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
# 容器用户的 UID/GID 须对齐宿主上 config/logs/data 的属主(见 Dockerfile)
|
||||
PUID: ${PUID:-1000}
|
||||
PGID: ${PGID:-1000}
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
ports:
|
||||
- "1080:1080"
|
||||
- "5000:5000"
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./logs:/app/logs
|
||||
- ./modules/proxypool/data:/app/modules/proxypool/data
|
||||
restart: unless-stopped
|
||||
network_mode: "bridge"
|
||||
# 应用收尾时会排空池的写队列与两个落库线程:池 stop 默认预算 35 秒(见
|
||||
# modules/proxypool_service.py 的 stop),加上两次落库与 app.py 的 3 秒硬超时,
|
||||
# 最坏约 44 秒。降到 Docker 默认的 10 秒宽限期会被 SIGKILL,最后一批验证结果
|
||||
# 与访问记录随之丢失。
|
||||
stop_grace_period: 60s
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"checked_at": 1791184183.758845,
|
||||
"checked_at_text": "2026-10-05 15:09:43",
|
||||
"current_version": "ProxyCat-V3.0.0",
|
||||
"latest_version": "ProxyCat-V2.0.4",
|
||||
"error_key": null,
|
||||
"error_args": []
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
模块名称:modules.access_log
|
||||
功能描述:描述并记录一次代理请求的客户端、上游出口(含真实出口 IP)、目标、成败与耗时,
|
||||
写入 access 类别日志与内存环形缓冲,并转交 modules.domain_stats 做域名累计、modules.access_records 存逐条流水。
|
||||
职责边界:负责:请求记录对象的结果标记与落盘、转交域名统计与逐条记录、两者间的格式转换;
|
||||
不负责:目标解析与转发(modules.proxyserver)、域名聚合与持久化(modules.domain_stats)、
|
||||
逐条记录的缓冲、查询与清理(modules.access_records)、日志装配(modules.logging_setup)。
|
||||
关键依赖:modules.logging_setup(日志与环形缓冲)、modules.domain_stats、modules.access_records、modules.modules。
|
||||
已知限制:
|
||||
1. 耗时从建记录计时到 close 落盘为止:长连接覆盖整段连接时长,结果(成败)确定时刻可能早于落盘;进程强杀会丢未落库的记录。
|
||||
2. track() 是收口保证:抛异常的记为失败并写入原因后继续抛出;无结论则默认记为「中断」,非直连隧道已建立、客户端发过数据且上游先断开时归为失败(隧道无数据);协程取消重抛后归类相同。
|
||||
3. 「中断」不算成功也不算失败:不写域名统计,但逐条流水照常落库;逐条记录不跟随 emit_log,有独立开关。
|
||||
4. AccessRecord 不是线程安全的,只在请求所属事件循环线程内使用;close() 的幂等靠 _closed 标记,重复调用只生效第一次。
|
||||
5. 结果首次写入即定,之后 succeeded/failed 不再改写 outcome;failed() 仍无条件覆盖 status_code,避免留下「失败…状态 200」的矛盾记录。
|
||||
6. retry_tunnel() 会清空 outcome、原因与归因标记,只有确实要重开时才可调用,调用后必须有结论补上。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import NamedTuple
|
||||
|
||||
from modules.access_records import TIME_FORMAT, RequestRecord, RequestRecordStore
|
||||
from modules.domain_stats import UPSTREAM_DIRECT, UPSTREAM_UNKNOWN, DomainStatsStore
|
||||
from modules.logging_setup import LogCategory, get_category_logger
|
||||
from modules.modules import get_message, sanitize_proxy
|
||||
|
||||
logger = get_category_logger(LogCategory.ACCESS)
|
||||
|
||||
OUTCOME_SUCCESS = 'success'
|
||||
OUTCOME_FAILURE = 'failure'
|
||||
OUTCOME_ABORTED = 'aborted'
|
||||
|
||||
KIND_CONNECT = 'connect'
|
||||
KIND_HTTP = 'http'
|
||||
KIND_SOCKS5 = 'socks5'
|
||||
|
||||
_REASON_MAX_LENGTH = 200
|
||||
|
||||
_LEVEL_BY_OUTCOME = {
|
||||
OUTCOME_SUCCESS: logging.INFO,
|
||||
OUTCOME_FAILURE: logging.WARNING,
|
||||
OUTCOME_ABORTED: logging.INFO,
|
||||
}
|
||||
|
||||
|
||||
class ClientIdentity(NamedTuple):
|
||||
|
||||
ip: str = '-'
|
||||
username: str = ''
|
||||
|
||||
def display(self) -> str:
|
||||
if self.username:
|
||||
return f"{self.ip}/{self.username}"
|
||||
return self.ip or '-'
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessRecord:
|
||||
|
||||
kind: str
|
||||
method: str
|
||||
client: ClientIdentity
|
||||
host: str = ''
|
||||
port: int = 0
|
||||
language: str = 'cn'
|
||||
|
||||
upstream: str = ''
|
||||
real_ip: str = ''
|
||||
direct: bool = False
|
||||
attempts: int = 0
|
||||
status_code: int | None = None
|
||||
reason: str = ''
|
||||
outcome: str | None = None
|
||||
|
||||
tunnel_established: bool = False
|
||||
client_data_seen: bool = False
|
||||
upstream_data_seen: bool = False
|
||||
last_activity_at: float = 0.0
|
||||
tunnel_ended_by: str = ''
|
||||
proxy_failure_reported: bool = False
|
||||
swept_as_dead: bool = False
|
||||
swept_as_idle: bool = False
|
||||
|
||||
started_at: float = field(default_factory=time.monotonic, repr=False)
|
||||
started_wall: float = field(default_factory=time.time, repr=False)
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
if not self.host:
|
||||
return '-'
|
||||
return f"{self.host}:{self.port}" if self.port else self.host
|
||||
|
||||
@property
|
||||
def upstream_key(self) -> str:
|
||||
if self.upstream:
|
||||
return self.upstream
|
||||
return UPSTREAM_DIRECT if self.direct else UPSTREAM_UNKNOWN
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> int:
|
||||
return int((time.monotonic() - self.started_at) * 1000)
|
||||
|
||||
def use_proxy(self, proxy: str, real_ip: str = '') -> None:
|
||||
if self.outcome is not None:
|
||||
return
|
||||
self.attempts += 1
|
||||
self.upstream = sanitize_proxy(proxy)
|
||||
self.real_ip = real_ip or ''
|
||||
self.direct = False
|
||||
|
||||
def use_direct(self) -> None:
|
||||
self.upstream = ''
|
||||
self.real_ip = ''
|
||||
self.direct = True
|
||||
|
||||
def succeeded(self, status_code: int | None = None) -> None:
|
||||
if self.outcome is not None:
|
||||
return
|
||||
self.outcome = OUTCOME_SUCCESS
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
|
||||
def failed(self, reason: str, status_code: int | None = None) -> None:
|
||||
if self.outcome is not None:
|
||||
return
|
||||
self.outcome = OUTCOME_FAILURE
|
||||
self.reason = sanitize_reason(reason)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def tunnel_ready(self, status_code: int | None = None) -> None:
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
self.tunnel_established = True
|
||||
|
||||
def tunnel_client_data(self) -> None:
|
||||
self.client_data_seen = True
|
||||
self.last_activity_at = time.monotonic()
|
||||
|
||||
def retry_tunnel(self) -> None:
|
||||
self.outcome = None
|
||||
self.reason = ''
|
||||
self.tunnel_ended_by = ''
|
||||
self.proxy_failure_reported = False
|
||||
self.swept_as_dead = False
|
||||
self.swept_as_idle = False
|
||||
|
||||
def tunnel_relayed(self) -> None:
|
||||
self.upstream_data_seen = True
|
||||
self.last_activity_at = time.monotonic()
|
||||
self.succeeded(self.status_code)
|
||||
|
||||
def tunnel_ended(self, side: str) -> None:
|
||||
if not self.tunnel_ended_by:
|
||||
self.tunnel_ended_by = side
|
||||
|
||||
def render(self) -> str:
|
||||
status = self.status_code if self.status_code is not None else '-'
|
||||
if self.upstream:
|
||||
upstream = self.upstream
|
||||
else:
|
||||
upstream = get_message(
|
||||
'access_direct' if self.direct else 'access_unknown', self.language
|
||||
)
|
||||
if self.outcome == OUTCOME_SUCCESS:
|
||||
return get_message(
|
||||
'access_ok', self.language,
|
||||
self.client.display(), upstream, self.target, self.method,
|
||||
self.elapsed_ms, status,
|
||||
)
|
||||
key = 'access_aborted' if self.outcome == OUTCOME_ABORTED else 'access_failed'
|
||||
return get_message(
|
||||
key, self.language,
|
||||
self.client.display(), upstream, self.target, self.method,
|
||||
self.elapsed_ms, status, self.reason or '-',
|
||||
)
|
||||
|
||||
|
||||
class AccessTracker:
|
||||
|
||||
def __init__(self, language: str = 'cn', stats: DomainStatsStore | None = None,
|
||||
emit_log: bool = True, records: RequestRecordStore | None = None):
|
||||
self.language = language
|
||||
self.stats = stats
|
||||
self.emit_log = emit_log
|
||||
self.records = records
|
||||
|
||||
@contextmanager
|
||||
def track(self, kind: str, method: str, client: ClientIdentity,
|
||||
host: str = '', port: int = 0):
|
||||
record = AccessRecord(
|
||||
kind=kind, method=method, client=client,
|
||||
host=host, port=port, language=self.language,
|
||||
)
|
||||
try:
|
||||
yield record
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
record.failed(str(e))
|
||||
raise
|
||||
finally:
|
||||
self.close(record)
|
||||
|
||||
def close(self, record: AccessRecord) -> None:
|
||||
if getattr(record, '_closed', False):
|
||||
return
|
||||
record._closed = True
|
||||
|
||||
if record.outcome is None:
|
||||
self._settle_tunnel(record)
|
||||
|
||||
if record.outcome is None:
|
||||
record.outcome = OUTCOME_ABORTED
|
||||
if not record.reason:
|
||||
record.reason = get_message('access_incomplete', self.language)
|
||||
|
||||
if self.emit_log:
|
||||
self._emit(record)
|
||||
|
||||
if self.records is not None and self.records.enabled:
|
||||
self.records.append(entry_from_record(record))
|
||||
|
||||
if self.stats is not None and record.outcome != OUTCOME_ABORTED:
|
||||
self.stats.record(
|
||||
record.host,
|
||||
record.upstream_key,
|
||||
succeeded=record.outcome == OUTCOME_SUCCESS,
|
||||
status_code=record.status_code,
|
||||
error=record.reason,
|
||||
port=record.port,
|
||||
)
|
||||
|
||||
def _settle_tunnel(self, record: AccessRecord) -> None:
|
||||
if not record.tunnel_established:
|
||||
return
|
||||
|
||||
if (record.client_data_seen and record.tunnel_ended_by == 'upstream'
|
||||
and not record.direct):
|
||||
record.failed(get_message('access_tunnel_no_data', self.language))
|
||||
return
|
||||
|
||||
record.outcome = OUTCOME_ABORTED
|
||||
if not record.reason:
|
||||
if record.tunnel_ended_by == 'upstream':
|
||||
key = 'access_tunnel_closed_by_upstream'
|
||||
elif record.client_data_seen:
|
||||
key = 'access_tunnel_client_left'
|
||||
else:
|
||||
key = 'access_tunnel_idle'
|
||||
record.reason = get_message(key, self.language)
|
||||
|
||||
def _emit(self, record: AccessRecord) -> None:
|
||||
try:
|
||||
logger.log(_LEVEL_BY_OUTCOME.get(record.outcome, logging.INFO), record.render())
|
||||
except Exception:
|
||||
logger.exception("写访问日志失败")
|
||||
|
||||
|
||||
def entry_from_record(record: AccessRecord) -> RequestRecord:
|
||||
return RequestRecord(
|
||||
ts=datetime.fromtimestamp(record.started_wall).strftime(TIME_FORMAT),
|
||||
kind=record.kind,
|
||||
method=record.method,
|
||||
client_ip=record.client.ip or '-',
|
||||
client_user=record.client.username,
|
||||
host=record.host,
|
||||
port=record.port,
|
||||
upstream=record.upstream_key,
|
||||
outcome=record.outcome or OUTCOME_ABORTED,
|
||||
status_code=record.status_code,
|
||||
elapsed_ms=record.elapsed_ms,
|
||||
reason=record.reason,
|
||||
real_ip=record.real_ip,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_reason(reason) -> str:
|
||||
text = ' '.join(str(reason).split())
|
||||
if len(text) > _REASON_MAX_LENGTH:
|
||||
return text[:_REASON_MAX_LENGTH] + '...'
|
||||
return text
|
||||
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
模块名称:modules.access_records
|
||||
功能描述:代理请求的逐条事件流水存储:热路径把记录投入内存缓冲,后台线程按间隔批量落库;
|
||||
提供按时间与维度分页查询、导出取数,以及保留期与行数上限清理。
|
||||
职责边界:负责:内存缓冲、增量落库、查询与导出取数、过期与超限清理;不负责:请求解析与转发(见
|
||||
modules.proxyserver)、记录内容组装(见 modules.access_log)、HTTP 接口与导出序列化(见 app)。
|
||||
关键依赖:modules.modules 的 parse_int_lenient / parse_bool_lenient、modules.spool_store 的
|
||||
FlushBackedSqliteStore;其余为标准库(含 sqlite3、threading、collections)。
|
||||
已知限制:
|
||||
1. 查询与导出只读数据库,最近一个 flush_interval 内的记录还看不到(pending 计数即该窗口);读取时把 failure 行状态码置空,库里旧行保持原值。
|
||||
2. 缓冲满时丢弃最老的记录并计入 dropped,不阻塞写入;落库失败整批并回缓冲队首,空间不足时同样丢弃;buffer_size 调小仅下次 start() 生效。
|
||||
3. max_rows 在每次落库后裁剪,retention_days 清理限频 3600 秒且失败不推进计时;两者取 <= 0 表示关闭对应清理。
|
||||
4. 查询、导出与写入都走短连接,不跨线程共享连接;导出先取最新的 EXPORT_LIMIT 条再反转为升序。
|
||||
5. 数据库不开 WAL、不设 auto_vacuum:删除后的页会被复用,文件大小停在历史高水位,不随清理回落。
|
||||
6. real_ip 对已有库由 PRAGMA + ALTER 补列;
|
||||
_COLUMNS、_row_to_dict 解包、_write_batch 值元组与 INSERT 列名四处须同名同序同步改。
|
||||
7. _OUTCOME_FAILURE 取值须与 modules.access_log.OUTCOME_FAILURE 一致,改字面量时两处同步(反向导入成环)。
|
||||
8. real_ip 补列标记只在 ALTER 成功后才置位;失败时保持 False,下一次连接重试补列。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
from modules.modules import parse_bool_lenient, parse_int_lenient
|
||||
from modules.spool_store import FlushBackedSqliteStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_FILENAME = "access_records.db"
|
||||
|
||||
_TABLE = "request_records"
|
||||
|
||||
TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
EXPORT_LIMIT = 50000
|
||||
|
||||
_PURGE_MIN_INTERVAL_SECONDS = 3600.0
|
||||
|
||||
_DROP_WARN_INTERVAL_SECONDS = 60.0
|
||||
|
||||
_OUTCOME_FAILURE = "failure"
|
||||
|
||||
_SCHEMA = f"""
|
||||
CREATE TABLE IF NOT EXISTS {_TABLE} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
client_ip TEXT NOT NULL DEFAULT '-',
|
||||
client_user TEXT NOT NULL DEFAULT '',
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
upstream TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
status_code INTEGER,
|
||||
elapsed_ms INTEGER NOT NULL DEFAULT 0,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
real_ip TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_records_ts
|
||||
ON {_TABLE}(ts DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_records_upstream
|
||||
ON {_TABLE}(upstream, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_records_host
|
||||
ON {_TABLE}(host, ts);
|
||||
"""
|
||||
|
||||
_COLUMNS = ("id, ts, kind, method, client_ip, client_user, host, port, "
|
||||
"upstream, outcome, status_code, elapsed_ms, reason, real_ip")
|
||||
|
||||
|
||||
class RequestRecord(NamedTuple):
|
||||
|
||||
ts: str
|
||||
kind: str
|
||||
method: str
|
||||
client_ip: str
|
||||
client_user: str
|
||||
host: str
|
||||
port: int
|
||||
upstream: str
|
||||
outcome: str
|
||||
status_code: Optional[int]
|
||||
elapsed_ms: int
|
||||
reason: str
|
||||
real_ip: str = ''
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordFilter:
|
||||
|
||||
since: Optional[str] = None
|
||||
until: Optional[str] = None
|
||||
upstream: Optional[str] = None
|
||||
host: Optional[str] = None
|
||||
outcome: Optional[str] = None
|
||||
search: str = ""
|
||||
|
||||
|
||||
class RequestRecordStore(FlushBackedSqliteStore):
|
||||
|
||||
def __init__(self, config, base_dir: str):
|
||||
self._init_flush_state(os.path.join(base_dir, 'logs', DB_FILENAME))
|
||||
|
||||
self._config = None
|
||||
self.enabled = False
|
||||
self.flush_interval = 5
|
||||
self.retention_days = 7
|
||||
self.max_rows = 200000
|
||||
self.buffer_size = 20000
|
||||
self.apply_config(config)
|
||||
|
||||
self._buffer: deque[RequestRecord] = deque(maxlen=self.buffer_size)
|
||||
self._dropped = 0
|
||||
self._real_ip_schema_ready = False
|
||||
self._schema_lock = threading.Lock()
|
||||
self._row_count = 0
|
||||
self._last_purge_at = 0.0
|
||||
self._last_drop_warn_at = 0.0
|
||||
|
||||
|
||||
|
||||
def append(self, entry: RequestRecord) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
overflowed = len(self._buffer) == self._buffer.maxlen
|
||||
self._buffer.append(entry)
|
||||
if overflowed:
|
||||
self._dropped += 1
|
||||
should_warn = self._mark_drop_warn_locked()
|
||||
else:
|
||||
should_warn = False
|
||||
|
||||
if should_warn:
|
||||
self._warn_dropped()
|
||||
|
||||
def _mark_drop_warn_locked(self) -> bool:
|
||||
now = time.monotonic()
|
||||
if now - self._last_drop_warn_at < _DROP_WARN_INTERVAL_SECONDS:
|
||||
return False
|
||||
self._last_drop_warn_at = now
|
||||
return True
|
||||
|
||||
def _warn_dropped(self) -> None:
|
||||
with self._lock:
|
||||
dropped = self._dropped
|
||||
capacity = self._buffer.maxlen
|
||||
logger.warning(
|
||||
"访问记录缓冲已满(上限 %d 条),开始丢弃最老的记录,累计已丢 %d 条;"
|
||||
"落库跟不上时请调大 access_records_buffer_size 或加快落库间隔",
|
||||
capacity, dropped,
|
||||
)
|
||||
|
||||
|
||||
def query(self, flt: RecordFilter, limit: int = 100, offset: int = 0,
|
||||
order: str = "desc") -> tuple[list[dict], int, dict]:
|
||||
where, params = _build_where(flt)
|
||||
order_sql = "ASC" if order == "asc" else "DESC"
|
||||
|
||||
try:
|
||||
with self._session() as conn:
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) FROM {_TABLE}{where}", params
|
||||
).fetchone()[0]
|
||||
counts = {
|
||||
row[0]: row[1] for row in conn.execute(
|
||||
f"SELECT outcome, COUNT(*) FROM {_TABLE}{where} GROUP BY outcome",
|
||||
params,
|
||||
)
|
||||
}
|
||||
rows = conn.execute(
|
||||
f"SELECT {_COLUMNS} FROM {_TABLE}{where}"
|
||||
f" ORDER BY ts {order_sql}, id {order_sql} LIMIT ? OFFSET ?",
|
||||
(*params, limit, offset),
|
||||
).fetchall()
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("查询访问记录失败: %s", e)
|
||||
return [], 0, {}
|
||||
|
||||
return [_row_to_dict(row) for row in rows], total, counts
|
||||
|
||||
def export_rows(self, flt: RecordFilter) -> list[dict]:
|
||||
where, params = _build_where(flt)
|
||||
try:
|
||||
with self._session() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT {_COLUMNS} FROM {_TABLE}{where}"
|
||||
f" ORDER BY ts DESC, id DESC LIMIT ?",
|
||||
(*params, EXPORT_LIMIT),
|
||||
).fetchall()
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("读取导出用的访问记录失败: %s", e)
|
||||
return []
|
||||
|
||||
return [_row_to_dict(row) for row in reversed(rows)]
|
||||
|
||||
def distinct_options(self, limit: int = 200) -> dict:
|
||||
options = {"upstreams": [], "hosts": []}
|
||||
try:
|
||||
with self._session() as conn:
|
||||
options["upstreams"] = [
|
||||
{"value": row[0], "count": row[1]} for row in conn.execute(
|
||||
f"SELECT upstream, COUNT(*) AS n FROM {_TABLE}"
|
||||
f" GROUP BY upstream ORDER BY n DESC LIMIT ?", (limit,)
|
||||
)
|
||||
]
|
||||
options["hosts"] = [
|
||||
{"value": row[0], "count": row[1]} for row in conn.execute(
|
||||
f"SELECT host, COUNT(*) AS n FROM {_TABLE}"
|
||||
f" WHERE host <> '' GROUP BY host ORDER BY n DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
]
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("读取访问记录的筛选项失败: %s", e)
|
||||
return options
|
||||
|
||||
def stats(self) -> dict:
|
||||
with self._lock:
|
||||
pending = len(self._buffer)
|
||||
dropped = self._dropped
|
||||
|
||||
oldest = ""
|
||||
try:
|
||||
with self._session() as conn:
|
||||
row = conn.execute(f"SELECT MIN(ts) FROM {_TABLE}").fetchone()
|
||||
oldest = (row[0] or "") if row else ""
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("读取访问记录时间范围失败: %s", e)
|
||||
|
||||
return {"pending": pending, "dropped": dropped, "oldest_ts": oldest}
|
||||
|
||||
|
||||
def apply_config(self, config) -> bool:
|
||||
was_enabled = self.enabled
|
||||
self._config = config
|
||||
self.enabled = parse_bool_lenient(
|
||||
config.get('access_records_enabled', 'true'), default=True
|
||||
)
|
||||
self.flush_interval = parse_int_lenient(
|
||||
config.get('access_records_flush_interval', '5'), default=5, low=1
|
||||
)
|
||||
self.retention_days = parse_int_lenient(
|
||||
config.get('access_records_retention_days', '7'), default=7, low=0
|
||||
)
|
||||
self.max_rows = parse_int_lenient(
|
||||
config.get('access_records_max_rows', '200000'), default=200000, low=0
|
||||
)
|
||||
self.buffer_size = parse_int_lenient(
|
||||
config.get('access_records_buffer_size', '20000'), default=20000, low=100
|
||||
)
|
||||
return was_enabled
|
||||
|
||||
def start(self) -> bool:
|
||||
if not self.enabled:
|
||||
logger.info("访问记录已禁用,跳过启动")
|
||||
return False
|
||||
if self._started:
|
||||
return True
|
||||
|
||||
with self._lock:
|
||||
shrunk = len(self._buffer) - self.buffer_size
|
||||
self._buffer = deque(self._buffer, maxlen=self.buffer_size)
|
||||
if shrunk > 0:
|
||||
self._dropped += shrunk
|
||||
|
||||
with self._db_lock:
|
||||
try:
|
||||
with self._session() as conn:
|
||||
loaded_rows = conn.execute(
|
||||
f"SELECT COUNT(*) FROM {_TABLE}"
|
||||
).fetchone()[0]
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("访问记录行数载入失败,按 0 起算: %s", e)
|
||||
loaded_rows = 0
|
||||
with self._lock:
|
||||
self._row_count = loaded_rows
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._flush_loop, name='access-records-flush', daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._started = True
|
||||
logger.info(
|
||||
"访问记录已启动: %s(每 %d 秒落库,保留 %d 天 / 最多 %d 行)",
|
||||
self.db_path, self.flush_interval, self.retention_days, self.max_rows,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
_table = _TABLE
|
||||
_log_label = '访问记录'
|
||||
|
||||
def _ensure_schema(self, conn):
|
||||
conn.executescript(_SCHEMA)
|
||||
if self._real_ip_schema_ready:
|
||||
return
|
||||
with self._schema_lock:
|
||||
if self._real_ip_schema_ready:
|
||||
return
|
||||
columns = [row[1] for row in
|
||||
conn.execute(f"PRAGMA table_info({_TABLE})").fetchall()]
|
||||
if columns and 'real_ip' not in columns:
|
||||
conn.execute(f"ALTER TABLE {_TABLE} "
|
||||
f"ADD COLUMN real_ip TEXT NOT NULL DEFAULT ''")
|
||||
logger.info("访问记录表已迁移:新增列 real_ip")
|
||||
self._real_ip_schema_ready = True
|
||||
|
||||
def _take_batch(self):
|
||||
with self._lock:
|
||||
if not self._buffer:
|
||||
return None, self._flush_generation
|
||||
batch = list(self._buffer)
|
||||
self._buffer.clear()
|
||||
return batch, self._flush_generation
|
||||
|
||||
def _on_write_succeeded(self, batch):
|
||||
with self._lock:
|
||||
self._row_count += len(batch)
|
||||
|
||||
def _after_flush(self):
|
||||
self._trim_rows()
|
||||
|
||||
def _clear_memory(self, deleted_rows):
|
||||
removed = deleted_rows + len(self._buffer)
|
||||
self._buffer.clear()
|
||||
self._row_count = 0
|
||||
return removed
|
||||
|
||||
def _restore_batch(self, batch: list[RequestRecord]) -> None:
|
||||
drop_warned = False
|
||||
with self._lock:
|
||||
buffered = len(self._buffer)
|
||||
to_drop = min(max(0, buffered + len(batch) - self._buffer.maxlen), buffered)
|
||||
for _ in range(to_drop):
|
||||
self._buffer.popleft()
|
||||
self._dropped += 1
|
||||
|
||||
keep = len(batch) if len(batch) <= self._buffer.maxlen else self._buffer.maxlen
|
||||
discarded = len(batch) - keep
|
||||
if discarded:
|
||||
self._dropped += discarded
|
||||
self._buffer.extendleft(reversed(batch[-keep:]))
|
||||
|
||||
if to_drop or discarded:
|
||||
drop_warned = self._mark_drop_warn_locked()
|
||||
|
||||
if drop_warned:
|
||||
self._warn_dropped()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _write_batch(self, batch: list[RequestRecord]) -> bool:
|
||||
rows = [
|
||||
(entry.ts, entry.kind, entry.method, entry.client_ip, entry.client_user,
|
||||
entry.host, entry.port, entry.upstream, entry.outcome,
|
||||
entry.status_code, entry.elapsed_ms, entry.reason, entry.real_ip)
|
||||
for entry in batch
|
||||
]
|
||||
try:
|
||||
with self._session() as conn:
|
||||
conn.executemany(
|
||||
f"""
|
||||
INSERT INTO {_TABLE}
|
||||
(ts, kind, method, client_ip, client_user, host, port,
|
||||
upstream, outcome, status_code, elapsed_ms, reason, real_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
return True
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("访问记录批量写入失败(将退回缓冲重试): %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _purge_expired(self) -> None:
|
||||
if self.retention_days <= 0:
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._last_purge_at < _PURGE_MIN_INTERVAL_SECONDS:
|
||||
return
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=self.retention_days)).strftime(
|
||||
TIME_FORMAT
|
||||
)
|
||||
with self._db_lock:
|
||||
try:
|
||||
with self._session() as conn:
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM {_TABLE} WHERE ts < ?", (cutoff,)
|
||||
)
|
||||
removed = cursor.rowcount
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("清理过期访问记录失败: %s", e)
|
||||
return
|
||||
|
||||
self._last_purge_at = now
|
||||
with self._lock:
|
||||
self._row_count = max(0, self._row_count - removed)
|
||||
|
||||
if removed:
|
||||
logger.info(
|
||||
"已清理 %d 条超过 %d 天的访问记录", removed, self.retention_days
|
||||
)
|
||||
|
||||
def _trim_rows(self) -> None:
|
||||
if self.max_rows <= 0:
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
excess = self._row_count - self.max_rows
|
||||
if excess <= 0:
|
||||
return
|
||||
|
||||
with self._db_lock:
|
||||
try:
|
||||
with self._session() as conn:
|
||||
actual = conn.execute(f"SELECT COUNT(*) FROM {_TABLE}").fetchone()[0]
|
||||
excess = actual - self.max_rows
|
||||
if excess <= 0:
|
||||
with self._lock:
|
||||
self._row_count = actual
|
||||
return
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM {_TABLE} WHERE id IN ("
|
||||
f" SELECT id FROM {_TABLE} ORDER BY id ASC LIMIT ?)",
|
||||
(excess,),
|
||||
)
|
||||
removed = cursor.rowcount
|
||||
remaining = actual - removed
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("按行数上限裁剪访问记录失败: %s", e)
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
self._row_count = remaining
|
||||
|
||||
logger.info(
|
||||
"访问记录已达上限 %d 行,删除了最老的 %d 条", self.max_rows, removed
|
||||
)
|
||||
|
||||
|
||||
def _build_where(flt: RecordFilter) -> tuple[str, tuple]:
|
||||
clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if flt.since:
|
||||
clauses.append("ts >= ?")
|
||||
params.append(flt.since)
|
||||
if flt.until:
|
||||
clauses.append("ts <= ?")
|
||||
params.append(flt.until)
|
||||
if flt.upstream:
|
||||
clauses.append("upstream = ?")
|
||||
params.append(flt.upstream)
|
||||
if flt.host:
|
||||
clauses.append("host = ?")
|
||||
params.append(flt.host)
|
||||
if flt.outcome:
|
||||
clauses.append("outcome = ?")
|
||||
params.append(flt.outcome)
|
||||
if flt.search:
|
||||
clauses.append(
|
||||
"(host LIKE ? ESCAPE '\\' OR client_ip LIKE ? ESCAPE '\\'"
|
||||
" OR client_user LIKE ? ESCAPE '\\' OR reason LIKE ? ESCAPE '\\')"
|
||||
)
|
||||
needle = f"%{_escape_like(flt.search)}%"
|
||||
params.extend([needle] * 4)
|
||||
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return where, tuple(params)
|
||||
|
||||
|
||||
def _escape_like(text: str) -> str:
|
||||
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _row_to_dict(row) -> dict:
|
||||
(record_id, ts, kind, method, client_ip, client_user, host, port,
|
||||
upstream, outcome, status_code, elapsed_ms, reason, real_ip) = row
|
||||
|
||||
if outcome == _OUTCOME_FAILURE and status_code:
|
||||
status_code = None
|
||||
|
||||
client = f"{client_ip}/{client_user}" if client_user else (client_ip or "-")
|
||||
target = f"{host}:{port}" if port else (host or "-")
|
||||
|
||||
return {
|
||||
"id": record_id,
|
||||
"ts": ts,
|
||||
"kind": kind,
|
||||
"method": method,
|
||||
"client": client,
|
||||
"client_ip": client_ip,
|
||||
"client_user": client_user,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"target": target,
|
||||
"upstream": upstream,
|
||||
"real_ip": real_ip,
|
||||
"outcome": outcome,
|
||||
"status_code": status_code,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DB_FILENAME",
|
||||
"TIME_FORMAT",
|
||||
"EXPORT_LIMIT",
|
||||
"RecordFilter",
|
||||
"RequestRecord",
|
||||
"RequestRecordStore",
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
模块名称:modules.config_validation
|
||||
功能描述:[Server] 段配置项的校验与归一化:键名白名单、类型转换、取值范围校验、布尔与枚举取值归一,并渲染面板可直接使用的展示值,供保存流程在写盘前拒绝非法输入。
|
||||
职责边界:负责:[Server] 段配置项的逐键校验、归一化与展示值渲染;不负责:配置落盘与热重载、[Pool] 段校验(由 modules.pool_config_ini 负责)。
|
||||
关键依赖:modules.modules 的 DEFAULT_CONFIG(白名单)、TRUE_WORDS / FALSE_WORDS(布尔词表)、
|
||||
render_config_error(错误文案);标准库 functools、typing、logging(仅用于创建模块级 logger)。
|
||||
已知限制:
|
||||
1. 白名单即 DEFAULT_CONFIG 的键集合:新增 [Server] 键若未进入 DEFAULT_CONFIG,保存时会按未知键拒绝。
|
||||
2. 未在 _SERVER_RULES 登记的键按纯文本处理,只截断首尾空白,不做类型与范围检查。
|
||||
3. 布尔词表直接引用 modules.modules 的 TRUE_WORDS / FALSE_WORDS,与运行期宽松解析共用同一份,改词表会同时改变两处判定。
|
||||
4. display_level 接受 0 / 1 / 2;字面量 3 归一为 2;其余非整数或越界值报错。
|
||||
5. mode 的合法值按来源分两组:本地来源 cycle / loadbalance,API 与维护池 continuous / request,本模块两组都放行。
|
||||
6. ConfigValidationError 默认文案固定为中文;其他界面语言须由调用方调 localized(language) 取得。
|
||||
7. 布尔项展示值须归一为 'true' / 'false':面板按小写后等于 'true' 判定勾选,原样返回 yes / on / 1 会显示为未勾选。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from modules.modules import (
|
||||
DEFAULT_CONFIG, FALSE_WORDS, TRUE_WORDS, render_config_error,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRUE_VALUES = TRUE_WORDS
|
||||
_FALSE_VALUES = FALSE_WORDS
|
||||
|
||||
_VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||
|
||||
|
||||
class ConfigValidationError(ValueError):
|
||||
|
||||
def __init__(self, key: str, value: Any, reason_key: str, *reason_args: Any):
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.reason_key = reason_key
|
||||
self.reason_args = reason_args
|
||||
super().__init__(self.localized('cn'))
|
||||
|
||||
def localized(self, language: str) -> str:
|
||||
return render_config_error(
|
||||
self.key, self.value, self.reason_key, self.reason_args, language
|
||||
)
|
||||
|
||||
|
||||
def _as_int(value: Any, key: str, *, low: int | None = None,
|
||||
high: int | None = None) -> str:
|
||||
try:
|
||||
number = int(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise ConfigValidationError(key, value, "config_reason_expected_int") from None
|
||||
|
||||
if low is not None and number < low:
|
||||
raise ConfigValidationError(key, value, "config_reason_min", low)
|
||||
if high is not None and number > high:
|
||||
raise ConfigValidationError(key, value, "config_reason_max", high)
|
||||
return str(number)
|
||||
|
||||
|
||||
def _as_bool(value: Any, key: str) -> str:
|
||||
text = str(value).strip().lower()
|
||||
if text in _TRUE_VALUES:
|
||||
return "true"
|
||||
if text in _FALSE_VALUES:
|
||||
return "false"
|
||||
raise ConfigValidationError(key, value, "config_reason_expected_bool")
|
||||
|
||||
|
||||
def _as_display_level(value: Any, key: str) -> str:
|
||||
if str(value).strip() == '3':
|
||||
return '2'
|
||||
return _as_int(value, key, low=0, high=2)
|
||||
|
||||
|
||||
def _as_choice(value: Any, key: str, allowed: tuple[str, ...], *, lower: bool = True) -> str:
|
||||
text = str(value).strip()
|
||||
normalized = text.lower() if lower else text.upper()
|
||||
if normalized not in allowed:
|
||||
raise ConfigValidationError(key, value, "config_reason_expected_choice",
|
||||
'、'.join(allowed))
|
||||
return normalized
|
||||
|
||||
|
||||
def _as_text(value: Any, key: str) -> str:
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _as_upper_choice(value: Any, key: str, allowed: tuple[str, ...]) -> str:
|
||||
return _as_choice(value, key, allowed, lower=False)
|
||||
|
||||
|
||||
_SERVER_RULES: dict[str, Callable[[Any, str], str]] = {
|
||||
'port': partial(_as_int, low=1, high=65535),
|
||||
'web_port': partial(_as_int, low=1, high=65535),
|
||||
'interval': partial(_as_int, low=0),
|
||||
'request_interval': partial(_as_int, low=0),
|
||||
'display_level': _as_display_level,
|
||||
'switch_cooldown': partial(_as_int, low=0),
|
||||
'proxy_check_ttl': partial(_as_int, low=0),
|
||||
'check_cooldown': partial(_as_int, low=0),
|
||||
'proxy_failure_cooldown': partial(_as_int, low=0),
|
||||
'tunnel_idle_timeout': partial(_as_int, low=0),
|
||||
'buffer_size': partial(_as_int, low=1024),
|
||||
'max_concurrent_requests': partial(_as_int, low=1),
|
||||
'max_pool_size': partial(_as_int, low=1),
|
||||
'check_concurrency': partial(_as_int, low=1, high=1000),
|
||||
'max_concurrent_per_proxy': partial(_as_int, low=0, high=10000),
|
||||
'exit_count': partial(_as_int, low=1, high=1000),
|
||||
'exit_wait_timeout': partial(_as_int, low=0, high=600),
|
||||
'client_max_connections': partial(_as_int, low=1),
|
||||
'client_max_keepalive': partial(_as_int, low=0, high=64),
|
||||
'client_keepalive_expiry': partial(_as_int, low=0),
|
||||
'client_idle_timeout': partial(_as_int, low=1),
|
||||
|
||||
'check_proxies_on_startup': _as_bool,
|
||||
'check_proxies_on_use': _as_bool,
|
||||
'auto_expand_enabled': _as_bool,
|
||||
|
||||
'mode': partial(_as_choice, allowed=(
|
||||
'cycle', 'loadbalance', 'continuous', 'request')),
|
||||
'proxy_source_mode': partial(_as_choice, allowed=('local', 'api', 'pool')),
|
||||
'ip_auth_priority': partial(_as_choice, allowed=('whitelist', 'blacklist')),
|
||||
'language': partial(_as_choice, allowed=('cn', 'en')),
|
||||
'log_level': partial(_as_upper_choice, allowed=_VALID_LOG_LEVELS),
|
||||
'log_max_bytes': partial(_as_int, low=1024),
|
||||
'log_backup_count': partial(_as_int, low=1),
|
||||
'log_access_enabled': _as_bool,
|
||||
|
||||
'domain_stats_enabled': _as_bool,
|
||||
'domain_stats_flush_interval': partial(_as_int, low=1),
|
||||
'domain_stats_retention_days': partial(_as_int, low=0),
|
||||
|
||||
'access_records_enabled': _as_bool,
|
||||
'access_records_flush_interval': partial(_as_int, low=1),
|
||||
'access_records_retention_days': partial(_as_int, low=0),
|
||||
'access_records_max_rows': partial(_as_int, low=0),
|
||||
'access_records_buffer_size': partial(_as_int, low=100),
|
||||
'access_records_real_ip_probe': _as_bool,
|
||||
}
|
||||
|
||||
_ALLOWED_SERVER_KEYS = frozenset(DEFAULT_CONFIG)
|
||||
|
||||
_REJECTED_SERVER_KEYS = frozenset({'users', 'Users'})
|
||||
|
||||
|
||||
def validate_server_updates(updates: Mapping[str, Any]) -> dict[str, str]:
|
||||
normalized: dict[str, str] = {}
|
||||
|
||||
for key, value in updates.items():
|
||||
if key in _REJECTED_SERVER_KEYS:
|
||||
raise ConfigValidationError(key, value, "config_reason_users_via_api")
|
||||
|
||||
if key not in _ALLOWED_SERVER_KEYS:
|
||||
raise ConfigValidationError(key, value, "config_reason_unknown_key")
|
||||
|
||||
rule = _SERVER_RULES.get(key, _as_text)
|
||||
normalized[key] = rule(value, key)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def display_server_values(section: Mapping[str, Any]) -> dict[str, str]:
|
||||
displayed: dict[str, str] = {}
|
||||
for key, value in section.items():
|
||||
if _SERVER_RULES.get(key) is _as_bool:
|
||||
try:
|
||||
displayed[key] = _as_bool(value, key)
|
||||
except ConfigValidationError:
|
||||
displayed[key] = str(value)
|
||||
else:
|
||||
displayed[key] = str(value)
|
||||
return displayed
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConfigValidationError",
|
||||
"display_server_values",
|
||||
"validate_server_updates",
|
||||
]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
模块名称:modules.domain_stats
|
||||
功能描述:按「目标域名 × 上游代理」累计代理请求的成功/失败次数并持久化到 SQLite,
|
||||
供面板查询哪个代理访问过哪些域名、何时、成功还是失败。
|
||||
职责边界:负责:内存聚合、增量批量落库、启动载入历史基线、过期清理、按上游代理与按域名查询。
|
||||
不负责:日志文案与文件(modules.access_log)、代理请求解析(modules.proxyserver)。
|
||||
关键依赖:modules.modules 的 parse_int_lenient / parse_bool_lenient、modules.spool_store 的
|
||||
FlushBackedSqliteStore;其余为标准库(含 sqlite3、threading、os、datetime、logging、dataclasses)。
|
||||
已知限制:
|
||||
1. 聚合键是「域名 × 上游代理」,不区分同域名的路径、端口与方法;last_port 只记最近一次且不落库。
|
||||
2. record() 只更新内存(_totals 查询口径、_pending 待落库),后台线程每 flush_interval 秒落库,强杀丢未落库增量,stop() 补最终 flush。
|
||||
3. 锁顺序固定为 _db_lock → _lock,不得反向;_lock 只保护内存结构,_db_lock 保证落库与清空不互相穿插。
|
||||
4. start() 载入失败只记日志并退化为「仅本次运行」统计;历史与内存合并时计数取较大值,不整体替换。
|
||||
5. retention_days <= 0 为永久保留;清理以内存 last_seen 为准,内存侧连 _pending 一起清。
|
||||
6. enabled=false 时 record() 直接返回;apply_config 只改内存参数并返回更新前的启用状态;载入时直接删除遗留表 domain_stats(不迁移)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from modules.modules import parse_bool_lenient, parse_int_lenient
|
||||
from modules.spool_store import FlushBackedSqliteStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_FILENAME = "domain_stats.db"
|
||||
|
||||
UPSTREAM_DIRECT = "direct"
|
||||
UPSTREAM_UNKNOWN = "unknown"
|
||||
|
||||
_TABLE = "access_stats"
|
||||
|
||||
_LEGACY_TABLE = "domain_stats"
|
||||
|
||||
_SCHEMA = f"""
|
||||
CREATE TABLE IF NOT EXISTS {_TABLE} (
|
||||
host TEXT NOT NULL,
|
||||
upstream TEXT NOT NULL,
|
||||
success INTEGER NOT NULL DEFAULT 0,
|
||||
failure INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen TEXT,
|
||||
last_seen TEXT,
|
||||
last_status INTEGER,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (host, upstream)
|
||||
);
|
||||
"""
|
||||
|
||||
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
_SORT_KEYS = {
|
||||
'host': lambda row: row.get('host'),
|
||||
'upstream': lambda row: row.get('upstream'),
|
||||
'success': lambda row: row.get('success', 0),
|
||||
'failure': lambda row: row.get('failure', 0),
|
||||
'total': lambda row: (row.get('success') or 0) + (row.get('failure') or 0),
|
||||
'last_seen': lambda row: row.get('last_seen') or '',
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccessCounters:
|
||||
|
||||
success: int = 0
|
||||
failure: int = 0
|
||||
first_seen: str = ''
|
||||
last_seen: str = ''
|
||||
last_status: int | None = None
|
||||
last_error: str = ''
|
||||
last_port: int | None = None
|
||||
|
||||
def as_dict(self, host: str, upstream: str) -> dict:
|
||||
return {
|
||||
'host': host,
|
||||
'upstream': upstream,
|
||||
'success': self.success,
|
||||
'failure': self.failure,
|
||||
'total': self.success + self.failure,
|
||||
'first_seen': self.first_seen,
|
||||
'last_seen': self.last_seen,
|
||||
'last_status': self.last_status,
|
||||
'last_error': self.last_error,
|
||||
'last_port': self.last_port,
|
||||
}
|
||||
|
||||
|
||||
class DomainStatsStore(FlushBackedSqliteStore):
|
||||
|
||||
def __init__(self, config, base_dir: str):
|
||||
self.enabled = _as_bool(config.get('domain_stats_enabled', 'true'))
|
||||
self.flush_interval = _as_int(
|
||||
config.get('domain_stats_flush_interval', '15'), default=15, low=1
|
||||
)
|
||||
self.retention_days = _as_int(
|
||||
config.get('domain_stats_retention_days', '30'), default=30, low=0
|
||||
)
|
||||
self._init_flush_state(os.path.join(base_dir, 'logs', DB_FILENAME))
|
||||
self._totals: dict[tuple[str, str], AccessCounters] = {}
|
||||
self._pending: dict[tuple[str, str], AccessCounters] = {}
|
||||
|
||||
|
||||
def record(self, host: str, upstream: str, succeeded: bool,
|
||||
status_code: int | None = None, error: str = '',
|
||||
port: int | None = None) -> None:
|
||||
if not self.enabled or not host:
|
||||
return
|
||||
|
||||
key = (host, upstream or UPSTREAM_UNKNOWN)
|
||||
stamp = datetime.now().strftime(_TIME_FORMAT)
|
||||
with self._lock:
|
||||
for table in (self._totals, self._pending):
|
||||
counters = table.get(key)
|
||||
if counters is None:
|
||||
counters = AccessCounters(first_seen=stamp)
|
||||
table[key] = counters
|
||||
if succeeded:
|
||||
counters.success += 1
|
||||
else:
|
||||
counters.failure += 1
|
||||
counters.last_seen = stamp
|
||||
counters.last_status = status_code
|
||||
counters.last_error = error
|
||||
counters.last_port = port
|
||||
|
||||
|
||||
def snapshot(self) -> list[dict]:
|
||||
with self._lock:
|
||||
return [c.as_dict(*key) for key, c in self._totals.items()]
|
||||
|
||||
def query_proxies(self, search: str = '', sort: str = 'last_seen',
|
||||
order: str = 'desc', limit: int = 100,
|
||||
offset: int = 0) -> tuple[list[dict], int]:
|
||||
groups: dict[str, dict] = {}
|
||||
for row in self.snapshot():
|
||||
group = groups.get(row['upstream'])
|
||||
if group is None:
|
||||
group = groups[row['upstream']] = {
|
||||
'upstream': row['upstream'], 'hosts': set(),
|
||||
'success': 0, 'failure': 0, 'first_seen': '', 'last_seen': '',
|
||||
'last_error': '', 'last_error_time': '',
|
||||
}
|
||||
group['hosts'].add(row['host'])
|
||||
group['success'] += row['success']
|
||||
group['failure'] += row['failure']
|
||||
if not group['first_seen'] or row['first_seen'] < group['first_seen']:
|
||||
group['first_seen'] = row['first_seen']
|
||||
if row['last_seen'] > group['last_seen']:
|
||||
group['last_seen'] = row['last_seen']
|
||||
if row['last_error'] and row['last_seen'] >= group['last_error_time']:
|
||||
group['last_error'] = row['last_error']
|
||||
group['last_error_time'] = row['last_seen']
|
||||
|
||||
rows = []
|
||||
for group in groups.values():
|
||||
total = group['success'] + group['failure']
|
||||
rows.append({
|
||||
'upstream': group['upstream'],
|
||||
'host_count': len(group['hosts']),
|
||||
'success': group['success'],
|
||||
'failure': group['failure'],
|
||||
'total': total,
|
||||
'rate': round(group['success'] * 100 / total) if total else 0,
|
||||
'first_seen': group['first_seen'],
|
||||
'last_seen': group['last_seen'],
|
||||
'last_error': group['last_error'],
|
||||
})
|
||||
return self._paginate(rows, search, sort, order, limit, offset,
|
||||
searchable=('upstream',))
|
||||
|
||||
def query_domains(self, upstream: str, search: str = '', sort: str = 'last_seen',
|
||||
order: str = 'desc', limit: int = 100,
|
||||
offset: int = 0) -> tuple[list[dict], int]:
|
||||
rows = [row for row in self.snapshot() if row['upstream'] == upstream]
|
||||
return self._paginate(rows, search, sort, order, limit, offset,
|
||||
searchable=('host', 'last_error'))
|
||||
|
||||
def _paginate(self, rows: list[dict], search: str, sort: str, order: str,
|
||||
limit: int, offset: int, searchable: tuple[str, ...]):
|
||||
if search:
|
||||
needle = search.lower()
|
||||
rows = [
|
||||
row for row in rows
|
||||
if any(needle in str(row.get(field) or '').lower() for field in searchable)
|
||||
]
|
||||
|
||||
primary = 'upstream' if rows and 'upstream' in rows[0] else 'host'
|
||||
rows.sort(key=lambda row: (row.get(primary) or ''))
|
||||
|
||||
key_func = _SORT_KEYS.get(sort, _SORT_KEYS['last_seen'])
|
||||
if rows and all(key_func(row) is None for row in rows):
|
||||
key_func = _SORT_KEYS['last_seen']
|
||||
rows.sort(key=lambda row: key_func(row) or '', reverse=order != 'asc')
|
||||
|
||||
total = len(rows)
|
||||
return rows[offset:offset + limit], total
|
||||
|
||||
def summary(self) -> dict:
|
||||
rows = self.snapshot()
|
||||
return {
|
||||
'pairs': len(rows),
|
||||
'hosts': len({row['host'] for row in rows}),
|
||||
'upstreams': len({row['upstream'] for row in rows}),
|
||||
'success': sum(row['success'] for row in rows),
|
||||
'failure': sum(row['failure'] for row in rows),
|
||||
}
|
||||
|
||||
|
||||
def apply_config(self, config) -> bool:
|
||||
was_enabled = self.enabled
|
||||
self.enabled = _as_bool(config.get('domain_stats_enabled', 'true'))
|
||||
self.flush_interval = _as_int(
|
||||
config.get('domain_stats_flush_interval', '15'), default=15, low=1
|
||||
)
|
||||
self.retention_days = _as_int(
|
||||
config.get('domain_stats_retention_days', '30'), default=30, low=0
|
||||
)
|
||||
return was_enabled
|
||||
|
||||
def start(self) -> bool:
|
||||
if not self.enabled:
|
||||
logger.info("域名统计已禁用,跳过启动")
|
||||
return False
|
||||
if self._started:
|
||||
return True
|
||||
|
||||
try:
|
||||
self._load()
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("访问统计历史载入失败,本次运行从零开始: %s", e)
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._flush_loop, name='domain-stats-flush', daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self._started = True
|
||||
logger.info("访问统计已启动: %s(每 %d 秒落库)", self.db_path, self.flush_interval)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_table = _TABLE
|
||||
_log_label = '访问统计'
|
||||
|
||||
def _ensure_schema(self, conn):
|
||||
conn.execute(_SCHEMA)
|
||||
|
||||
def _take_batch(self):
|
||||
with self._lock:
|
||||
pending, self._pending = self._pending, {}
|
||||
return pending, self._flush_generation
|
||||
|
||||
def _clear_memory(self, deleted_rows):
|
||||
removed = len(self._totals)
|
||||
self._totals.clear()
|
||||
self._pending.clear()
|
||||
return removed
|
||||
|
||||
def _restore_batch(self, pending: dict[tuple[str, str], AccessCounters]) -> None:
|
||||
with self._lock:
|
||||
for key, counters in pending.items():
|
||||
target = self._pending.setdefault(key, AccessCounters())
|
||||
target.success += counters.success
|
||||
target.failure += counters.failure
|
||||
if counters.last_seen >= (target.last_seen or ''):
|
||||
target.last_seen = counters.last_seen
|
||||
target.last_status = counters.last_status
|
||||
target.last_error = counters.last_error
|
||||
target.last_port = counters.last_port
|
||||
if not target.first_seen or (
|
||||
counters.first_seen and counters.first_seen < target.first_seen
|
||||
):
|
||||
target.first_seen = counters.first_seen
|
||||
|
||||
|
||||
def _write_batch(self, pending: dict[tuple[str, str], AccessCounters]) -> bool:
|
||||
rows = [
|
||||
(host, upstream, c.success, c.failure, c.first_seen, c.last_seen,
|
||||
c.last_status, c.last_error)
|
||||
for (host, upstream), c in pending.items()
|
||||
]
|
||||
try:
|
||||
with self._session() as conn:
|
||||
conn.executemany(
|
||||
f"""
|
||||
INSERT INTO {_TABLE}
|
||||
(host, upstream, success, failure, first_seen, last_seen,
|
||||
last_status, last_error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(host, upstream) DO UPDATE SET
|
||||
success = success + excluded.success,
|
||||
failure = failure + excluded.failure,
|
||||
last_seen = excluded.last_seen,
|
||||
last_status = excluded.last_status,
|
||||
last_error = excluded.last_error
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
return True
|
||||
except (sqlite3.Error, OSError) as e:
|
||||
logger.error("访问统计批量写入失败(将在下一轮重试): %s", e)
|
||||
return False
|
||||
|
||||
def _load(self) -> None:
|
||||
cutoff = self._retention_cutoff()
|
||||
with self._session() as conn:
|
||||
conn.execute(f"DROP TABLE IF EXISTS {_LEGACY_TABLE}")
|
||||
if cutoff:
|
||||
conn.execute(f"DELETE FROM {_TABLE} WHERE last_seen < ?", (cutoff,))
|
||||
loaded = {
|
||||
(row[0], row[1]): AccessCounters(
|
||||
success=row[2], failure=row[3], first_seen=row[4] or '',
|
||||
last_seen=row[5] or '', last_status=row[6], last_error=row[7] or '',
|
||||
)
|
||||
for row in conn.execute(
|
||||
f"SELECT host, upstream, success, failure, first_seen, last_seen, "
|
||||
f"last_status, last_error FROM {_TABLE}"
|
||||
)
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
for key, counters in loaded.items():
|
||||
existing = self._totals.get(key)
|
||||
if existing is None:
|
||||
self._totals[key] = counters
|
||||
continue
|
||||
existing.success = max(existing.success, counters.success)
|
||||
existing.failure = max(existing.failure, counters.failure)
|
||||
if counters.last_seen > existing.last_seen:
|
||||
existing.last_seen = counters.last_seen
|
||||
existing.last_status = counters.last_status
|
||||
existing.last_error = counters.last_error
|
||||
if not existing.first_seen or (
|
||||
counters.first_seen and counters.first_seen < existing.first_seen
|
||||
):
|
||||
existing.first_seen = counters.first_seen
|
||||
logger.info("已载入 %d 条域名×代理访问记录", len(loaded))
|
||||
|
||||
def _purge_expired(self) -> int:
|
||||
cutoff = self._retention_cutoff()
|
||||
if not cutoff:
|
||||
return 0
|
||||
|
||||
with self._session() as conn:
|
||||
candidates = [
|
||||
(row[0], row[1]) for row in conn.execute(
|
||||
f"SELECT host, upstream FROM {_TABLE} WHERE last_seen < ?", (cutoff,)
|
||||
)
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
return 0
|
||||
|
||||
with self._lock:
|
||||
expired = [
|
||||
key for key in candidates
|
||||
if (counters := self._totals.get(key)) is None
|
||||
or counters.last_seen < cutoff
|
||||
]
|
||||
|
||||
if not expired:
|
||||
return 0
|
||||
|
||||
with self._db_lock:
|
||||
with self._lock:
|
||||
purged = []
|
||||
for key in expired:
|
||||
counters = self._totals.get(key)
|
||||
if counters is not None and counters.last_seen >= cutoff:
|
||||
continue
|
||||
self._totals.pop(key, None)
|
||||
self._pending.pop(key, None)
|
||||
purged.append(key)
|
||||
|
||||
with self._session() as conn:
|
||||
conn.executemany(
|
||||
f"DELETE FROM {_TABLE} WHERE host = ? AND upstream = ?", purged
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"已清理 %d 条超过 %d 天未访问的记录", len(purged), self.retention_days
|
||||
)
|
||||
return len(purged)
|
||||
|
||||
def _retention_cutoff(self) -> str:
|
||||
if self.retention_days <= 0:
|
||||
return ''
|
||||
return (datetime.now() - timedelta(days=self.retention_days)).strftime(_TIME_FORMAT)
|
||||
|
||||
|
||||
def _as_int(value, *, default: int, low: int) -> int:
|
||||
return parse_int_lenient(value, default, low=low)
|
||||
|
||||
|
||||
def _as_bool(value) -> bool:
|
||||
return parse_bool_lenient(value)
|
||||
@@ -0,0 +1,661 @@
|
||||
"""
|
||||
模块名称:modules.logging_setup
|
||||
功能描述:ProxyCat 的统一日志装配与分类路由:把主程序、代理请求与代理生命周期记录按
|
||||
logger 名前缀分流到 logs/ 下独立文件,ERROR 及以上汇总到 error.log,同时写入内存环形缓冲供面板实时查看。
|
||||
职责边界:负责:handler 创建与挂载、按 logger 名分类、日志级别应用、环形缓冲读写、logs/ 下日志路径解析与列举清空;
|
||||
以及面板错误句(LogFileError.localized)与折叠后缀(log_suppressed)的界面语言渲染(语言由 Web 层传入);
|
||||
不负责:文件正文文案翻译、控制台配色(ColoredFormatter)、代理池自身落盘(modules.proxypool.core.logger)。
|
||||
关键依赖:modules.modules(NoPoolConsoleFilter、ColoredFormatter、get_message、parse_int_lenient);
|
||||
标准库 logging、queue、threading、logging.handlers。
|
||||
已知限制:
|
||||
1. 分类依赖 logger 名前缀最长优先匹配,未命中一律归入 main;扩展日志类别必须同步维护 LOGGER_NAMES 与 ALL_CATEGORIES。
|
||||
2. 文件写入异步:记录进有界队列由写线程落盘,读文件、清空与关停前必须先排空(read_log_file 与 truncate_log_file 内部会先排空),崩溃会丢队列里未落盘的记录。
|
||||
3. 队列满时丢最旧的普通记录、保留控制帧(排空栅栏与停止哨兵),控制帧被丢会让排空或停止一直等到超时。
|
||||
4. [Server] log_max_bytes / log_backup_count 只管宿主类别文件与 error.log;池 proxy_pool.log 轮转另由池配置控制,不进历史列表。
|
||||
5. 读取只认白名单文件名(三个类别文件、error.log 及数字轮转份),非法名抛 LogFileError;清空对非法名不触碰文件却按成功返回,文件不存在时读取空列表、清空成功。
|
||||
6. setup_logging 幂等:重复装配先摘除旧的 handler;文件 handler 创建失败只警告并降级;控制台默认 INFO,命令行入口传 WARNING 以免干扰 tqdm。
|
||||
7. _FileQueueHandler.prepare 必须保持恒等覆写:stdlib 预格式化会清掉 exc_info/stack_info,error.log 会丢 traceback。
|
||||
8. 装配顺序固定:先停写线程并排空,再关它持有的文件 handler;未停写线程就关 handler 会让旧写线程重开文件与新装配双写。
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
|
||||
|
||||
from modules.modules import (
|
||||
ColoredFormatter,
|
||||
NoPoolConsoleFilter,
|
||||
get_message,
|
||||
parse_int_lenient,
|
||||
)
|
||||
|
||||
|
||||
class LogFileError(ValueError):
|
||||
|
||||
def __init__(self, key: str, *args):
|
||||
self.message_key = key
|
||||
self.message_args = args
|
||||
super().__init__(self.localized('cn'))
|
||||
|
||||
def localized(self, language: str) -> str:
|
||||
return get_message(self.message_key, language, *self.message_args)
|
||||
|
||||
|
||||
class LogCategory:
|
||||
|
||||
MAIN = "main"
|
||||
ACCESS = "access"
|
||||
PROXY = "proxy"
|
||||
POOL = "pool"
|
||||
|
||||
|
||||
ALL_CATEGORIES: tuple[str, ...] = (
|
||||
LogCategory.ACCESS,
|
||||
LogCategory.PROXY,
|
||||
LogCategory.MAIN,
|
||||
LogCategory.POOL,
|
||||
)
|
||||
|
||||
FILED_CATEGORIES: tuple[str, ...] = (
|
||||
LogCategory.MAIN,
|
||||
LogCategory.ACCESS,
|
||||
LogCategory.PROXY,
|
||||
)
|
||||
|
||||
LOGGER_NAMES: dict[str, str] = {
|
||||
LogCategory.MAIN: "proxycat.main",
|
||||
LogCategory.ACCESS: "proxycat.access",
|
||||
LogCategory.PROXY: "proxycat.proxy",
|
||||
}
|
||||
|
||||
CATEGORY_FILES: dict[str, str] = {
|
||||
LogCategory.MAIN: "main.log",
|
||||
LogCategory.ACCESS: "access.log",
|
||||
LogCategory.PROXY: "proxy.log",
|
||||
}
|
||||
|
||||
ERROR_LOG_FILENAME = "error.log"
|
||||
|
||||
ERROR_LOG_CATEGORY = "error"
|
||||
|
||||
LEVEL_IMPORTANT = "IMPORTANT"
|
||||
_IMPORTANT_LEVELNO = logging.WARNING
|
||||
|
||||
_RING_SIZE = 20000
|
||||
_ACCESS_RING_SIZE = 5000
|
||||
|
||||
_DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
||||
_DEFAULT_BACKUP_COUNT = 3
|
||||
|
||||
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
_FILE_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
|
||||
|
||||
|
||||
def _build_category_prefixes() -> tuple[tuple[str, str], ...]:
|
||||
table = [(prefix, LogCategory.POOL) for prefix in NoPoolConsoleFilter.POOL_LOGGER_PREFIXES]
|
||||
table += [(name, category) for category, name in LOGGER_NAMES.items()]
|
||||
normalized = [(prefix.rstrip('.'), category) for prefix, category in table]
|
||||
return tuple(sorted(normalized, key=lambda item: len(item[0]), reverse=True))
|
||||
|
||||
|
||||
_CATEGORY_PREFIXES = _build_category_prefixes()
|
||||
|
||||
_CACHE_ATTR = "_proxycat_category"
|
||||
|
||||
|
||||
def classify_record(record: logging.LogRecord) -> str:
|
||||
cached = getattr(record, _CACHE_ATTR, None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
name = record.name or ""
|
||||
category = LogCategory.MAIN
|
||||
for prefix, candidate in _CATEGORY_PREFIXES:
|
||||
if name == prefix or name.startswith(prefix + "."):
|
||||
category = candidate
|
||||
break
|
||||
setattr(record, _CACHE_ATTR, category)
|
||||
return category
|
||||
|
||||
|
||||
def get_category_logger(category: str) -> logging.Logger:
|
||||
return logging.getLogger(LOGGER_NAMES[category])
|
||||
|
||||
|
||||
class CategoryFilter(logging.Filter):
|
||||
|
||||
def __init__(self, category: str):
|
||||
super().__init__()
|
||||
self.category = category
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return classify_record(record) == self.category
|
||||
|
||||
|
||||
class MinLevelFilter(logging.Filter):
|
||||
|
||||
def __init__(self, levelno: int):
|
||||
super().__init__()
|
||||
self.levelno = levelno
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return record.levelno >= self.levelno
|
||||
|
||||
|
||||
class ConsoleFilter(logging.Filter):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._pool_filter = NoPoolConsoleFilter()
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if not self._pool_filter.filter(record):
|
||||
return False
|
||||
return classify_record(record) != LogCategory.ACCESS
|
||||
|
||||
|
||||
class LogRingHandler(logging.Handler):
|
||||
|
||||
def __init__(self, rings: dict, lock: threading.Lock, counter):
|
||||
super().__init__()
|
||||
self._rings = rings
|
||||
self._lock = lock
|
||||
self._counter = counter
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
category = classify_record(record)
|
||||
entry = {
|
||||
'seq': next(self._counter),
|
||||
'time': datetime.fromtimestamp(record.created).strftime(_TIME_FORMAT),
|
||||
'level': record.levelname,
|
||||
'message': self.format(record),
|
||||
'category': category,
|
||||
'logger': record.name or '',
|
||||
}
|
||||
with self._lock:
|
||||
ring = self._rings.get(category)
|
||||
if ring is not None:
|
||||
ring.append(entry)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
_LOG_QUEUE_SIZE = 20000
|
||||
_DRAIN_TIMEOUT = 5.0
|
||||
|
||||
_file_write_lock = threading.RLock()
|
||||
_file_queue: "queue.Queue | None" = None
|
||||
_queue_listener: "_FileQueueListener | None" = None
|
||||
_file_targets: list[logging.Handler] = []
|
||||
|
||||
|
||||
class _QueueControl:
|
||||
pass
|
||||
|
||||
class _FileQueueHandler(QueueHandler):
|
||||
def prepare(self, record):
|
||||
return record
|
||||
|
||||
def enqueue(self, record) -> None:
|
||||
try:
|
||||
self.queue.put_nowait(record)
|
||||
return
|
||||
except queue.Full:
|
||||
pass
|
||||
try:
|
||||
oldest = self.queue.get_nowait()
|
||||
except queue.Empty:
|
||||
oldest = None
|
||||
if isinstance(oldest, _QueueControl):
|
||||
try:
|
||||
self.queue.put_nowait(oldest)
|
||||
except queue.Full:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
self.queue.put_nowait(record)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
class _DrainBarrier(_QueueControl):
|
||||
__slots__ = ('event',)
|
||||
|
||||
def __init__(self):
|
||||
self.event = threading.Event()
|
||||
|
||||
|
||||
class _FileQueueListener(QueueListener):
|
||||
def __init__(self, log_queue, *handlers):
|
||||
super().__init__(log_queue, *handlers)
|
||||
self._sentinel = _QueueControl()
|
||||
|
||||
def handle(self, record) -> None:
|
||||
if isinstance(record, _DrainBarrier):
|
||||
record.event.set()
|
||||
return
|
||||
with _file_write_lock:
|
||||
super().handle(record)
|
||||
|
||||
def stop(self, timeout: float = _DRAIN_TIMEOUT) -> bool:
|
||||
if self._thread is None:
|
||||
return True
|
||||
try:
|
||||
self.queue.put(self._sentinel, timeout=timeout)
|
||||
except queue.Full:
|
||||
return False
|
||||
self._thread.join(timeout)
|
||||
drained = not self._thread.is_alive()
|
||||
if drained:
|
||||
self._thread = None
|
||||
return drained
|
||||
|
||||
|
||||
def flush_log_queue(timeout: float = _DRAIN_TIMEOUT) -> bool:
|
||||
listener, log_queue = _queue_listener, _file_queue
|
||||
if listener is None or log_queue is None:
|
||||
return True
|
||||
barrier = _DrainBarrier()
|
||||
try:
|
||||
log_queue.put(barrier, timeout=timeout)
|
||||
except queue.Full:
|
||||
return False
|
||||
return barrier.event.wait(timeout)
|
||||
|
||||
|
||||
def shutdown_logging(timeout: float = _DRAIN_TIMEOUT) -> bool:
|
||||
if _queue_listener is None:
|
||||
return True
|
||||
return _stop_file_queue(timeout)
|
||||
|
||||
|
||||
def _start_file_queue(targets: list) -> None:
|
||||
global _file_queue, _queue_listener, _file_targets
|
||||
_file_queue = queue.Queue(maxsize=_LOG_QUEUE_SIZE)
|
||||
_queue_listener = _FileQueueListener(_file_queue, *targets)
|
||||
_queue_listener.start()
|
||||
_file_targets = list(targets)
|
||||
|
||||
|
||||
def _stop_file_queue(timeout: float = _DRAIN_TIMEOUT) -> bool:
|
||||
global _file_queue, _queue_listener
|
||||
listener = _queue_listener
|
||||
_queue_listener = None
|
||||
_file_queue = None
|
||||
if listener is None:
|
||||
return True
|
||||
return listener.stop(timeout)
|
||||
|
||||
|
||||
atexit.register(shutdown_logging)
|
||||
|
||||
|
||||
def _ring_size(category: str) -> int:
|
||||
return _ACCESS_RING_SIZE if category == LogCategory.ACCESS else _RING_SIZE
|
||||
|
||||
|
||||
def _new_rings() -> dict:
|
||||
return {category: deque(maxlen=_ring_size(category)) for category in ALL_CATEGORIES}
|
||||
|
||||
|
||||
_rings = _new_rings()
|
||||
_log_lock = threading.Lock()
|
||||
_seq_counter = itertools.count(1)
|
||||
_owned_handlers: list[logging.Handler] = []
|
||||
_file_handlers: dict[str, logging.Handler] = {}
|
||||
_config_lock = threading.Lock()
|
||||
|
||||
|
||||
def _resolve_level(level_name) -> int:
|
||||
level = logging.getLevelName(str(level_name or '').strip().upper())
|
||||
if isinstance(level, int):
|
||||
return level
|
||||
logging.getLogger(__name__).warning("无法识别的日志级别 %r,回退为 INFO", level_name)
|
||||
return logging.INFO
|
||||
|
||||
|
||||
def _make_file_handler(path, rotation, formatter, filters) -> logging.Handler | None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
path,
|
||||
maxBytes=rotation['max_bytes'],
|
||||
backupCount=rotation['backup_count'],
|
||||
encoding='utf-8',
|
||||
)
|
||||
except OSError as e:
|
||||
print(f"警告: 创建日志文件失败 {path}: {e}", file=sys.stderr)
|
||||
return None
|
||||
handler.setFormatter(formatter)
|
||||
for log_filter in filters:
|
||||
handler.addFilter(log_filter)
|
||||
return handler
|
||||
|
||||
|
||||
def setup_logging(config, base_dir: str, console_level: int = logging.INFO) -> None:
|
||||
rotation = {
|
||||
'max_bytes': _as_int(config, 'log_max_bytes', _DEFAULT_MAX_BYTES, low=1024),
|
||||
'backup_count': _as_int(config, 'log_backup_count', _DEFAULT_BACKUP_COUNT, low=1),
|
||||
}
|
||||
|
||||
logs_dir = os.path.join(base_dir, 'logs')
|
||||
file_formatter = logging.Formatter(_FILE_FORMAT, datefmt=_TIME_FORMAT)
|
||||
|
||||
with _config_lock:
|
||||
_detach_owned_handlers()
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(_resolve_level(config.get('log_level', 'INFO')))
|
||||
|
||||
file_targets = []
|
||||
for category in FILED_CATEGORIES:
|
||||
filename = CATEGORY_FILES[category]
|
||||
handler = _make_file_handler(
|
||||
os.path.join(logs_dir, filename),
|
||||
rotation,
|
||||
file_formatter,
|
||||
[CategoryFilter(category)],
|
||||
)
|
||||
if handler is not None:
|
||||
_file_handlers[filename] = handler
|
||||
file_targets.append(handler)
|
||||
|
||||
error_filename = ERROR_LOG_FILENAME
|
||||
error_handler = _make_file_handler(
|
||||
os.path.join(logs_dir, error_filename),
|
||||
rotation,
|
||||
file_formatter,
|
||||
[MinLevelFilter(logging.ERROR)],
|
||||
)
|
||||
if error_handler is not None:
|
||||
_file_handlers[error_filename] = error_handler
|
||||
file_targets.append(error_handler)
|
||||
|
||||
if file_targets:
|
||||
_start_file_queue(file_targets)
|
||||
_attach(root_logger, _FileQueueHandler(_file_queue))
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(ColoredFormatter(_FILE_FORMAT, datefmt=_TIME_FORMAT))
|
||||
console_handler.setLevel(console_level)
|
||||
console_handler.addFilter(ConsoleFilter())
|
||||
_attach(root_logger, console_handler)
|
||||
|
||||
ring_handler = LogRingHandler(_rings, _log_lock, _seq_counter)
|
||||
ring_handler.setFormatter(logging.Formatter('%(message)s'))
|
||||
_attach(root_logger, ring_handler)
|
||||
|
||||
|
||||
def _attach(root_logger: logging.Logger, handler: logging.Handler) -> None:
|
||||
root_logger.addHandler(handler)
|
||||
_owned_handlers.append(handler)
|
||||
|
||||
|
||||
def _detach_owned_handlers() -> None:
|
||||
drained = _stop_file_queue()
|
||||
if drained:
|
||||
for handler in _file_targets:
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
elif _file_targets:
|
||||
print("警告: 日志写线程未能在超时内停下,保留其文件句柄以免双写", file=sys.stderr)
|
||||
_file_targets.clear()
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
for handler in _owned_handlers:
|
||||
root_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
_owned_handlers.clear()
|
||||
_file_handlers.clear()
|
||||
|
||||
|
||||
def _as_int(config, key, default: int, *, low: int) -> int:
|
||||
try:
|
||||
raw = config.get(key, default)
|
||||
except AttributeError:
|
||||
return default
|
||||
return parse_int_lenient(raw, default, low=low)
|
||||
|
||||
|
||||
class ThrottledLogger:
|
||||
def __init__(self, logger, language_provider=None, interval=5.0):
|
||||
self._logger = logger
|
||||
self._language_provider = language_provider
|
||||
self._interval = max(0.0, float(interval or 0))
|
||||
self._last = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_interval(self, interval) -> None:
|
||||
self._interval = max(0.0, float(interval or 0))
|
||||
|
||||
def warning(self, key, message) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
last, suppressed = self._last.get(key, (0.0, 0))
|
||||
if last and now - last < self._interval:
|
||||
self._last[key] = (last, suppressed + 1)
|
||||
return
|
||||
self._last[key] = (now, 0)
|
||||
|
||||
if suppressed:
|
||||
language = self._language_provider() if self._language_provider else 'cn'
|
||||
message = f"{message}{get_message('log_suppressed', language, suppressed)}"
|
||||
self._logger.warning(message)
|
||||
|
||||
|
||||
def apply_log_level(level_name) -> int:
|
||||
level = _resolve_level(level_name)
|
||||
logging.getLogger().setLevel(level)
|
||||
return level
|
||||
|
||||
|
||||
def query_log_ring(category: str | None = None,
|
||||
level: str | None = None,
|
||||
search: str | None = None) -> list[dict]:
|
||||
with _log_lock:
|
||||
if category and category != 'all':
|
||||
entries = list(_rings.get(category, ()))
|
||||
else:
|
||||
entries = []
|
||||
for ring in _rings.values():
|
||||
entries.extend(ring)
|
||||
entries.sort(key=lambda e: e['seq'])
|
||||
|
||||
if level and level != 'ALL':
|
||||
if level == LEVEL_IMPORTANT:
|
||||
entries = [e for e in entries if _levelno(e) >= _IMPORTANT_LEVELNO]
|
||||
else:
|
||||
entries = [e for e in entries if e.get('level') == level]
|
||||
|
||||
if search:
|
||||
needle = search.lower()
|
||||
entries = [
|
||||
e for e in entries
|
||||
if needle in e.get('message', '').lower()
|
||||
or needle in e.get('level', '').lower()
|
||||
or needle in e.get('time', '').lower()
|
||||
or needle in e.get('logger', '').lower()
|
||||
]
|
||||
return entries
|
||||
|
||||
|
||||
def _levelno(entry: dict) -> int:
|
||||
level = logging.getLevelName(entry.get('level', 'INFO'))
|
||||
return level if isinstance(level, int) else logging.INFO
|
||||
|
||||
|
||||
def log_ring_stats() -> dict:
|
||||
with _log_lock:
|
||||
entries = []
|
||||
for ring in _rings.values():
|
||||
entries.extend(ring)
|
||||
|
||||
levels: dict[str, int] = {}
|
||||
categories: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
levels[entry['level']] = levels.get(entry['level'], 0) + 1
|
||||
category = entry.get('category', LogCategory.MAIN)
|
||||
categories[category] = categories.get(category, 0) + 1
|
||||
|
||||
stats = {'total': len(entries), 'levels': levels, 'categories': categories}
|
||||
for category, ring in _rings.items():
|
||||
stats.setdefault('capacity', {})[category] = ring.maxlen
|
||||
if entries:
|
||||
stats['first_time'] = min(entries, key=lambda e: e['seq'])['time']
|
||||
stats['last_time'] = max(entries, key=lambda e: e['seq'])['time']
|
||||
return stats
|
||||
|
||||
|
||||
def clear_log_ring(category: str | None = None) -> int:
|
||||
with _log_lock:
|
||||
if not category or category == 'all':
|
||||
removed = sum(len(ring) for ring in _rings.values())
|
||||
for ring in _rings.values():
|
||||
ring.clear()
|
||||
return removed
|
||||
|
||||
ring = _rings.get(category)
|
||||
if ring is None:
|
||||
return 0
|
||||
removed = len(ring)
|
||||
ring.clear()
|
||||
return removed
|
||||
|
||||
|
||||
def log_file_path(filename: str, base_dir: str) -> str | None:
|
||||
if os.path.basename(filename) != filename:
|
||||
return None
|
||||
if not _is_known_log_filename(filename):
|
||||
return None
|
||||
return os.path.join(base_dir, 'logs', filename)
|
||||
|
||||
|
||||
def _is_known_log_filename(filename: str) -> bool:
|
||||
for name in _known_filenames():
|
||||
if filename == name:
|
||||
return True
|
||||
if filename.startswith(name + '.') and filename[len(name) + 1:].isdigit():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _known_filenames() -> list[str]:
|
||||
return list(CATEGORY_FILES.values()) + [ERROR_LOG_FILENAME]
|
||||
|
||||
|
||||
def _rotation_index(filename: str) -> int:
|
||||
head, _, suffix = filename.rpartition('.')
|
||||
if head and suffix.isdigit():
|
||||
return int(suffix)
|
||||
return -1
|
||||
|
||||
|
||||
def iter_log_files(base_dir: str) -> list[dict]:
|
||||
logs_dir = os.path.join(base_dir, 'logs')
|
||||
if not os.path.isdir(logs_dir):
|
||||
return []
|
||||
|
||||
files = []
|
||||
for filename in os.listdir(logs_dir):
|
||||
path = os.path.join(logs_dir, filename)
|
||||
if not os.path.isfile(path) or not _is_known_log_filename(filename):
|
||||
continue
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
is_current = filename in _known_filenames()
|
||||
files.append({
|
||||
'name': filename,
|
||||
'category': _filename_category(filename),
|
||||
'size': stat.st_size,
|
||||
'modified': datetime.fromtimestamp(stat.st_mtime).strftime(_TIME_FORMAT),
|
||||
'current': is_current,
|
||||
'rotated': not is_current,
|
||||
})
|
||||
|
||||
files.sort(key=lambda f: (
|
||||
ALL_CATEGORIES.index(f['category']) if f['category'] in ALL_CATEGORIES else 99,
|
||||
not f['current'],
|
||||
_rotation_index(f['name']),
|
||||
f['name'],
|
||||
))
|
||||
return files
|
||||
|
||||
|
||||
def _filename_category(filename: str) -> str:
|
||||
for category, name in CATEGORY_FILES.items():
|
||||
if filename == name or filename.startswith(name + '.'):
|
||||
return category
|
||||
return ERROR_LOG_CATEGORY
|
||||
|
||||
|
||||
def truncate_log_file(filename: str, base_dir: str) -> bool:
|
||||
if not flush_log_queue():
|
||||
return False
|
||||
|
||||
handler = _file_handlers.get(filename)
|
||||
if handler is not None:
|
||||
with _file_write_lock:
|
||||
handler.acquire()
|
||||
try:
|
||||
stream = getattr(handler, 'stream', None)
|
||||
if stream is not None:
|
||||
stream.seek(0)
|
||||
stream.truncate()
|
||||
stream.flush()
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
finally:
|
||||
handler.release()
|
||||
return False
|
||||
|
||||
path = log_file_path(filename, base_dir)
|
||||
if path is None or not os.path.exists(path):
|
||||
return True
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8'):
|
||||
pass
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def current_log_filenames(category: str | None = None) -> list[str]:
|
||||
if category and category != 'all':
|
||||
name = CATEGORY_FILES.get(category)
|
||||
return [name] if name else []
|
||||
return _known_filenames()
|
||||
|
||||
|
||||
def read_log_file(filename: str, base_dir: str, lines: int = 500) -> tuple[list[str], bool]:
|
||||
path = log_file_path(filename, base_dir)
|
||||
if path is None:
|
||||
raise LogFileError('log_file_name_invalid', filename)
|
||||
|
||||
flush_log_queue()
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8', errors='replace') as handle:
|
||||
tail = deque(handle, maxlen=lines)
|
||||
truncated = handle.tell() > 0 and len(tail) == lines
|
||||
except FileNotFoundError:
|
||||
return [], False
|
||||
except OSError as e:
|
||||
raise LogFileError('log_file_read_failed', e) from e
|
||||
|
||||
return [line.rstrip('\n') for line in tail], truncated
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
模块名称:modules.loop_noise_filter
|
||||
功能描述:抑制 Windows 上 asyncio 传输层收尾对已重置连接抛出的 ConnectionResetError:仅把该已知异常降级为 DEBUG,其余 context 交回原处理器。
|
||||
职责边界:负责:判定该已知噪声并安装事件循环异常过滤器;不负责:修复 CPython 传输层缺陷,也不代替业务代码捕获其自身应处理的 ConnectionResetError。
|
||||
关键依赖:仅标准库 asyncio、logging、os。
|
||||
已知限制:
|
||||
1. 只对调用过 install_loop_noise_filter() 的循环生效,新增承载网络 I/O 的常驻循环需自行安装。
|
||||
2. 只在 Windows 安装;其它平台出现同类异常说明另有原因,应让它照常报错。
|
||||
3. 判定要求回调名、宿主类名后缀、异常类型三者同时成立;CPython 若重命名该私有方法,失效方向是噪声重现而不会误吞真实错误。
|
||||
4. 只认 ConnectionResetError,同一位置上成因不同的其它 OSError 不会被一并吞掉。
|
||||
5. 幂等检测依赖本模块处理器仍是循环当前处理器;被第三方替换后再次安装会再包一层。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IS_WINDOWS = os.name == 'nt'
|
||||
|
||||
_INSTALLED_FLAG = '_proxycat_loop_noise_filter'
|
||||
|
||||
_CLOSE_CALLBACK_NAME = '_call_connection_lost'
|
||||
|
||||
_CLOSE_CALLBACK_OWNER_SUFFIX = 'Transport'
|
||||
|
||||
|
||||
def is_proactor_close_noise(context: dict) -> bool:
|
||||
if not _IS_WINDOWS:
|
||||
return False
|
||||
|
||||
if not isinstance(context.get('exception'), ConnectionResetError):
|
||||
return False
|
||||
|
||||
callback = getattr(context.get('handle'), '_callback', None)
|
||||
owner, _, method = getattr(callback, '__qualname__', '').rpartition('.')
|
||||
return method == _CLOSE_CALLBACK_NAME and owner.endswith(_CLOSE_CALLBACK_OWNER_SUFFIX)
|
||||
|
||||
|
||||
def install_loop_noise_filter(loop: asyncio.AbstractEventLoop) -> bool:
|
||||
if not _IS_WINDOWS:
|
||||
return False
|
||||
|
||||
previous = loop.get_exception_handler()
|
||||
if getattr(previous, _INSTALLED_FLAG, False):
|
||||
return False
|
||||
|
||||
def _handler(active_loop, context):
|
||||
if is_proactor_close_noise(context):
|
||||
logger.debug(
|
||||
"已抑制 Proactor 关闭噪声(连接此前已关闭,此异常无后续影响): %s",
|
||||
context.get('exception'),
|
||||
)
|
||||
return
|
||||
if previous is not None:
|
||||
previous(active_loop, context)
|
||||
else:
|
||||
active_loop.default_exception_handler(context)
|
||||
|
||||
setattr(_handler, _INSTALLED_FLAG, True)
|
||||
loop.set_exception_handler(_handler)
|
||||
return True
|
||||
@@ -0,0 +1,560 @@
|
||||
"""
|
||||
模块名称:modules.proxy_workers
|
||||
功能描述:上游出口代理池:在多个出口之间分配请求(默认按负载最少,亦可按名册顺序),
|
||||
给每个出口套独立的并发额度与活跃窗口,并用失败滑窗把持续失败的出口移出名册。
|
||||
职责边界:负责:worker 名册增删替换、出口挑选、名额等待与归还、活跃窗口结算、失败滑窗与
|
||||
停用判定、候补队列与展示快照;不负责:代理的获取与可用性验证(见 config.getip、
|
||||
modules.proxypool_service)以及请求转发与重试(见 modules.proxyserver)。
|
||||
关键依赖:modules.access_log 的 sanitize_proxy;标准库 asyncio、threading、time、collections。
|
||||
已知限制:
|
||||
1. 名额绑定在连接任务上:asyncio.gather 包装出的子任务里 asyncio.current_task() 查不到租约,worker/lease 必须由调用方显式传下去。
|
||||
2. 活跃窗口靠 expire_idle 惰性结算,没有独立定时器;wake=True 只能在事件循环线程用,调用方不周期性触发则活跃数不会回落。
|
||||
3. 额度等待不设超时,每 _QUEUE_RECHECK_SECONDS 自行重查一次;放行凭据按腾出的名额逐张发放,一次释放只唤起一个等待者。
|
||||
4. 活跃口径的额度是准入控制而非运行时硬上限:空闲隧道不占额度,数据到达重新计入时计数可能超过 capacity。
|
||||
5. 停用只把 worker 移出名册,不关闭它已建立的连接;名册里最后一个出口永不被停用。
|
||||
6. 寿命的 expires_at 与 requests_served 两条口径同时生效、谁先到算谁;requests_served 记分配次数而非服务成功次数,0 表示不按该项计。
|
||||
7. rebind 必须先给旧租约置 _released 再清空租约表;否则清表后一次迟到的 touch 会把已归零的 active 永久加 1。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from modules.access_log import sanitize_proxy
|
||||
|
||||
DEFAULT_CAPACITY = 0
|
||||
|
||||
BLAME_IGNORED = 'ignored'
|
||||
BLAME_COUNTED = 'counted'
|
||||
BLAME_RETIRED = 'retired'
|
||||
|
||||
_OUTCOME_WINDOW = 10
|
||||
|
||||
_SUSPECT_FAILURE_RATIO = 0.5
|
||||
|
||||
_RETIRE_MIN_OUTCOMES = 5
|
||||
_RETIRE_FAILURE_RATIO = 0.9
|
||||
|
||||
RETIRED_MEMORY_SECONDS = 300.0
|
||||
|
||||
ACTIVITY_WINDOW_SECONDS = 5.0
|
||||
|
||||
_QUEUE_RECHECK_SECONDS = 0.5
|
||||
|
||||
STANDBY_TTL_SECONDS = 30.0
|
||||
|
||||
STANDBY_LIMIT = 200
|
||||
|
||||
|
||||
class ProxyWorker:
|
||||
__slots__ = ('url', 'masked', 'capacity', 'inflight', 'active', 'failures',
|
||||
'outcomes', 'last_used', 'added_at', 'retired',
|
||||
'expires_at', 'requests_served')
|
||||
|
||||
def __init__(self, url, masked, capacity, expires_at=0.0):
|
||||
self.url = url
|
||||
self.masked = masked
|
||||
self.capacity = capacity
|
||||
self.inflight = 0
|
||||
self.active = 0
|
||||
self.failures = 0
|
||||
self.outcomes = deque(maxlen=_OUTCOME_WINDOW)
|
||||
self.last_used = 0.0
|
||||
self.added_at = time.monotonic()
|
||||
self.expires_at = expires_at
|
||||
self.requests_served = 0
|
||||
self.retired = False
|
||||
|
||||
@property
|
||||
def failure_ratio(self):
|
||||
if not self.outcomes:
|
||||
return 0.0
|
||||
return sum(1 for ok in self.outcomes if not ok) / len(self.outcomes)
|
||||
|
||||
@property
|
||||
def suspect(self):
|
||||
return self.failure_ratio > _SUSPECT_FAILURE_RATIO
|
||||
|
||||
def has_free_slot(self):
|
||||
return not self.capacity or self.active < self.capacity
|
||||
|
||||
@property
|
||||
def load(self):
|
||||
if self.capacity:
|
||||
return self.active / self.capacity
|
||||
return float(self.active)
|
||||
|
||||
def snapshot(self):
|
||||
return {
|
||||
'url': self.masked,
|
||||
'inflight': self.inflight,
|
||||
'active': self.active,
|
||||
'capacity': self.capacity,
|
||||
'failures': self.failures,
|
||||
'suspect': self.suspect,
|
||||
'failure_ratio': round(self.failure_ratio, 3),
|
||||
'last_used': self.last_used,
|
||||
'added_at': self.added_at,
|
||||
'expires_at': self.expires_at,
|
||||
'requests_served': self.requests_served,
|
||||
}
|
||||
|
||||
|
||||
class StandbyQueue:
|
||||
def __init__(self, ttl=STANDBY_TTL_SECONDS, limit=STANDBY_LIMIT):
|
||||
self._entries = []
|
||||
self._ttl = ttl
|
||||
self._limit = limit
|
||||
|
||||
def put(self, urls):
|
||||
self._expire()
|
||||
known = {url for url, _expires in self._entries}
|
||||
added = 0
|
||||
for url in urls:
|
||||
if url in known:
|
||||
continue
|
||||
known.add(url)
|
||||
self._entries.append((url, time.monotonic() + self._ttl))
|
||||
added += 1
|
||||
if len(self._entries) > self._limit:
|
||||
del self._entries[:len(self._entries) - self._limit]
|
||||
return added
|
||||
|
||||
def take(self):
|
||||
self._expire()
|
||||
if not self._entries:
|
||||
return None
|
||||
url, _expires = self._entries.pop(0)
|
||||
return url
|
||||
|
||||
def clear(self):
|
||||
self._entries.clear()
|
||||
|
||||
def __len__(self):
|
||||
self._expire()
|
||||
return len(self._entries)
|
||||
|
||||
def _expire(self):
|
||||
now = time.monotonic()
|
||||
self._entries = [entry for entry in self._entries if entry[1] > now]
|
||||
|
||||
|
||||
class WorkerLease:
|
||||
__slots__ = ('worker', '_pool', '_key', '_released', 'busy',
|
||||
'_counted', '_active_until')
|
||||
|
||||
def __init__(self, worker, pool, key, active_until=0.0):
|
||||
self.worker = worker
|
||||
self._pool = pool
|
||||
self._key = key
|
||||
self._released = False
|
||||
self.busy = True
|
||||
self._counted = True
|
||||
self._active_until = active_until
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.worker.url
|
||||
|
||||
def idle(self):
|
||||
self._pool.mark_idle(self)
|
||||
|
||||
def touch(self):
|
||||
self._pool.touch(self)
|
||||
|
||||
|
||||
class ProxyWorkerPool:
|
||||
def __init__(self, capacity=DEFAULT_CAPACITY, activity_window=ACTIVITY_WINDOW_SECONDS):
|
||||
self._capacity = max(0, int(capacity or 0))
|
||||
self._activity_window = max(0.0, float(activity_window))
|
||||
self._workers = ()
|
||||
self._leases = {}
|
||||
self._watched = set()
|
||||
self._retired_at = {}
|
||||
self._cursor = 0
|
||||
self._selection = 'load'
|
||||
self._revision = 0
|
||||
self._next_expiry = float('inf')
|
||||
self._lock = threading.Lock()
|
||||
self._wakeup = asyncio.Event()
|
||||
self._quota = asyncio.Semaphore(0)
|
||||
self._waiting = 0
|
||||
self._generation = 0
|
||||
self._event_loop = None
|
||||
|
||||
def rebind(self):
|
||||
loop = asyncio.get_running_loop()
|
||||
with self._lock:
|
||||
self._wakeup = asyncio.Event()
|
||||
self._quota = asyncio.Semaphore(0)
|
||||
self._waiting = 0
|
||||
self._generation += 1
|
||||
self._event_loop = loop
|
||||
for lease in self._leases.values():
|
||||
lease._released = True
|
||||
self._leases.clear()
|
||||
self._watched.clear()
|
||||
self._revision += 1
|
||||
self._next_expiry = float('inf')
|
||||
for worker in self._workers:
|
||||
worker.inflight = 0
|
||||
worker.active = 0
|
||||
|
||||
def _signal_wakeup(self, permits=None):
|
||||
loop = self._event_loop
|
||||
if loop is None:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._wakeup.set)
|
||||
if permits is None or permits > 0:
|
||||
loop.call_soon_threadsafe(self._wake_up_waiters, permits)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _wake_up_waiters(self, permits=None) -> None:
|
||||
with self._lock:
|
||||
pending = self._waiting
|
||||
count = pending if permits is None else min(permits, pending)
|
||||
for _ in range(max(0, count)):
|
||||
self._quota.release()
|
||||
|
||||
def set_selection(self, selection):
|
||||
self._selection = 'order' if selection == 'order' else 'load'
|
||||
|
||||
def apply_capacity(self, capacity):
|
||||
with self._lock:
|
||||
self._capacity = max(0, int(capacity or 0))
|
||||
for worker in self._workers:
|
||||
worker.capacity = self._capacity
|
||||
|
||||
def set_workers(self, urls):
|
||||
with self._lock:
|
||||
survivors = {worker.url: worker for worker in self._workers}
|
||||
workers = []
|
||||
for url in urls:
|
||||
worker = survivors.pop(url, None)
|
||||
if worker is None:
|
||||
worker = ProxyWorker(url, sanitize_proxy(url), self._capacity)
|
||||
else:
|
||||
worker.capacity = self._capacity
|
||||
workers.append(worker)
|
||||
for orphan in survivors.values():
|
||||
orphan.retired = True
|
||||
self._workers = tuple(workers)
|
||||
self._revision += 1
|
||||
self._signal_wakeup()
|
||||
|
||||
def add_worker(self, url, expires_at=0.0):
|
||||
with self._lock:
|
||||
existing = self.find(url)
|
||||
if existing is not None:
|
||||
return existing
|
||||
worker = ProxyWorker(url, sanitize_proxy(url), self._capacity, expires_at)
|
||||
self._workers = self._workers + (worker,)
|
||||
self._revision += 1
|
||||
self._signal_wakeup(self._capacity)
|
||||
return worker
|
||||
|
||||
@staticmethod
|
||||
def lifetime_ended(worker, now, request_limit=0) -> bool:
|
||||
if worker.expires_at and now >= worker.expires_at:
|
||||
return True
|
||||
return request_limit > 0 and worker.requests_served >= request_limit
|
||||
|
||||
def expired_workers(self, now, request_limit=0):
|
||||
return [w for w in self._workers
|
||||
if self.lifetime_ended(w, now, request_limit)]
|
||||
|
||||
def replan_expiries(self, expires_at):
|
||||
with self._lock:
|
||||
for worker in self._workers:
|
||||
worker.expires_at = expires_at
|
||||
|
||||
async def wait_for_roster_change(self, timeout):
|
||||
with self._lock:
|
||||
self._wakeup.clear()
|
||||
try:
|
||||
await asyncio.wait_for(self._wakeup.wait(), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
def readmit(self, worker, expires_at) -> bool:
|
||||
with self._lock:
|
||||
if worker.retired or worker not in self._workers:
|
||||
return False
|
||||
worker.expires_at = expires_at
|
||||
worker.requests_served = 0
|
||||
worker.added_at = time.monotonic()
|
||||
return True
|
||||
|
||||
def drop_worker(self, url, remember=False):
|
||||
with self._lock:
|
||||
worker = self.find(url)
|
||||
if worker is None:
|
||||
return False
|
||||
worker.retired = True
|
||||
self._workers = tuple(w for w in self._workers if w is not worker)
|
||||
if remember:
|
||||
self._retired_at[worker.url] = time.monotonic()
|
||||
self._prune_retired_locked()
|
||||
self._revision += 1
|
||||
self._signal_wakeup(0)
|
||||
return True
|
||||
|
||||
def _prune_retired_locked(self):
|
||||
now = time.monotonic()
|
||||
expired = [url for url, at in self._retired_at.items()
|
||||
if now - at >= RETIRED_MEMORY_SECONDS]
|
||||
for url in expired:
|
||||
self._retired_at.pop(url, None)
|
||||
|
||||
@property
|
||||
def roster(self):
|
||||
return self._workers
|
||||
|
||||
def find(self, url):
|
||||
for worker in self._workers:
|
||||
if worker.url == url:
|
||||
return worker
|
||||
return None
|
||||
|
||||
def recently_retired(self, url, within=RETIRED_MEMORY_SECONDS):
|
||||
retired_at = self._retired_at.get(url)
|
||||
return retired_at is not None and (time.monotonic() - retired_at) < within
|
||||
|
||||
def snapshot(self):
|
||||
self.expire_idle(wake=False)
|
||||
return [worker.snapshot() for worker in self._workers]
|
||||
|
||||
def lease_of(self, task):
|
||||
with self._lock:
|
||||
return self._leases.get(task)
|
||||
|
||||
def saturation(self, reference_capacity):
|
||||
if not reference_capacity:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
workers = [w for w in self._workers if not w.retired]
|
||||
if not workers:
|
||||
return None
|
||||
return sum(1 for w in workers if w.active >= reference_capacity) / len(workers)
|
||||
|
||||
def utilization(self, reference_capacity):
|
||||
if not reference_capacity:
|
||||
return None
|
||||
|
||||
with self._lock:
|
||||
workers = [w for w in self._workers if not w.retired]
|
||||
if not workers:
|
||||
return None
|
||||
used = sum(min(w.active, reference_capacity) for w in workers)
|
||||
return used / (len(workers) * reference_capacity)
|
||||
|
||||
def _pick(self, exclude=None):
|
||||
roster = self._workers
|
||||
if not roster:
|
||||
return None
|
||||
|
||||
capable = [w for w in roster if w.has_free_slot()]
|
||||
if exclude is not None and capable:
|
||||
without = [w for w in capable if w.url != exclude]
|
||||
if without:
|
||||
capable = without
|
||||
if not capable:
|
||||
return None
|
||||
|
||||
healthy = [worker for worker in capable if not worker.suspect]
|
||||
pool = healthy or capable
|
||||
|
||||
if self._selection == 'order':
|
||||
return pool[0]
|
||||
|
||||
best_load = min(worker.load for worker in pool)
|
||||
top = [worker for worker in pool if worker.load == best_load]
|
||||
if len(top) == 1:
|
||||
return top[0]
|
||||
|
||||
index_of = {id(worker): index for index, worker in enumerate(roster)}
|
||||
chosen = min(top, key=lambda w: (index_of[id(w)] - self._cursor) % len(roster))
|
||||
self._cursor = (index_of[id(chosen)] + 1) % len(roster)
|
||||
return chosen
|
||||
|
||||
async def acquire(self, task=None, exclude=None):
|
||||
key = task if task is not None else asyncio.current_task()
|
||||
if key is None:
|
||||
key = object()
|
||||
|
||||
self.release(key)
|
||||
|
||||
while True:
|
||||
with self._lock:
|
||||
self._expire_locked(time.monotonic())
|
||||
worker = self._pick(exclude)
|
||||
if worker is not None:
|
||||
now = time.monotonic()
|
||||
worker.inflight += 1
|
||||
worker.active += 1
|
||||
worker.requests_served += 1
|
||||
worker.last_used = now
|
||||
lease = WorkerLease(worker, self, key, now + self._activity_window)
|
||||
self._leases[key] = lease
|
||||
self._watch(key)
|
||||
return lease
|
||||
if not self._workers:
|
||||
return None
|
||||
self._waiting += 1
|
||||
generation = self._generation
|
||||
try:
|
||||
await asyncio.wait_for(self._quota.acquire(), _QUEUE_RECHECK_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
with self._lock:
|
||||
if generation == self._generation:
|
||||
self._waiting -= 1
|
||||
|
||||
def _watch(self, key):
|
||||
if key in self._watched:
|
||||
return
|
||||
add_done_callback = getattr(key, 'add_done_callback', None)
|
||||
if add_done_callback is None:
|
||||
return
|
||||
self._watched.add(key)
|
||||
add_done_callback(self._on_task_done)
|
||||
|
||||
def _on_task_done(self, task):
|
||||
self._watched.discard(task)
|
||||
self.release(task)
|
||||
|
||||
def release(self, task, blame=False, reason=''):
|
||||
with self._lock:
|
||||
lease = self._leases.pop(task, None)
|
||||
if lease is None:
|
||||
return None
|
||||
lease._released = True
|
||||
worker = lease.worker
|
||||
worker.inflight = max(0, worker.inflight - 1)
|
||||
freed_slot = lease._counted
|
||||
if lease._counted:
|
||||
lease._counted = False
|
||||
worker.active = max(0, worker.active - 1)
|
||||
outcome = self._blame_locked(worker, reason) if blame else None
|
||||
if freed_slot or outcome == BLAME_RETIRED:
|
||||
self._wakeup.set()
|
||||
permits = 1 if freed_slot else 0
|
||||
for _ in range(permits):
|
||||
self._quota.release()
|
||||
return outcome
|
||||
|
||||
def mark_idle(self, lease):
|
||||
with self._lock:
|
||||
if lease._released:
|
||||
return
|
||||
lease.busy = False
|
||||
if lease._counted:
|
||||
self._note_expiry_locked(lease)
|
||||
|
||||
def touch(self, lease):
|
||||
with self._lock:
|
||||
if lease._released:
|
||||
return
|
||||
lease._active_until = time.monotonic() + self._activity_window
|
||||
if not lease._counted:
|
||||
lease._counted = True
|
||||
lease.worker.active += 1
|
||||
if not lease.busy:
|
||||
self._note_expiry_locked(lease)
|
||||
|
||||
def expire_idle(self, now=None, wake=False) -> int:
|
||||
with self._lock:
|
||||
expired = self._expire_locked(time.monotonic() if now is None else now)
|
||||
if expired and wake:
|
||||
self._wakeup.set()
|
||||
permits = min(expired, self._waiting)
|
||||
else:
|
||||
permits = 0
|
||||
for _ in range(permits):
|
||||
self._quota.release()
|
||||
return expired
|
||||
|
||||
def _expire_locked(self, now) -> int:
|
||||
if now < self._next_expiry:
|
||||
return 0
|
||||
expired = 0
|
||||
soonest = float('inf')
|
||||
for lease in self._leases.values():
|
||||
if not lease._counted or lease.busy:
|
||||
continue
|
||||
if lease._active_until <= now:
|
||||
lease._counted = False
|
||||
lease.worker.active = max(0, lease.worker.active - 1)
|
||||
expired += 1
|
||||
continue
|
||||
if lease._active_until < soonest:
|
||||
soonest = lease._active_until
|
||||
self._next_expiry = soonest
|
||||
return expired
|
||||
|
||||
def _note_expiry_locked(self, lease) -> None:
|
||||
if lease._active_until < self._next_expiry:
|
||||
self._next_expiry = lease._active_until
|
||||
|
||||
def _blame_locked(self, worker, reason):
|
||||
if worker.retired:
|
||||
return BLAME_IGNORED
|
||||
|
||||
worker.failures += 1
|
||||
worker.outcomes.append(False)
|
||||
if not self._should_retire(worker):
|
||||
return BLAME_COUNTED
|
||||
|
||||
if len(self._workers) <= 1:
|
||||
return BLAME_COUNTED
|
||||
|
||||
self._retire_locked(worker)
|
||||
return BLAME_RETIRED
|
||||
|
||||
@staticmethod
|
||||
def _should_retire(worker) -> bool:
|
||||
outcomes = worker.outcomes
|
||||
if len(outcomes) < _RETIRE_MIN_OUTCOMES:
|
||||
return False
|
||||
return worker.failure_ratio >= _RETIRE_FAILURE_RATIO
|
||||
|
||||
def _retire_locked(self, worker):
|
||||
self._retired_at[worker.url] = time.monotonic()
|
||||
worker.retired = True
|
||||
self._workers = tuple(w for w in self._workers if w is not worker)
|
||||
self._revision += 1
|
||||
self._prune_retired_locked()
|
||||
|
||||
def report_failure(self, worker, reason=''):
|
||||
if worker is None:
|
||||
return BLAME_IGNORED
|
||||
with self._lock:
|
||||
if worker not in self._workers:
|
||||
return BLAME_IGNORED
|
||||
outcome = self._blame_locked(worker, reason)
|
||||
self._wakeup.set()
|
||||
return outcome
|
||||
|
||||
def note_success(self, worker):
|
||||
if worker is None:
|
||||
return
|
||||
with self._lock:
|
||||
worker.failures = 0
|
||||
worker.outcomes.append(True)
|
||||
|
||||
@property
|
||||
def revision(self):
|
||||
return self._revision
|
||||
|
||||
|
||||
__all__ = [
|
||||
'ProxyWorker', 'WorkerLease', 'ProxyWorkerPool', 'StandbyQueue',
|
||||
'DEFAULT_CAPACITY', 'RETIRED_MEMORY_SECONDS', 'ACTIVITY_WINDOW_SECONDS',
|
||||
'STANDBY_TTL_SECONDS', 'STANDBY_LIMIT',
|
||||
'BLAME_IGNORED', 'BLAME_COUNTED', 'BLAME_RETIRED',
|
||||
]
|
||||
@@ -0,0 +1,235 @@
|
||||
# Proxy Pool Module
|
||||
|
||||
[Back to README](../../README-EN.md)
|
||||
|
||||
The proxy resource pool: scrapes public proxies, validates their usability, manages them by quality score, and supplies them to ProxyCat's outbound proxy service on demand.
|
||||
|
||||
**This module is not a standalone project.** It runs as part of ProxyCat inside the same process, has no port of its own, and no longer has a separate web UI either — its management pages are the three sub-views Proxy Management, Pool Settings and Database Maintenance under the Proxy Pool tab in the ProxyCat panel.
|
||||
|
||||
- How to use: see [README-EN.md](../../README-EN.md) in the repository root
|
||||
- API documentation: see [API-EN.md](../../API-EN.md) in the repository root
|
||||
- Configuration: the `[Pool]` section of `config/config.ini` (migrated automatically from the old `config.yaml` on first start)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
proxypool/
|
||||
├── core/ Core implementation (layered)
|
||||
│ ├── config.py Configuration data model (the data structure of the [Pool] section)
|
||||
│ ├── paths.py Sub-project root anchor, so nothing depends on the process working directory
|
||||
│ ├── database.py Table creation and column migrations
|
||||
│ ├── backup.py Scheduled database backup and restore
|
||||
│ ├── data_cleaner.py Database optimization and statistics queries
|
||||
│ ├── logger.py Rotating file handler for the pool log
|
||||
│ ├── domain/ Domain models and exceptions
|
||||
│ ├── interfaces/ Abstract contracts (repositories, services, infrastructure)
|
||||
│ ├── infrastructure/ Connection pool, dedup cache, HTTP client, DI container, configuration manager, offline geolocation database
|
||||
│ ├── data/ Repository implementations and the asynchronous write queue
|
||||
│ ├── services/ Validator, plugin manager, automatic revalidation, health scoring, GeoIP
|
||||
│ └── application/ Service orchestration (Application), background tasks, business operations layer
|
||||
├── plugins/ Scrape plugins (put new plugins here)
|
||||
├── data/proxies.db SQLite database
|
||||
├── data/backups/ Automatic database backups (expired ones are pruned automatically)
|
||||
├── data/geodb/ Offline geolocation databases (~104 MB, not committed; pulled by the update command, see "Geolocation")
|
||||
└── logs/proxy_pool.log Pool log (also fed into the panel's Logs)
|
||||
```
|
||||
|
||||
## Scrape Plugin Development
|
||||
|
||||
A plugin is a `.py` file in the `plugins/` directory; the file name is the plugin name (and also the `source_plugin` of the proxies it collects). Plugins are scanned and loaded when the pool service starts, so a newly added file only appears after the pool service is restarted; for a plugin already in the list, after its code is changed, click **Reload** under Proxy Pool → Plugin Management in the panel to hot-reload it without a restart.
|
||||
|
||||
### Option 1: Implement IProxyPlugin (Recommended)
|
||||
|
||||
```python
|
||||
"""Example: scrape proxies from an API."""
|
||||
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from core.domain.exceptions import PluginExecutionException
|
||||
from core.interfaces.services import IProxyPlugin, PluginContext
|
||||
|
||||
|
||||
class ExamplePlugin(IProxyPlugin):
|
||||
"""Plugin description. It is written to the log and also shown in the plugin list."""
|
||||
|
||||
API_URL = "https://example.com/api/proxies"
|
||||
|
||||
async def fetch_proxies(self, context: PluginContext) -> List[str]:
|
||||
"""Return a list of proxy URLs, each in the form protocol://ip:port or protocol://user:pass@ip:port."""
|
||||
response = await context.http_client.get(self.API_URL)
|
||||
if response.status != 200:
|
||||
# Raise when the fetch fails: returning an empty list is taken by the
|
||||
# scheduler as "this source has no proxies right now" — it neither
|
||||
# retries nor reports an error in the plugin list.
|
||||
raise PluginExecutionException(
|
||||
f"Request to {self.API_URL} failed, HTTP status code: {response.status}"
|
||||
)
|
||||
|
||||
items = json.loads(response.text).get("data", [])
|
||||
return [f"http://{item['ip']}:{item['port']}" for item in items]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "example"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
```
|
||||
|
||||
`PluginContext` provides three things:
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `http_client` | Asynchronous HTTP client. A single request's default timeout is `plugins.request_timeout_seconds`, and a whole fetch is additionally bounded by `plugins.execution_timeout_seconds`; no need to manage a connection pool yourself |
|
||||
| `logger` | A logger already named after the plugin |
|
||||
| `config` | The configuration object currently in effect, from which `[Pool]` options can be read |
|
||||
|
||||
### Option 2: Provide Only a fetch_proxies Function
|
||||
|
||||
The old style is still supported: just define a `fetch_proxies` function in the module (synchronous or asynchronous both work; the synchronous version is executed in a thread pool):
|
||||
|
||||
```python
|
||||
def fetch_proxies():
|
||||
return ["http://1.2.3.4:8080", "socks5://5.6.7.8:1080"]
|
||||
```
|
||||
|
||||
### Conventions and Caveats
|
||||
|
||||
- Return a `list[str]`; every element must be `protocol://[user:pass@]ip:port`; entries that fail to parse are skipped one by one with a log line.
|
||||
- Only the `http`, `https` and `socks5` protocols are supported.
|
||||
- A plugin **does not** need to store anything itself, nor deduplicate or validate — the plugin manager deduplicates fetch results by `ip:port` and hands them to the ingest pipeline (only proxies that pass validation are stored).
|
||||
- An exception raised by one plugin does not affect the other plugins; a failure is retried with exponential backoff up to `plugins.max_retries_on_failure` times (the total number of attempts is that value + 1).
|
||||
- The number of plugins running at the same time is capped by `plugins.max_parallel_plugins`; a change takes effect immediately.
|
||||
- The fetch interval and test URL are set per plugin and stored in the database — there is no need to write them into the configuration file.
|
||||
|
||||
## How Proxies Enter the Pool
|
||||
|
||||
```
|
||||
Fetch (plugins) ─┐
|
||||
Run plugin manually ─┼─► Deduplicate ─► Port pre-screen ─► Validation ─► Store only "confirmed working" ones ─► Resolve geolocation asynchronously
|
||||
Batch import ─┘
|
||||
```
|
||||
|
||||
- **Only working proxies are stored** (`core/application/ingest.py`): this is the only entry point through which proxies enter the pool. **Conclusively unusable** ones (cannot connect to the proxy, the proxy refuses to forward) and ones **inconclusive for this round** (our check endpoints are broken, this host has no network) are not written to the database; a proxy that survives the liveness probe but fails to obtain an egress IP in a way attributable to the proxy is likewise not written (failures attributable to our side are still stored, pending a later re-test). The cost is that proxies collected while the check chain is flapping get discarded; the benefit is that every record in the pool really did pass validation — otherwise the question "how many usable proxies are in the pool" simply cannot be answered. (Note that this does not conflict with the "inconclusive results are not punished" point below: that one is about not deleting **existing** records.)
|
||||
- **Port pre-screen**: fully validating a black-hole proxy — one that accepts connections but never forwards — burns the whole timeout (about 6.5 seconds), while public proxy lists run to hundreds of thousands of entries, the vast majority of which have nothing listening at all. The pre-screen does a single TCP connect: anything that cannot connect is out immediately and **never enters validation**. It touches no proxy protocol and requests no external site, so it can run at several times the validation concurrency (4× by default; floor 64, ceiling 2000).
|
||||
- Measured: 3000 unroutable addresses dropped from 64 seconds to 17, without sending a single validation request.
|
||||
- Known limitation: the pre-screen cannot stop black-hole proxies that accept connections but never forward; those can only be caught by the validation timeout. So a large scrape of hundreds of thousands of addresses is still a wait measured in minutes — the progress bar shows the live rate and estimated time remaining, and the task can be cancelled at any time.
|
||||
- **The test URL is per plugin**: the default is `[Server] test_url`, and each plugin can specify its own test URL under Proxy Pool → Plugin Management (`PUT /api/pool/plugins/<name>/test-url`; leaving it empty restores inheritance of the global one).
|
||||
- **Batch validation has two levels** (the `mode` of `POST /api/pool/proxies/batch/validate`): `liveness` only judges "is it still reachable" (it touches neither the egress IP nor anonymity, and does not advance the full-evaluation time; the liveness probe itself records the protocols that carried the test URL as supported), `full` refreshes identity and capability together, and `auto` keeps the original "choose per record state". The panel's **Check liveness** / **Full check** buttons correspond to the first two respectively.
|
||||
- **Per-plugin validation policy** (`PUT /api/pool/plugins/<name>/validation`): the revalidation switch, the revalidation interval and **skip ingest validation** are submitted together in one call. The last one is aimed at proxy sources billed per request whose validity the provider guarantees — once turned on, proxies scraped by that plugin go straight into the database without pre-screening or validation, neither connecting to the proxy nor sending probe requests. The cost is that these records have no egress IP, delay or protocol support, their anonymity stays at `unverified`, and their health score is computed from the defaults for missing data (`total_checks` is 0); all of that is only filled in once automatic revalidation has actually run a check. It is therefore independent of the revalidation switch: turning off ingest validation alone does not stop the later scheduled checks.
|
||||
|
||||
## Validation
|
||||
|
||||
- **Three intensities** (`ValidationMode`, chosen centrally by `validation_policy`):
|
||||
|
||||
| Intensity | Probes | When it is used |
|
||||
|---|---|---|
|
||||
| `LIVENESS` | Liveness | Routine rounds |
|
||||
| `IDENTITY` | Liveness + **HTTPS identity** + **plaintext identity** | A full evaluation already exists, but **identity data is incomplete** (no egress IP, or anonymity unknown — the latter can only be old data) |
|
||||
| `FULL` | The three above + plaintext forwarding capability | Full checks, first-time ingest, and when the last full evaluation is more than `auto_revalidation.full_recheck_interval_minutes` ago |
|
||||
|
||||
- A "probe" is not the same as a request: inside each probe, several candidate endpoints are **dispatched one after another** according to direct-connect latency (see the race below), so the actual request count depends on how many endpoints are reachable. The counts in the table are lower bounds.
|
||||
- **`IDENTITY` has two identity probes**, not one: the HTTPS one yields the egress IP, and the plaintext one **yields anonymity only** (the reason is under "Both the plaintext and HTTPS paths are evidence" below). Anonymity prefers plaintext evidence, so filling in identity data updates anonymity along the way.
|
||||
- `FULL`'s fourth probe is sent only when **the test URL is not plaintext http**: when the test URL is itself http, the liveness probe has already proved that plaintext forwarding works, so there is no need to probe again. The default test URL is https, so count it as four probes.
|
||||
|
||||
- **The liveness probe is the gate**: the test URL is requested through the proxy first (answering "does it work, and how fast"), and **if it fails, that is the end of it**; only after it passes are the remaining probes sent **concurrently** (the wall clock is the slowest of them, not their sum).
|
||||
- Why it has the final say: it is the **only** probe that can answer "can this proxy open the site you want". If a success in "some other probe" were allowed to certify usability, the pool would take in a string of records "judged usable, but with delay / egress IP / anonymity / region all empty" — all four of those fields come from the later probes. In a real database, all 9 such records arose exactly this way.
|
||||
- It also saves a great deal of work: failed candidates are the vast majority in a large scrape, and they now cost **just 1 request** (previously the identity and plaintext probes were fired off along with it). Measured: a single black-hole proxy went from 7.6 seconds to 5.4, throughput from 57 to 67 per second, and the number of requests sent to external sites dropped to a third.
|
||||
- The probes after the gate are **independent of one another and therefore sent concurrently** (`IDENTITY` has the HTTPS identity and plaintext identity probes; `FULL` adds the plaintext capability probe). The HTTPS identity and plaintext capability probes together form a "protocol × target protocol" capability matrix: an `http://` target goes over plaintext forwarding while an `https://` target goes through a CONNECT tunnel; in testing there are proxies that only handle one of the two, so the two are recorded separately.
|
||||
- **The egress IP is detected only when needed**: it goes through HTTPS check endpoints (`validator.identity_echo_apis` / `ip_check_apis`, which **only accept https** — plaintext echoes can be forged by the proxy, and identity data feeds both the geolocation and the anonymity decisions). The last hop of the `origin` chain is taken, **preferring IPv4**; IPv6 is accepted only when the whole chain has no IPv4.
|
||||
- **No truncation by count**: candidates are ordered by **our** direct-connect latency, but "can this proxy reach a given endpoint" depends on **its** network — the ones at the front being blocked on its side is the norm. Truncating would leave usable proxies permanently unable to obtain an egress IP, and therefore permanently without a region.
|
||||
- Taking part in full does not slow the failure path: the dispatch window has a total cap (1.5 seconds), so even a dozen candidates are all sent within 1.5 seconds and the cost of failure is still "window + timeout". On success, the first one returns.
|
||||
- The endpoints must **span providers**: same-family mirrors tend to be blocked together. The default list contains both the httpbin family (which returns an `origin` chain → anonymity falls out of it too) and pure IP echoes (icanhazip / ipinfo / ipwho.is / Cloudflare trace); supported response shapes include plain text, `{"ip": ...}` and line-by-line `key=value`.
|
||||
- When a reachable proxy cannot reach any echo endpoint, a WARNING is logged — that is exactly the root cause of an "empty egress IP and region" record, and it should not be silent.
|
||||
- **Anonymity is judged by "what the target actually received"** (`_judge_anonymity`): transparent (the target can tell who we are) / anonymous (it can tell a proxy is in between) / elite (neither is visible). The criterion is not the proxy's protocol type but what the target actually received when it forwarded.
|
||||
- **The evidence has two carriers, and both are needed.** In testing, the four default endpoints behaved in completely different ways:
|
||||
|
||||
| Endpoint (fronted by) | `origin` | `X-Forwarded-For` in the echoed headers | What it can be judged by |
|
||||
|---|---|---|---|
|
||||
| httpbin.org / eu.httpbin.org (AWS ALB) | Present, **with the XFF folded in** | **Stripped** (`Via` too) | `origin` only |
|
||||
| httpbingo.org (fly.io) | Present, but **only the peer**, without XFF | **Kept** | Headers only |
|
||||
| postman-echo (Cloudflare) | **Absent** | **Stripped** | Only the `Via` it echoes back |
|
||||
|
||||
Relying on headers alone would judge nothing on httpbin, and relying on origin alone would judge nothing on httpbingo — the two are **two presentations of the same evidence** and must be used together. Look at this table before swapping or adding endpoints.
|
||||
- **The test**: ① this host's egress IP appears in the `origin` chain or in any header value → transparent (**no baseline subtraction**: the question is precisely "does what the target received after this proxy include us"); ② proxy traces appear → anonymous — the `origin` chain is longer than the direct-connection baseline, or a **proxy-added** header carries an address, or a header matches the known proxy-header list while being absent from the baseline; ③ anything else → elite.
|
||||
- **Judging "proxy smuggling" looks at "does the baseline hold an IP", not "did the value change".** A CDN-fronted endpoint writes the peer address into XFF itself: on a direct connection it is our IP, through a proxy it becomes the proxy's IP — the name is unchanged while the value changes. Judging by "the value changed" would mark **every** clean proxy as anonymous (this is exactly what httpbingo did in testing). So headers the endpoint writes itself (whose baseline holds an IP) are excluded wholesale, while a header it does not write IPs into (such as its own `Via`) can be caught the moment the proxy slips an address in.
|
||||
- **Both the plaintext and the HTTPS paths are evidence**: plaintext takes priority (it also reflects plaintext forwarding behaviour); when it is unavailable, the HTTPS path is used. Inside a tunnel the proxy forwards bytes, but if it wants to smuggle an address it can do so through a tunnel just as well.
|
||||
- **socks5 gets no shortcut**: the protocol itself injects no headers, but a node speaking SOCKS5 may be an HTTP-aware gateway, or other proxies may sit further along the path — it is judged by the same tests as every other protocol.
|
||||
- **Cases where no conclusion is drawn** (the stored old value is kept rather than guessed): the `validator.check_anonymity` switch is off; this host's egress IP cannot be obtained (no basis for comparison); no identity probe was run at all this round (a lightweight LIVENESS round); or the failure is attributable to our side because the echo endpoints are unreachable. Only "the probe ran, yet not one usable echo came back" falls back to transparent with the `anonymity_fallback` flag, which the UI uses to tell it apart from a measured result.
|
||||
- The plaintext endpoints are **derived at the same addresses** from the HTTPS echo endpoint list (httpbingo's plaintext variant is the only one that can obtain XFF evidence), and can additionally be supplemented via `validator.plain_identity_echo_apis`.
|
||||
- **This host's public egress IP** (`ValidationEndpoints.ensure_host_ip`): the comparison baseline for judging transparent, obtained by visiting IP-query sites **without going through a proxy** (the `validator.ip_check_apis` set); it stops at the first success, caches for a 30-minute TTL, and backs off for the same TTL on failure. A user can put their egress IP straight into `validator.host_public_ip` and then not a single request is sent — the UI shows the value currently in effect and where it came from (manually set / auto-detected / not obtained).
|
||||
- **Anonymity is optional overhead**: with `validator.check_anonymity` off, no more plaintext identity probes are sent and anonymity is no longer judged; existing levels are kept as they are and new records stay at `unverified`; the endpoint pre-screen loses the plaintext batch of candidates as well (under the default configuration, requests per round drop from 11 to 4).
|
||||
- **Failure attribution**: every failure carries a conclusion category. Only "cannot connect to the proxy" and "the proxy does not forward" count as the proxy's fault; our check endpoints being unreachable, the test URL being unreachable and this host losing its network are all recorded as **inconclusive**, and no field is written then — the proxy is neither judged invalid nor charged a failure, so flapping check infrastructure never triggers automatic cleanup.
|
||||
- **Timeouts are fixed values**: every request uses `validator.timeout_seconds`, and the connection phase additionally caps the TCP handshake with `validator.connect_timeout_seconds` (this maps to aiohttp's `sock_connect`, not `connect`; the latter would include establishing a CONNECT tunnel and would wrongly fail usable proxies whose tunnels are slow) — black-hole proxies thus fail fast within the connect timeout, and that is the main source of the pool's speed. There used to be a mechanism of "dynamically deriving a per-request timeout from this host's direct-connect timings" plus a "whole-wave wall-clock budget"; it has been removed: combined, the two would starve identity probes of budget whenever the liveness probe was running a little slow, making "never measured" and "measured as failed" indistinguishable in the database.
|
||||
- **Endpoint pre-screening and the race**: before validation, each endpoint is probed directly from this host, and unreachable ones are dropped automatically (blocked or reset endpoints need not be hit through every proxy). Each endpoint's direct response is recorded as a baseline, used both to cancel out the response headers the endpoint **itself** injects and to order candidates — during the race, candidates are **dispatched one after another** by direct-connect latency (hedging) rather than all at once, so a slow proxy is no longer hammered by seven or eight concurrent tunnels at the same time. The **total dispatch time is capped** (1.5 seconds), so the cost on the failure path is "window + timeout" rather than "candidates × interval + timeout": with 5 candidates for an identity probe, this took a single black-hole proxy's validation from 12 seconds down to 6.5.
|
||||
- **Health scoring**: delay 25% + success rate 25% + stability 15% + uptime 15% + anonymity 10% + longevity 10%. The success-rate dimension uses the **Wilson lower confidence bound of the success rate**, shrinking toward the low end when samples are few, so a proxy "validated only once or twice" cannot collect an inflated score; samples are counted **per probe** (each probe that reaches a definite conclusion casts one vote — at most 3 votes per round: liveness, HTTPS identity, plaintext forwarding capability; candidate endpoint requests sent as race hedges do not each get a vote), which is up to 3× denser than "one vote per round" and converges the estimate faster. The delay mean and variance are clamped against outlier samples, so one bout of congestion cannot drag the whole score down.
|
||||
- **Identity fields are sticky**: the egress IP and protocol support are written to the database only when actually measured this time; otherwise the last known value is kept. Anonymity works the same — the only case where a projected value is written is when the probe really ran but no echo evidence came back, in which case the fallback level is written with the `anonymity_fallback` flag, and the UI can tell it apart from a measured result. Otherwise a single failure would wipe out a known egress IP. Delay works the same way, and the "last full evaluation time" advances only when every probe reached a definite conclusion — a round that did not ask everything does not keep the proxy from being re-probed within the redo interval.
|
||||
- **Re-scraping does not wipe history**: when the same `ip:port` is stored again it is merged by upsert; `delay_variance`, uptime, the favorite flag and first-ingest time are all preserved, and only the fields this scrape actually obtained are refreshed.
|
||||
|
||||
## Geolocation
|
||||
|
||||
Geolocation **goes entirely through the local offline databases**: a single lookup returns both the Chinese and English sets of names along with the canonical ISO codes, and makes no external request. This section covers where the names come from, and what to change when a name looks wrong.
|
||||
|
||||
(It used to go through the online ip-api service only, one request each for Chinese and English, and that service's Chinese coverage of first-level administrative divisions was incomplete — where a translation existed it returned Chinese, where none existed it returned the local original, so the database ended up with one Chinese cell and one English cell side by side. That path has been removed entirely, and the four `geoip.*` options are void along with it.)
|
||||
|
||||
### How the Two Offline Databases Divide the Work
|
||||
|
||||
| Data file | Coverage | What it provides | Where it is used |
|
||||
|---|---|---|---|
|
||||
| `GeoLite2-City.mmdb` (~65 MB) | Worldwide | ISO 3166-1 country codes, ISO 3166-2 subdivision codes, and names in 8 languages | Country decisions and subdivisions outside China |
|
||||
| `qqwry.ipdb` (Chunzhen, ~39 MB) | China | Province / city / district names (Chinese) | **Supplements mainland-China province and city levels only** |
|
||||
|
||||
- **Why GeoLite2**: a single lookup gives both name sets and the canonical codes at once. This saves "two requests for one IP" (one per language in the online-API era) and removes any API quota limit entirely — the latter was one of the main motivations for adopting the offline databases.
|
||||
- **Why Chunzhen as well**: its city-level coverage in China is clearly stronger — over the same batch of 8 Chinese IPs, Chunzhen returned a city for 7, GeoLite2 for only 3.
|
||||
- **Chunzhen only takes part when `country_code == "CN"`**: its country decisions outside China are not trustworthy — in testing it judged Hong Kong as the United States and the Netherlands as Ukraine. So non-Chinese records always follow GeoLite2's conclusion; for them even Chunzhen's province and city data is not used.
|
||||
- **GeoLite2's Chinese names have gaps**: over a sample of 4000 public IPs, a first-level administrative division was found for 46.8%, and 70.5% of those carried a Chinese name; a city was found for 46.1%, of which 63.0% carried a Chinese name. When a translated name is missing, the value **falls back to the original text and is marked as "original"** (see below), instead of letting the two languages blend silently into one cell — which was exactly the previous version's behaviour: the two sets were requested separately from the API, a Chinese name came back where a translation existed and the local original where none did, so the database held one Chinese cell and one English cell with no way to tell why.
|
||||
- **The two databases may disagree** on the city for the same IP (in testing, `183.62.176.46` was Shenzhen in Chunzhen and Guangzhou in GeoLite2); in that case GeoLite2's existing `zh-CN` name wins, Chunzhen only fills levels where GeoLite2 lacks a `zh-CN` name, and the provenance of the conclusion is recorded in `geo_source` for later review.
|
||||
|
||||
### Names Are Rendered from Codes and Lookup Tables
|
||||
|
||||
What the offline databases return is not strings but **canonical codes** (ISO 3166-1 country codes + ISO 3166-2 subdivision codes) plus a "language → name" mapping. The values written to the database — `country/province/city` (the Chinese set) and `country_en/province_en/city_en` (the English set) — are **derived data** rendered from those codes:
|
||||
|
||||
- The Chinese set: `zh-CN` from the database → first-level-division lookup table → the original English text
|
||||
- The English set: `en` from the database → China province/city lookup table → the original Chinese text
|
||||
|
||||
**Why store the codes**: names change with the data sources and the lookup tables, codes do not; the display name is rendered at lookup time from the table as it stands then, while the code is what selects records that can still be recomputed (only those missing codes — see below). The provenance of the conclusion (`geolite2` / `geolite2+qqwry`) is recorded as well, to make troubleshooting and later re-judging easier.
|
||||
|
||||
**The lookup tables are a best-effort finite set**: the 34 province-level divisions of China + 156 major cities (the ones proxy exits concentrate in), plus the first-level divisions of 13 countries/regions. Areas not covered fall back to the original text — an accurate original beats an invented wrong translation.
|
||||
|
||||
**"Original" is marked in the UI**: a light-coloured asterisk after the region name, with a tooltip reading "No translated name in this language yet - showing the original text". The test **is deterministic**: the Chinese and English columns are compared field by field, and a level whose values are equal on both sides was not translated (the API exposes both sets as separate columns precisely to make this decidable, rather than guessing from whether the string contains Latin letters). Missing translations are expected and can improve cumulatively; marking them is what makes them discoverable.
|
||||
|
||||
Known limitation: an original text in a third language whose Chinese and English columns **differ to begin with** cannot be detected — for example, the Japanese 千葉県 has the English Chiba and the two columns differ, yet Chinese and Japanese share characters, so string comparison alone cannot tell. Such values can only come from merged, preserved old data; first-level divisions of Japan and Korea have Chinese names in the offline databases and are "overwritten with the new value" rather than preserved, so this is rarely encountered in practice.
|
||||
|
||||
### Data Files
|
||||
|
||||
- **Location**: `modules/proxypool/data/geodb/`; the two files total about 104 MB and are **not committed** (the whole directory is excluded in `.gitignore`). **These two files are the sole source of geolocation data**: without downloading them there is no geolocation (see below).
|
||||
- **Update command**: run `python -m core.infrastructure.geodb` from the `modules/proxypool/` directory (optionally `--dir` to set the data directory). Both databases go through jsDelivr's `@latest` — Chunzhen updates weekly and GeoLite2 twice a week, and a pinned version number would keep pulling the same old database forever. Either one failing does not affect the other; when done, the command reports item by item, with exit code 1 if anything failed.
|
||||
- **Panel entry**: Proxy Pool → Database Maintenance → **Update Geo Databases** in the Geo Databases section (`POST /api/pool/geo/update`). It creates a background task; the download takes several minutes and the progress is shown under the button. The command line suits first-time deployment, the panel suits routine updates.
|
||||
- **When files are missing**: the subsystem as a whole is judged "offline databases unavailable" and every lookup returns "unknown" — **there is no fallback anymore**. The pool still starts as usual (scraping, validation and exit forwarding do not depend on it), but regions will be empty; the startup log records a WARNING pointing at the remedy, and the panel's Geo Databases section shows "not downloaded". Using "whichever database is present" is deliberately avoided here: with partial availability, "this IP cannot be found" could mean the database does not have it or merely that one file is missing, and that is far more expensive to troubleshoot than simply declaring the subsystem unavailable.
|
||||
- The panel shows each file's presence, size and modification time, plus the number of records pending recompute.
|
||||
|
||||
### Caching
|
||||
|
||||
- **Cached per IP** (`core/services/geoip.py`): a result with both the Chinese and English sets is cached for 24 hours; one with only a single set or a failed lookup is cached for 1 hour (so that a "half-missing" result is not locked in as complete for a whole day); the entry cap is 10000. The cache is in-process memory; a restart re-queries.
|
||||
- **Nothing is written for what was not found**: only the languages that were resolved are written. A failed lookup writes no field at all — this used to overwrite an already-resolved region with "unknown", the exact opposite of the "write only what was measured" policy used for the other identity fields.
|
||||
- **Recompute only touches records without codes** (`country_code IS NULL`): they were either never looked up, or looked up in the "online API only" version (when canonical codes were unavailable, so names could not be re-rendered per language). The panel's **Recompute Geo** (`POST /api/pool/geo/recompute`) does exactly this; the startup backfill enqueues at most 5000 records at a time (the queue only has capacity for 2000, and filling it would crowd out lookups triggered along the validation path), and the excess is left to that button. Records already resolved through the offline databases are not recomputed — to refresh them after a data-file update you would first have to clear their `country_code` (no such operation is currently provided).
|
||||
|
||||
### Resolution Is Asynchronous
|
||||
|
||||
- **Asynchronous resolution** (`core/services/geo_resolver.py`): the validation stage is only responsible for asking "what is the egress IP"; once it has the answer it drops the IP into the queue and returns — the lookup, retries and database write all happen in the background and consume none of the validation's wall clock. It used to be awaited record by record inside the validation coroutine at five call sites, directly stretching every round's wall clock.
|
||||
- **A few concurrent workers do the lookups**: the queue and workers exist to decouple geolocation from validation — validation just drops the egress IP into the queue and returns, while the background does the writing at its own pace. The lookup itself is now a local table read (microseconds), no longer the "online-API timeout plus ten-plus seconds of retries" it used to be, so the concurrency level (4) today mainly smooths out the write pace rather than reducing lookup latency.
|
||||
- **Startup backfill**: the queue is in-memory, so a restart loses whatever was not looked up. On startup, therefore, records that are currently valid, have an egress IP, but have no region (when the offline databases are available, missing canonical codes count too) are re-enqueued — otherwise these proxies would have to wait for their next full evaluation (24 hours by default) to be looked up again, with an empty region in the meantime.
|
||||
- **The attempt cap is per time window**: the same egress IP is attempted at most 3 times within 30 minutes. It used to be a one-shot counter — after 3 failures that IP was never looked up again, so a single API blip condemned its geolocation permanently.
|
||||
- **Where the two sets come from**: `country/province/city/region` is the Chinese set and `country_en/province_en/city_en/region_en` is the English set. With the offline databases, **a single lookup yields both sets at once** (neither translated from the other, nor fetched by two requests).
|
||||
- Region search hits either side (`region LIKE ? OR region_en LIKE ?`): searching 美国 and searching `United States` return the same records.
|
||||
- What the UI displays **follows the UI language**: the English interface shows `region_en` and puts the other set in the tooltip; sorting by region also switches to the matching column, otherwise the list order would not match the place names on screen. When one set is missing it falls back to the other ("unknown" counts as missing and is not shown as a place name).
|
||||
- **Deduplicated by egress IP**: a batch of proxies on the same egress IP shares one lookup and one write (updating by `real_ip`), so freshly inserted records having no id yet causes no trouble.
|
||||
- **Known limitation**: the two databases only guarantee that a conclusion is traceable, not that it matches physical location — most pool IPs are datacenter/cloud hosts, for which the "location" is essentially a registration place rather than a physical one; in testing, the same IP was judged as Japan, the United States, Singapore and the United States by four different sources. This is an inherent disagreement between data sources, not a resolution problem.
|
||||
|
||||
## Automatic Cleanup
|
||||
|
||||
A proxy is deleted when it meets any of these conditions: its health score is below the threshold and it has enough validations; its consecutive failure count is over the limit; or **it has remained invalid for longer than the retention days** (`auto_cleanup.max_age_days_if_invalid`; the test is the number of days since `validated_at`, falling back to the ingest time when it is missing — every conclusive validation refreshes it whether it passed or failed, so what it measures is "how long since it was last validated", not "how long since it last passed"). **Favorited proxies are never deleted.** Deletion is irreversible, which is why the "inconclusive results are not punished" rule above is its first line of defence.
|
||||
|
||||
Deletion happens in exactly this one place. There used to be a second, parallel deletion path (the `performance.*` group of keys, computing retention from `validated_at`); the two used different criteria, and which one removed a given row depended on timing, so both have been merged into `auto_cleanup`.
|
||||
@@ -0,0 +1,276 @@
|
||||
# 代理池模块
|
||||
|
||||
代理资源池:抓取公开代理、验证可用性、按质量评分管理,并按需提供给 ProxyCat 的出口代理服务。
|
||||
|
||||
**这个模块不是独立项目。** 它作为 ProxyCat 的一部分运行在同一个进程里,没有自己的端口,
|
||||
也不再有独立的 Web 界面 —— 它的管理页面是 ProxyCat 面板「代理池」页签下的「代理管理」、
|
||||
「池设置」与「数据库维护」三个子视图。
|
||||
|
||||
- 如何使用:见仓库根目录的 [README.md](../../README.md)
|
||||
- 接口文档:见仓库根目录的 [API.md](../../API.md)
|
||||
- 配置项:`config/config.ini` 的 `[Pool]` 段(首次启动会自动从旧的 `config.yaml` 迁移)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
proxypool/
|
||||
├── core/ 核心实现(分层)
|
||||
│ ├── config.py 配置数据模型([Pool] 段的数据结构)
|
||||
│ ├── paths.py 子项目根目录锚点,避免依赖进程工作目录
|
||||
│ ├── database.py 建表与列迁移
|
||||
│ ├── backup.py 数据库定时备份与恢复
|
||||
│ ├── data_cleaner.py 数据库优化与统计查询
|
||||
│ ├── logger.py 池日志的轮转文件 handler
|
||||
│ ├── domain/ 领域模型与异常
|
||||
│ ├── interfaces/ 抽象契约(仓库、服务、基础设施)
|
||||
│ ├── infrastructure/ 连接池、去重缓存、HTTP 客户端、DI 容器、配置管理器、离线归属地库
|
||||
│ ├── data/ 仓库实现与异步写队列
|
||||
│ ├── services/ 验证器、插件管理器、自动重验证、健康评分、GeoIP
|
||||
│ └── application/ 服务编排(Application)、后台任务、业务操作层
|
||||
├── plugins/ 抓取插件(新增插件放在这里)
|
||||
├── data/proxies.db SQLite 数据库
|
||||
├── data/backups/ 数据库自动备份(超期自动淘汰)
|
||||
├── data/geodb/ 离线归属地库(约 104MB,不入库,由更新命令拉取,见「归属地」)
|
||||
└── logs/proxy_pool.log 池日志(同时会汇入面板的「运行日志」)
|
||||
```
|
||||
|
||||
## 抓取插件开发
|
||||
|
||||
插件是 `plugins/` 目录下一个 `.py` 文件,文件名即插件名(也是代理的 `source_plugin`)。
|
||||
插件在池服务启动时扫描加载,新增文件需要重启池服务才会出现;已在列表中的插件改了代码后,
|
||||
在面板的「代理池 → 插件管理」里点「重载」即可热加载,无需重启。
|
||||
|
||||
### 方式一:实现 IProxyPlugin(推荐)
|
||||
|
||||
```python
|
||||
"""示例:从某个 API 抓取代理。"""
|
||||
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from core.domain.exceptions import PluginExecutionException
|
||||
from core.interfaces.services import IProxyPlugin, PluginContext
|
||||
|
||||
|
||||
class ExamplePlugin(IProxyPlugin):
|
||||
"""插件说明。会被记录到日志,也会显示在插件列表里。"""
|
||||
|
||||
API_URL = "https://example.com/api/proxies"
|
||||
|
||||
async def fetch_proxies(self, context: PluginContext) -> List[str]:
|
||||
"""返回代理 URL 列表,格式为 protocol://ip:port 或 protocol://user:pass@ip:port。"""
|
||||
response = await context.http_client.get(self.API_URL)
|
||||
if response.status != 200:
|
||||
# 抓不到就抛:返回空列表会被调度器当成「这个源暂时没有代理」,
|
||||
# 既不重试也不在插件列表报错。
|
||||
raise PluginExecutionException(
|
||||
f"请求 {self.API_URL} 失败,HTTP 状态码: {response.status}"
|
||||
)
|
||||
|
||||
items = json.loads(response.text).get("data", [])
|
||||
return [f"http://{item['ip']}:{item['port']}" for item in items]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "example"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
```
|
||||
|
||||
`PluginContext` 提供三样东西:
|
||||
|
||||
| 成员 | 说明 |
|
||||
|---|---|
|
||||
| `http_client` | 异步 HTTP 客户端。单次请求默认超时是 `plugins.request_timeout_seconds`,整个抓取另受 `plugins.execution_timeout_seconds` 上限约束;无需自己管理连接池 |
|
||||
| `logger` | 已按插件名命名好的 logger |
|
||||
| `config` | 当前生效的配置对象,可读取 `[Pool]` 段的参数 |
|
||||
|
||||
### 方式二:只提供 fetch_proxies 函数
|
||||
|
||||
旧式写法仍被支持,模块里定义 `fetch_proxies` 函数即可(同步或异步都可以,同步版本会被放到线程池里执行):
|
||||
|
||||
```python
|
||||
def fetch_proxies():
|
||||
return ["http://1.2.3.4:8080", "socks5://5.6.7.8:1080"]
|
||||
```
|
||||
|
||||
### 约定与注意事项
|
||||
|
||||
- 返回 `list[str]`,元素必须是 `protocol://[user:pass@]ip:port`;解析失败的条目会被逐条跳过并记日志。
|
||||
- 协议只支持 `http`、`https`、`socks5`。
|
||||
- 插件**不需要**自己入库,也**不需要**自己去重或验证 —— 抓取结果由插件管理器按 `ip:port` 去重后交给入库流水线(验证通过才入库)。
|
||||
- 插件抛出的异常不会影响其他插件;失败会按 `plugins.max_retries_on_failure` 次做指数退避重试(总尝试次数是这个值 +1)。
|
||||
- 同时执行的插件数受 `plugins.max_parallel_plugins` 限制,改完即时生效。
|
||||
- 抓取周期与测试地址都按插件单独设置,存在数据库里,不需要写在配置文件里。
|
||||
|
||||
## 代理进入池子的流程
|
||||
|
||||
```
|
||||
抓取(插件) ─┐
|
||||
手动运行插件 ─┼─► 去重 ─► 端口初筛 ─► 验证 ─► 只把「确认可用」的入库 ─► 异步查归属地
|
||||
批量导入 ─┘
|
||||
```
|
||||
|
||||
- **只入可用的**(`core/application/ingest.py`):这是代理进入池子的唯一入口。**明确不可用**(连不上代理、代理拒绝转发)与**本轮不可结论**(我方检测端点故障、本机断网)都不写库;
|
||||
通过存活探测、却归因于代理拿不到出口 IP 的同样不写(失败可归因于我方的仍入库待补测)。代价是检测链路抖动期间抓到的代理会被丢弃;收益是池里每一条记录都真的验证通过过 —— 否则「池里有多少能用」这个问题根本答不上来。(注意这与下一条「不可结论不惩罚」并不冲突:那条约束的是不要删除**已有**记录。)
|
||||
- **端口初筛**:完整验证一个「连得上但不转发」的黑洞代理要跑满超时(约 6.5 秒),而公共代理列表动辄十几万条,其中绝大多数根本没人监听。初筛只做一次 TCP 建连:连不上的直接出局,**不进入验证**。它不碰代理协议、不请求任何外部站点,因此可以用数倍于验证的并发跑(默认 4 倍,下限 64、上限 2000)。
|
||||
- 实测:3000 个不可路由地址从 64 秒降到 17 秒,且一次验证请求都没发出。
|
||||
- 已知限制:初筛拦不住「连得上但不转发」的黑洞代理,那类只能靠验证的超时兜住;因此十几万条的大批量抓取仍是以分钟计的等待 —— 进度条会给出实时速率与预计剩余时间,随时可以取消。
|
||||
- **测试地址按插件区分**:默认用 `[Server] test_url`,每个插件可以在「代理池 → 插件管理」里单独指定自己的测试地址(`PUT /api/pool/plugins/<name>/test-url`,留空即恢复继承全局)。
|
||||
- **批量验证分两档**(`POST /api/pool/proxies/batch/validate` 的 `mode`):`liveness` 只判「还通不通」(不碰出口 IP、匿名度,也不推进完整评估时间;存活探测本身会把测试地址走通的协议记为支持),`full` 把身份与能力一并刷新,`auto` 保持原有的「按记录状态自己选」。面板的「验证有效性 / 验证全部内容」两个按钮分别对应前两者。
|
||||
- **按插件的验证策略**(`PUT /api/pool/plugins/<name>/validation`):重验证开关、重验证间隔、以及**跳过入库验证**三项一次提交。最后一项面向按请求计费、有效性由供应商保证的代理源 —— 打开后该插件抓来的代理不经过初筛与验证直接入库,不连代理也不发探测请求。代价是这些记录没有出口 IP、延迟与方案能力,匿名度停在 `unverified`,健康分按缺数据默认值算出(`total_checks` 为 0),要等自动重验证真的测过一次才补上;因此它与重验证开关相互独立,只关入库验证并不会阻止后续的定时检测。
|
||||
|
||||
## 验证
|
||||
|
||||
- **三档强度**(`ValidationMode`,由 `validation_policy` 统一选择):
|
||||
|
||||
| 强度 | 探测项 | 何时使用 |
|
||||
|---|---|---|
|
||||
| `LIVENESS` | 存活 | 默认轮次 |
|
||||
| `IDENTITY` | 存活 + **https 身份** + **明文身份** | 已有完整评估、但**身份信息不全**(没有出口 IP,或匿名度未知——后者只可能是旧数据) |
|
||||
| `FULL` | 上列三项 + 明文转发能力 | 全面检测、首次入库、距上次完整评估超过 `auto_revalidation.full_recheck_interval_minutes` |
|
||||
|
||||
- 「探测项」不等于请求数:每项内部会按直连延迟**逐个补发**多个候选端点(见下面的竞速),实际请求数取决于有几个端点能通。表里的项数是下限。
|
||||
- **`IDENTITY` 有两项身份探测**,不是一项:https 那条给出口 IP,明文那条**只给匿名度**(原因见下文「明文路径与 https 路径都是证据」)。匿名度优先用明文证据,因此补身份信息时顺带也会更新匿名度。
|
||||
- `FULL` 的第四项只在**测试地址不是明文 http** 时才发:测试地址本身是 http 时,存活探测已经证明了明文转发可用,不必再探一次。默认测试地址是 https,所以按四项算。
|
||||
|
||||
- **存活探测是闸门**:先经代理请求测试地址(回答"能不能用、多快"),**不通就到此为止**;通过之后后续各项才**并发**发出(墙钟是最慢的那一项,而不是各项之和)。
|
||||
- 为什么以它为准:它是**唯一**能回答「这个代理能不能把你要的站点打开」的探测。若允许"别的探测成功"来印证可用,就会收下一串「判为可用、但延迟/出口 IP/匿名度/地区全空」的记录 —— 这四项全都来自后续探测。实测库里 9 条这样的记录全是这么来的。
|
||||
- 顺带省下大量工作:失败候选在大批量抓取里是绝大多数,而它们现在只花 **1 个请求**(此前会连身份与明文一起打出去)。实测单个黑洞代理从 7.6 秒降到 5.4 秒,吞吐 57 → 67 个/秒,对外部站点的请求量降到 1/3。
|
||||
- 闸门之后的各项**彼此独立,因此并发**发出(`IDENTITY` 是 https 身份与明文身份两项,`FULL` 再加明文能力探测)。其中 https 身份与明文能力两项构成「协议 × 目标协议」的能力矩阵:`http://` 目标走明文转发、`https://` 目标走 CONNECT 隧道,实测存在只通其中一条的代理,因此二者分别记录。
|
||||
- **出口 IP 检测只在需要时做**:它走 https 检测端点(`validator.identity_echo_apis` / `ip_check_apis`,**只接受 https** —— 明文回显可被代理伪造,而身份数据要喂给归属地与匿名度判定)。取 origin 链的最后一跳,并**优先取 IPv4**;整条链没有 IPv4 时才接受 IPv6。
|
||||
- **不做条数截断**:候选按**我们**的直连延迟排序,而「这个代理能不能访问某个端点」取决于**它**的网络 —— 排在前面的被它那侧挡掉是常态。截断会让可用的代理永远拿不到出口 IP,也就永远查不出地区。
|
||||
- 全量参与不会拖慢失败路径:补发窗口总长有上限(1.5 秒),十几个候选也在 1.5 秒内发完,失败代价仍是「窗口 + 超时」。成功时第一条就返回。
|
||||
- 端点要**跨服务商**:同族镜像往往一起被挡。默认清单里既有 httpbin 系(能给 origin 链 → 同时得出匿名度),也有纯 IP 回显(icanhazip / ipinfo / ipwho.is / Cloudflare trace),响应形态支持纯文本、`{"ip": ...}` 与 `key=value` 逐行三类。
|
||||
- 一个可达代理却够不到任何回显端点时,会记一条 WARNING —— 那正是「出口 IP 与地区为空」的根因,不该是静默的。
|
||||
- **匿名度按「目标实际收到了什么」判**(`_judge_anonymity`):transparent(目标能看出我们是谁)/ anonymous(能看出中间有代理)/ elite(两者都看不出)。判据不是代理的协议类型,而是它转发时目标究竟收到了什么。
|
||||
- **证据有两个载体,缺一不可**。四个默认端点实测下来的行为完全不同:
|
||||
|
||||
| 端点(前置) | `origin` | `X-Forwarded-For` 在回显的 headers 里 | 能靠什么判 |
|
||||
|---|---|---|---|
|
||||
| httpbin.org / eu.httpbin.org(AWS ALB) | 有,**把 XFF 拼了进去** | **被剥掉**(Via 也是) | 只能靠 origin |
|
||||
| httpbingo.org(fly.io) | 有,但**只有对端**、不含 XFF | **保留** | 只能靠 headers |
|
||||
| postman-echo(Cloudflare) | **没有** | **被剥掉** | 只能靠它回显的 `Via` |
|
||||
|
||||
只认头部会在 httpbin 上一个都判不出来,只认 origin 会在 httpbingo 上一个都判不出来 —— 两者是**同一份证据的两种呈现**,必须合起来用。换端点或加端点前先看这张表。
|
||||
- **判据**:① 本机出口 IP 出现在 `origin` 链里、或出现在任何一个头部值里 → transparent(**不做基线减法**:问的就是「经这个代理之后目标收到的东西里有没有我们」);② 出现代理痕迹 → anonymous —— `origin` 链比直连基线更长、或某个**代理新增**的头里带着地址、或某个头命中代理特征头清单却不在基线里;③ 其余 → elite。
|
||||
- **判「代理夹带」看的是「基线那一份里有没有 IP」**,不是「值变了没有」。带 CDN 的端点自己就往 XFF 里写对端地址:直连时是我们的 IP、经代理时变成代理的 IP,名字没变值却变了。按「值变了」判会让**每一个**干净代理都被判成匿名(实测 httpbingo 就是这样)。所以端点自己在写的头(基线含 IP)整条不参与,而它不写 IP 的头(如自己的 `Via`)被代理塞进地址就能抓到。
|
||||
- **明文路径与 https 路径都是证据**:明文优先(它同时反映明文转发行为),拿不到就用 https 那条。隧道里代理按字节转发,可它要夹带地址,隧道里一样夹带得了。
|
||||
- **socks5 没有捷径**:协议本身不注入头部,但跑 SOCKS5 的节点可能是 HTTP 感知的网关,或链路上还串着别的代理 —— 与别的协议用同一套判据。
|
||||
- **不下结论的几种情形**(都保留库里的旧值,而不是猜一个):开关 `validator.check_anonymity` 关着;拿不到本机出口 IP(没有对比基准);本轮根本没做身份探测(LIVENESS 轻量轮次);失败可归因于我方的回显端点不可达。只有「探测做了、却一个可用的回显都没拿到」才兜底 transparent 并打 `anonymity_fallback` 标记,界面据此把它与实测所得区分开。
|
||||
- 明文端点由 https 回显端点清单**同址派生**(`httpbingo` 的明文变体是其中唯一能拿到 XFF 证据的那个),另可由 `validator.plain_identity_echo_apis` 补充。
|
||||
- **本机公网出口 IP**(`ValidationEndpoints.ensure_host_ip`):判定 transparent 的对比基准,**不经过代理**直接访问 IP 查询站点(`validator.ip_check_apis` 那批)获取,第一个成功即停、按 30 分钟 TTL 缓存,失败也按同一 TTL 退避。用户可以在 `validator.host_public_ip` 里直接填自己的出口 IP,那样一个请求都不发 —— 界面上会显示当前生效的值与来源(手动指定 / 自动查询 / 未获取到)。
|
||||
- **匿名度是可选开销**:`validator.check_anonymity` 关掉后不再发明文身份探测、不再判匿名度,已有等级原样保留、新记录停在 `unverified`;端点预筛的候选也少掉明文那一批(默认配置下每轮 11 个请求降到 4 个)。
|
||||
- **失败归因**:每次失败都带结论分类。只有「连不上代理」与「代理不转发」才算代理的问题;我方检测端点不可达、测试地址不可达、本机断网都记为**不可结论**,此时不写任何字段 —— 既不判代理失效,也不累计失败次数,因此检测基础设施抖动不会触发自动清理。
|
||||
- **超时是固定值**:所有请求一律用 `validator.timeout_seconds`,连接阶段另用 `validator.connect_timeout_seconds` 卡 TCP 握手(对应 aiohttp 的 `sock_connect`,不是 `connect`;后者会把 CONNECT 隧道建立也算进去,误杀隧道偏慢的可用代理)——黑洞代理因此能在连接超时内快速失败,这是「高速」的主要来源。早先还有一套「按本机直连耗时动态推导单次超时」加「整波墙钟预算」的机制,已移除:两者叠在一起会让身份探测在存活探测稍慢时拿不到预算,于是「没测到」与「测失败」在库里无法区分。
|
||||
- **端点预筛与竞速**:验证前先对本机直连探测各端点,不可达的自动剔除(被墙、被重置的端点不必逐个代理去撞)。每个端点的直连响应会被记录为基线,既用于扣除端点**自身**注入的响应头,也用于给候选排序 —— 竞速时按直连延迟**逐个补发**(对冲)而不是全部并发,慢代理不再被七八条并发隧道同时打爆。补发的**总时长有上限**(1.5 秒),因此失败路径的代价是「窗口 + 超时」而不是「候选数 × 间隔 + 超时」:身份探测有 5 个候选时,这一条把单个黑洞代理的验证从 12 秒压到 6.5 秒。
|
||||
- **健康评分**:延迟 25% + 成功率 25% + 稳定性 15% + 可用时长 15% + 匿名度 10% + 存活时长 10%。成功率维度用**成功率的 Wilson 置信下界**,样本少时向低位收缩,避免「只验过一两次」的代理拿到虚高分;样本取**探测项级**计数(每个得出明确结论的探测项各一票,一轮最多 3 票:存活、https 身份、明文转发能力,竞速补发出去的候选端点请求不各自计票),比"每轮一票"至多密 3 倍,估计收敛更快。延迟均值与方差对离群样本做钳制,一次拥塞不会把评分整段拖走。
|
||||
- **身份类字段是粘性的**:出口 IP、方案能力只在本次确实测到时才写库;没测到就保留上一次的已知值。匿名度同理 —— 唯一会「写一个推算值」的情形是探测确实做了却拿不到任何回显证据,那时会写兜底档并带上 `anonymity_fallback` 标记,界面能把它与实测所得区分开。否则一次失败就会把已知的出口 IP 抹掉。延迟同理,且"上次完整评估时间"只在各个探测项都拿到明确结论时才推进 —— 一次没问全的轮次不会让这个代理在重做间隔内都不再被补测。
|
||||
- **重新抓取不清空历史**:同一个 `ip:port` 再次入库时按 upsert 合并,`delay_variance`、可用时长、收藏标记与首次入库时间都保留,只有本次抓取真正拿到的字段会被刷新。
|
||||
|
||||
## 归属地
|
||||
|
||||
归属地**只走本地离线库**:一次查询同时给出中英两套名字与 ISO 规范代码,不发出任何外部请求。
|
||||
这一节说的是名字从哪来、以及名字不好看时该改哪里。
|
||||
|
||||
(早先它只走在线接口 ip-api、中英各发一次请求,且该接口对一级行政区的中文覆盖不完整 ——
|
||||
有译名给中文、没译名给当地原文,于是库里一格中文一格英文。那条路径已整体移除,连带
|
||||
`geoip.*` 四项配置也一并作废。)
|
||||
|
||||
### 两个离线库的分工
|
||||
|
||||
| 数据文件 | 覆盖 | 给出什么 | 用在哪 |
|
||||
|---|---|---|---|
|
||||
| `GeoLite2-City.mmdb`(约 65MB) | 全球 | ISO 3166-1 国码、ISO 3166-2 行政区码,以及 8 种语言的名字 | 国家判定与国外行政区 |
|
||||
| `qqwry.ipdb`(纯真,约 39MB) | 中国 | 省 / 市 / 区县名(中文) | **只补中国大陆的省市级** |
|
||||
|
||||
- **为什么要 GeoLite2**:一次查询就同时给出中英两套名字与规范代码。这既省掉了「一个 IP
|
||||
发两次请求」(在线接口时代中英各要一次),也彻底不受任何接口额度限制 —— 后一条是引入
|
||||
离线库的主要动机之一。
|
||||
- **为什么还要纯真**:中国的城市级覆盖明显更强 —— 实测同一批 8 个中国 IP,纯真 7 个给出
|
||||
城市,GeoLite2 只有 3 个。
|
||||
- **纯真只在 `country_code == "CN"` 时参与**:它在国外的国家判定不可信,实测把香港判成
|
||||
美国、荷兰判成乌克兰。所以非中国记录一律按 GeoLite2 的结论走,纯真连省市级都不采用。
|
||||
- **GeoLite2 的中文名是有缺口的**:抽样 4000 个公网 IP,能查到一级行政区的占 46.8%,其中
|
||||
70.5% 带中文名;能查到城市的占 46.1%,其中 63.0% 带中文名。缺译名时**回退成原文并标记
|
||||
为「原文」**(见下),而不是让两种语言静默混在同一格 —— 那正是上一版的样子:中英两套
|
||||
分别向接口要,有译名给中文、没译名给当地原文,于是库里一格中文一格英文,还看不出原因。
|
||||
- 两个库对同一 IP 的城市判定**可能不一致**(实测 183.62.176.46 纯真=深圳、GeoLite2=广州),
|
||||
此时以 GeoLite2 已有的 `zh-CN` 名字为准,纯真只补 GeoLite2 缺 `zh-CN` 名字的级别,
|
||||
结论来源记在 `geo_source` 里备查。
|
||||
|
||||
### 名字是「代码 + 对照表」渲染出来的
|
||||
|
||||
离线库返回的不是字符串,而是**规范代码**(ISO 3166-1 的国码 + ISO 3166-2 的细分代码)与一份
|
||||
「语言 → 名称」映射。写进库的 `country/province/city`(中文那套)与
|
||||
`country_en/province_en/city_en`(英文那套)是拿代码渲染出来的**派生数据**:
|
||||
|
||||
- 中文那套:库里的 `zh-CN` → 一级行政区对照表 → 英文原文
|
||||
- 英文那套:库里的 `en` → 中国省市对照表 → 中文原文
|
||||
|
||||
**为什么存代码**:名字会随数据源和对照表变,代码不会;显示名在每次查询时按当时的对照表渲染,
|
||||
代码则用于挑选仍可重算的记录(只挑缺代码的,见下)。结论来源(`geolite2` / `geolite2+qqwry`)
|
||||
一并记下,便于排障与事后重判。
|
||||
|
||||
**对照表是尽力而为的有限集**:中国 34 个省级 + 156 个主要城市(代理出口集中的那些),
|
||||
以及 13 个国家/地区的一级行政区。没有收录的地区回退成原文 —— 宁可显示准确的原文,
|
||||
也不要编一个错的译名。
|
||||
|
||||
**「原文」在界面上是标出来的**:地区名后面点一个浅色星号,tooltip 说明「该名称暂无当前语言的
|
||||
译名,显示的是原文」。判据**是确定的**:中英两列逐字段比较,某一级取值相同就是没译出来
|
||||
(接口把两套分列都透出,正是为了让它可判定,而不是靠猜串里有没有英文字母)。缺译名是
|
||||
可预期、可累积改善的,标出来才能被发现。
|
||||
|
||||
已知限制:中英两列**本来就不同**的第三种语言原文判不出来 —— 例如日文「千葉県」对应的英文是
|
||||
Chiba,两列不等;中日共用汉字,仅凭字符串无法区分。这类值只可能来自合并保留的旧数据,
|
||||
而日韩的一级行政区离线库都有中文名,会走「新值覆盖」而不是保留,因此实际很少遇到。
|
||||
|
||||
### 数据文件
|
||||
|
||||
- **位置**:`modules/proxypool/data/geodb/`,两个文件合计约 104MB,**不入库**(`.gitignore`
|
||||
整个目录排除)。**这两个文件是归属地的唯一来源**:不下载就没有归属地(见下)。
|
||||
- **更新命令**:在 `modules/proxypool/` 目录下执行 `python -m core.infrastructure.geodb`
|
||||
(可选 `--dir` 指定数据目录)。两个库都走 jsDelivr 的 `@latest` —— 纯真每周、GeoLite2 每周
|
||||
两更,写死版本号会永远拉回同一个旧库。任一个失败不影响另一个,结束后逐项汇报,有失败时
|
||||
退出码为 1。
|
||||
- **面板入口**:代理池 → 数据库维护 → 「归属地库」小节的**「更新归属地库」**(`POST /api/pool/geo/update`)。
|
||||
它建的是后台任务,下载几分钟、进度显示在按钮下方;命令行适合首次部署,面板适合日常更新。
|
||||
- **文件缺失时**:整体判定为「离线库不可用」,所有查询返回「未知」——**没有兜底了**。池照常
|
||||
启动(抓取、验证、出口转发都不依赖它),但地区会是空的;启动日志会记一条 WARNING 指出
|
||||
补救方式,面板的归属地库区块也会显示「未下载」。
|
||||
这里刻意不做「缺谁用谁」:部分可用会让「这个 IP 查不到」既可能是库里没有、也可能只是少了
|
||||
一个文件,排查成本远高于直接判定不可用。
|
||||
- 面板上能看到两个文件各自的存在、大小与修改时间,以及待重算的记录数。
|
||||
|
||||
### 缓存
|
||||
|
||||
- **按 IP 缓存**(`core/services/geoip.py`):中英两套都拿到的结果缓存 24 小时;只拿到一套
|
||||
或查询失败缓存 1 小时(避免「缺一半」的结果被当成完整结果锁上一整天),条目上限 10000。
|
||||
缓存是进程内内存态,重启后重查。
|
||||
- **查不到就不写**:只写「查到的语种」。查询失败时一个字段都不写 —— 此处曾经会把已查好的
|
||||
归属地覆盖成「未知」,与其它身份字段「测到才写」的策略正好相反。
|
||||
- **重算只处理没代码的记录**(`country_code IS NULL`):它们要么从没查过,要么是在「只走在线
|
||||
接口」的版本里查过(那时拿不到规范代码,名字也就无法按语言重新呈现)。面板的「重算归属地」
|
||||
(`POST /api/pool/geo/recompute`)就是干这个的;启动补漏一次最多入队 5000 条(队列只有 2000 的
|
||||
容量,灌满会挤掉验证链路顺带触发的查询),超出部分留给这个按钮。已经用离线库解析过的记录
|
||||
不会被重算 —— 数据文件更新后想刷新它们,需要先把 `country_code` 清空(当前没有提供该操作)。
|
||||
|
||||
### 解析是异步的
|
||||
|
||||
- **异步解析**(`core/services/geo_resolver.py`):验证阶段只负责问出「出口 IP 是多少」,拿到之后就把 IP 丢进队列返回 —— 查询、重试、写库都在后台做,不占验证的墙钟。此前它在五个调用点被逐条 await 在验证协程里,直接拖长每轮墙钟。
|
||||
- **几个并发 worker 一起查**:队列与 worker 是为了把「查归属地」与「验证代理」解耦 —— 验证只管把出口 IP 丢进队列就返回,写库由后台按自己的节奏做。查询本身现在是本地查表(微秒级),不再是当初「在线接口超时 + 重试要十几秒」的形态,因此并发数(4)如今主要是把写库节奏摊平,而不是为了压查询延迟。
|
||||
- **启动补漏**:队列是内存态,重启会丢掉没查完的。因此启动时会把库里当前有效、有出口 IP、却没有地区(离线库可用时,缺规范代码的也算)的记录重新入队 —— 否则这些代理要等到下一次完整评估(默认 24 小时)才会再查一次,期间地区一直是空的。
|
||||
- **尝试上限是时间窗内的**:同一个出口 IP 在 30 分钟内最多尝试 3 次。早先是一次性计数,失败 3 次就再也不查了 —— 一次接口抖动就把这个 IP 的归属地永久判了死刑。
|
||||
- **中英两套怎么来**:`country/province/city/region` 是中文那一套,`country_en/province_en/city_en/region_en` 是英文那一套。走离线库时**同一次查询同时给出两套**(不是互译,也不是两次请求)。
|
||||
- 地区搜索两边都能命中(`region LIKE ? OR region_en LIKE ?`),搜「美国」与搜 `United States` 命中同一批记录。
|
||||
- 界面展示**跟随界面语言**:英文界面显示 `region_en`,另一套进 tooltip;按地区排序时也换成对应的列,否则列表顺序和看到的地名对不上。缺一套时回退到另一套(「未知」视为缺失,不当成地名显示)。
|
||||
- **按出口 IP 去重**:同一个出口 IP 上的一批代理共享一次查询并一次写完(按 `real_ip` 更新),因此刚插入的记录没有 id 也不影响。
|
||||
- **已知限制**:两个库只保证「结论可追溯」,不保证与物理位置一致 —— 池里的 IP 多是机房/云主机,这类 IP 的地理位置本质是注册地而非物理位置,实测同一个 IP 在四个源上分别被判成日本、美国、新加坡、美国。这是数据源之间的固有分歧,不是解析问题。
|
||||
|
||||
## 自动清理
|
||||
|
||||
命中以下任一条件的代理会被删除 —— 健康分低于阈值且验证次数达标、连续失败次数超限、**失效后超过保留天数**(`auto_cleanup.max_age_days_if_invalid`,判据取 `validated_at` 距今天数,缺失时用入库时间;每次结论性验证无论成败都会刷新它,所以衡量的是「多久没被验证过」而非「多久没验证成功过」);**收藏的代理不删**。删除不可逆,因此上面那条「不可结论不惩罚」是它的前置防线。
|
||||
|
||||
删除只有这一处。曾经还有一套并行的删除逻辑(`performance.*` 那组键,按 `validated_at` 算保留期),两套判据不同、同一行记录被哪套删掉取决于时序,已合并到 `auto_cleanup`。
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
模块名称:modules.proxypool
|
||||
功能描述:代理池子项目的包入口,导入时把池根目录插入 sys.path 首位,使子项目内部
|
||||
不带上层包名的绝对导入(from core.xxx import ...)能够解析。
|
||||
职责边界:负责:模块搜索路径的注入;不负责:子项目内部的一切业务逻辑(见 core.*)。
|
||||
关键依赖:标准库 os、sys。
|
||||
已知限制:
|
||||
1. POOL_DIR 依赖本文件固定位于池根包目录这一层级。
|
||||
2. 注入使 core 等成为进程级顶层包名,进程内若已有同名包会发生冲突。
|
||||
3. 路径插在 sys.path 首位,池内同名模块优先于仓库根下的模块被导入。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
POOL_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
if POOL_DIR not in sys.path:
|
||||
sys.path.insert(0, POOL_DIR)
|
||||
|
||||
__all__ = ["POOL_DIR"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core
|
||||
功能描述:代理池子项目核心包的包标记,不含代码;各项能力由子模块直接提供。
|
||||
职责边界:负责:作为 core 包的标记存在;不负责:任何再导出与初始化逻辑。
|
||||
关键依赖:无。
|
||||
已知限制:本包不做再导出,导入本包不会连带加载任何子模块,使用能力须显式导入对应子模块。
|
||||
"""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application
|
||||
功能描述:代理池应用层的包标记,不含代码;装配入口 Application 定义在子模块
|
||||
core.application.app 中。
|
||||
职责边界:负责:作为应用层包的标记存在;不负责:任何再导出与初始化逻辑。
|
||||
关键依赖:无。
|
||||
已知限制:导入本包不会连带加载 core.application.app,也不触发任何装配流程。
|
||||
"""
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api
|
||||
功能描述:代理池 HTTP 适配层的包标记,不含代码;依赖装配见 dependencies,响应与
|
||||
异常类型见 http_types,各资源的路由函数见 routes。
|
||||
职责边界:负责:作为适配层各子模块的包入口;不负责:路由注册与请求解析(由
|
||||
modules.proxypool_api 承担)以及业务逻辑(见 routes 下的各功能域)。
|
||||
关键依赖:无。
|
||||
已知限制:导入本包不会连带加载 dependencies、http_types 或 routes。
|
||||
"""
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.dependencies
|
||||
功能描述:路由层获取服务的统一入口:容器注册的服务按接口类型解析,去重缓存、备份管理器、数据清理器、归属地解析器与语言提供者由宿主启动时注入。
|
||||
职责边界:负责:持有并暴露 DI 容器与各注入项的进程内引用;不负责:实例创建、注册、生命周期与注入时机(见 core.application.app.Application 与宿主)。
|
||||
关键依赖:core.infrastructure 的 di_container、dedup_cache 模块,以及 core.interfaces 下供容器解析的接口类型。
|
||||
已知限制:
|
||||
1. 容器与注入项都是模块级全局引用,仅能通过 set_* 传入新实例或 None 替换,无专用清空接口;多进程部署时各进程各持一份,互不可见。
|
||||
2. 停池只清空 container 与 dedup_cache,backup_manager、data_cleaner、geo_resolver、language_provider 刻意保留。
|
||||
3. get_container 未初始化时抛 RuntimeError,属装配顺序问题,不自愈也不应重试。
|
||||
4. get_geodb 延迟导入并吞掉一切异常返回 None,表示「未启用」,调用方不得视为错误。
|
||||
5. get_dedup_cache 返回 None 时调用方必须回落查库,不能当作异常吞掉。
|
||||
6. get_language 永不抛异常也不返回空串,缺省一律 'cn';语言必须以函数注入,缓存字符串会一直用旧语言。
|
||||
"""
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.infrastructure.di_container import DIContainer
|
||||
from core.infrastructure.dedup_cache import DedupCache
|
||||
from core.interfaces.repository import IProxyRepository, IPluginConfigRepository
|
||||
from core.interfaces.services import IPluginManager, IProxyValidator
|
||||
from core.interfaces.infrastructure import IWriteQueue, IConfigManager
|
||||
|
||||
|
||||
_container: Optional[DIContainer] = None
|
||||
_dedup_cache: Optional[DedupCache] = None
|
||||
_backup_manager = None
|
||||
_data_cleaner = None
|
||||
_geo_resolver = None
|
||||
_language_provider: Optional[Callable[[], str]] = None
|
||||
|
||||
|
||||
def set_container(container: Optional[DIContainer]) -> None:
|
||||
global _container
|
||||
_container = container
|
||||
|
||||
|
||||
def set_dedup_cache(dedup_cache: Optional[DedupCache]) -> None:
|
||||
global _dedup_cache
|
||||
_dedup_cache = dedup_cache
|
||||
|
||||
|
||||
def get_container() -> DIContainer:
|
||||
global _container
|
||||
if _container is None:
|
||||
raise RuntimeError("依赖注入容器未初始化,请先调用 set_container()")
|
||||
return _container
|
||||
|
||||
|
||||
def get_proxy_repository() -> IProxyRepository:
|
||||
container = get_container()
|
||||
return container.resolve(IProxyRepository)
|
||||
|
||||
|
||||
def get_plugin_config_repository() -> IPluginConfigRepository:
|
||||
container = get_container()
|
||||
return container.resolve(IPluginConfigRepository)
|
||||
|
||||
|
||||
def get_plugin_manager() -> IPluginManager:
|
||||
container = get_container()
|
||||
return container.resolve(IPluginManager)
|
||||
|
||||
|
||||
def get_proxy_validator() -> IProxyValidator:
|
||||
container = get_container()
|
||||
return container.resolve(IProxyValidator)
|
||||
|
||||
|
||||
def get_write_queue() -> IWriteQueue:
|
||||
container = get_container()
|
||||
return container.resolve(IWriteQueue)
|
||||
|
||||
|
||||
def get_config_manager() -> IConfigManager:
|
||||
container = get_container()
|
||||
return container.resolve(IConfigManager)
|
||||
|
||||
|
||||
def get_dedup_cache() -> Optional[DedupCache]:
|
||||
return _dedup_cache
|
||||
|
||||
|
||||
def set_backup_manager(manager) -> None:
|
||||
global _backup_manager
|
||||
_backup_manager = manager
|
||||
|
||||
|
||||
def get_backup_manager():
|
||||
return _backup_manager
|
||||
|
||||
|
||||
def set_data_cleaner(cleaner) -> None:
|
||||
global _data_cleaner
|
||||
_data_cleaner = cleaner
|
||||
|
||||
|
||||
def get_data_cleaner():
|
||||
return _data_cleaner
|
||||
|
||||
|
||||
def set_geo_resolver(resolver) -> None:
|
||||
global _geo_resolver
|
||||
_geo_resolver = resolver
|
||||
|
||||
|
||||
def get_geo_resolver():
|
||||
return _geo_resolver
|
||||
|
||||
|
||||
def get_geodb():
|
||||
try:
|
||||
from core.infrastructure.geodb import GeoDatabase
|
||||
|
||||
return get_container().resolve(GeoDatabase)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def set_language_provider(provider: Optional[Callable[[], str]]) -> None:
|
||||
global _language_provider
|
||||
_language_provider = provider
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
if _language_provider is None:
|
||||
return 'cn'
|
||||
try:
|
||||
return _language_provider() or 'cn'
|
||||
except Exception:
|
||||
return 'cn'
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.http_types
|
||||
功能描述:路由层与 Web 适配层之间的响应契约:路由函数返回普通字典(转 JSON)或
|
||||
RawResponse(按原样输出,filename 非空时作为附件下载),出错抛 ApiError,
|
||||
由适配层统一翻译成 HTTP 响应;json_download 用于生成 JSON 附件下载响应。
|
||||
职责边界:负责:定义上述返回与异常类型;不负责:把 ApiError 翻译成 HTTP 响应(见
|
||||
modules.proxypool_api);除 json_download 外,正文序列化由调用方完成。
|
||||
关键依赖:仅标准库 json、dataclasses、typing。
|
||||
已知限制:
|
||||
1. RawResponse.content 必须是已序列化好的文本,适配层按原样写出。
|
||||
2. RawResponse.filename 非空时才作为附件下载,空字符串不会触发下载响应头。
|
||||
3. ApiError 只携带状态码与文案,适配层固定以 error_code=api_error 返回。
|
||||
4. RawResponse.mimetype 默认 text/plain,返回 JSON 正文时需显式指定。
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RawResponse:
|
||||
content: str
|
||||
mimetype: str = "text/plain"
|
||||
filename: Optional[str] = None
|
||||
|
||||
|
||||
def json_download(data, filename: str) -> RawResponse:
|
||||
return RawResponse(
|
||||
content=json.dumps(data, ensure_ascii=False, indent=2),
|
||||
mimetype="application/json",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ApiError", "RawResponse", "json_download"]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.routes
|
||||
功能描述:代理池业务操作层的功能域包:database、geo、proxies、plugins、validation 子模块按功能域实现业务逻辑;本文件不含代码,仅作包标记。
|
||||
职责边界:负责:作为 routes 包的标记存在,不聚合也不转发子模块的处理函数。
|
||||
不负责:HTTP 路由注册与请求解析(见 modules.proxypool_api)、服务实例的装配与解析
|
||||
(见 core.application.api.dependencies)。
|
||||
关键依赖:无。
|
||||
已知限制:
|
||||
1. 子模块不依赖 Web 框架:返回值是普通 JSON 数据(字典/列表)或 http_types.RawResponse(原始文本/附件)。
|
||||
2. 参数错误抛 InvalidParameterException(ValueError 子类)或 http_types.ApiError(400),两者均由适配层转换为 HTTP 响应。
|
||||
3. 新增功能域须在 modules/proxypool_api.py 中导入并注册 URL 才能访问;服务访问器由 core.application.api.dependencies 提供。
|
||||
"""
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.routes.database
|
||||
功能描述:数据库维护端点的业务逻辑:备份列表、备份恢复、数据库统计与优化(ANALYZE + REINDEX + VACUUM)。
|
||||
返回普通字典,失败统一抛 ApiError;备份列表与恢复是纯文件操作,池停止时同样可用。
|
||||
职责边界:负责:备份文件枚举与格式化、按名恢复、数据库统计与优化动作;不负责:HTTP 路由注册与请求
|
||||
解析(见 modules.proxypool_api)、备份文件的定时创建(见 core.backup.DatabaseBackup)、
|
||||
恢复前必须先停池的把关(由适配层负责)。
|
||||
关键依赖:core.application.api.dependencies 的进程内注入、core.application.api.http_types.ApiError、
|
||||
core.backup 的恢复原因码、modules.modules 的文案表。
|
||||
已知限制:
|
||||
1. 未注入备份管理器或清理器时按「功能未启用」抛 ApiError(404),不得降级成 AttributeError。
|
||||
2. 备份列表是同步文件操作;created_at 被格式化成 "%Y-%m-%d %H:%M:%S" 字符串,改格式要同步改前端。
|
||||
3. 恢复只覆盖库文件,不校验备份与当前库的版本兼容性;成功后必须重启池才会加载新数据。
|
||||
4. _RESTORE_FAILURE_MESSAGES 的原因码须与 core.backup 的 last_restore_error_code 同步,未命中的码会误报为 400。
|
||||
5. get_database_stats 与 optimize_database 需池在运行;VACUUM 会长时间独占数据库锁。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from core.application.api.dependencies import (
|
||||
get_backup_manager,
|
||||
get_data_cleaner,
|
||||
get_language,
|
||||
)
|
||||
from core.application.api.http_types import ApiError
|
||||
from modules.modules import get_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RESTORE_FAILURE_MESSAGES: dict[str, tuple[str, bool]] = {
|
||||
'invalid_backup': ('pool_backup_restore_invalid_backup', False),
|
||||
'wal_locked': ('pool_backup_restore_wal_locked', False),
|
||||
'verify_failed': ('pool_backup_restore_verify_failed', False),
|
||||
'error': ('pool_backup_restore_error', True),
|
||||
}
|
||||
|
||||
|
||||
def list_backups() -> dict:
|
||||
manager = get_backup_manager()
|
||||
if manager is None:
|
||||
raise ApiError(404, get_message('pool_db_backup_disabled', get_language()))
|
||||
|
||||
backups = []
|
||||
for entry in manager.list_backups():
|
||||
backups.append({
|
||||
"filename": entry["filename"],
|
||||
"size": entry["size"],
|
||||
"created_at": entry["created_at"].strftime("%Y-%m-%d %H:%M:%S"),
|
||||
})
|
||||
return {"backups": backups}
|
||||
|
||||
|
||||
async def restore_backup(filename: str) -> dict:
|
||||
manager = get_backup_manager()
|
||||
language = get_language()
|
||||
if manager is None:
|
||||
raise ApiError(404, get_message('pool_db_backup_disabled', language))
|
||||
|
||||
if not await manager.restore_backup_file(filename):
|
||||
failure = _RESTORE_FAILURE_MESSAGES.get(manager.last_restore_error_code)
|
||||
if failure is None:
|
||||
raise ApiError(400, get_message('pool_backup_restore_bad_name', language))
|
||||
key, with_detail = failure
|
||||
if with_detail:
|
||||
raise ApiError(
|
||||
500, get_message(key, language, manager.last_restore_error)
|
||||
)
|
||||
raise ApiError(500, get_message(key, language))
|
||||
|
||||
logger.warning("数据库已从备份 %s 恢复,请重启代理池以加载恢复后的数据", filename)
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_backup_restore_success', language),
|
||||
}
|
||||
|
||||
|
||||
async def get_database_stats() -> dict:
|
||||
cleaner = get_data_cleaner()
|
||||
if cleaner is None:
|
||||
raise ApiError(
|
||||
404, get_message('pool_db_maintenance_disabled', get_language())
|
||||
)
|
||||
|
||||
stats = await cleaner.get_database_stats()
|
||||
return {"stats": stats}
|
||||
|
||||
|
||||
async def optimize_database() -> dict:
|
||||
cleaner = get_data_cleaner()
|
||||
language = get_language()
|
||||
if cleaner is None:
|
||||
raise ApiError(404, get_message('pool_db_maintenance_disabled', language))
|
||||
|
||||
if not await cleaner.optimize_database():
|
||||
raise ApiError(500, get_message('pool_db_optimize_failed', language))
|
||||
|
||||
return {"success": True, "message": get_message('pool_db_optimize_done', language)}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_backups",
|
||||
"restore_backup",
|
||||
"get_database_stats",
|
||||
"optimize_database",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.routes.geo
|
||||
功能描述:归属地维护端点:查询离线库状态、触发数据文件更新、按离线库重算存量记录;更新与重算都登记为后台任务、由面板按 task_id 轮询。
|
||||
职责边界:负责:把离线库的状态、更新、重算暴露成可调用的操作并生成任务 id;不负责:HTTP 路由
|
||||
注册与线程绑定(见 modules.proxypool_api)、数据文件的下载实现(见 core.infrastructure.geodb)、
|
||||
任务的执行与状态表(见 batch_tasks)、归属地查询本身(见 core.services.geoip)。
|
||||
关键依赖:core.application.api.dependencies 的进程内注入、core.application.batch_tasks、
|
||||
core.infrastructure.geodb、modules.modules 的文案表。
|
||||
已知限制:
|
||||
1. 三个端点都必须在池的事件循环线程上调用(路由侧用 _bind_query / _bind_no_args 绑定)。
|
||||
2. 状态查询要求仓库可用:容器未装配或仓库解析失败时待重算数按 0 返回并记一条 warning。
|
||||
3. 重算只挑 country_code 为 NULL 的出口 IP(real_ip 非空且 DISTINCT),已解析过的记录不会被重算。
|
||||
4. 重算的完成信号是解析队列排空(每秒轮询 pending 归零),并发解析时 done 会被压低(以 0 兜底)。
|
||||
5. 更新任务把运行中的 geodb 实例(可能为 None)透传给下载流程:换入新文件前必须先释放句柄,否则 Windows 上 os.replace 会被拒绝。
|
||||
6. 重算要求解析服务已启用:resolver 为 None 时抛 ApiError(503) 且不创建任务,任务内部还会再判一次。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from core.application import batch_tasks
|
||||
from core.application.api.dependencies import (
|
||||
get_geodb,
|
||||
get_geo_resolver,
|
||||
get_language,
|
||||
get_proxy_repository,
|
||||
)
|
||||
from core.application.api.http_types import ApiError
|
||||
from core.infrastructure.geodb import describe_databases
|
||||
from modules.modules import get_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
async def get_geo_status() -> dict:
|
||||
geodb = get_geodb()
|
||||
available = bool(geodb is not None and geodb.available)
|
||||
|
||||
pending = 0
|
||||
try:
|
||||
repository = get_proxy_repository()
|
||||
except Exception as e:
|
||||
logger.warning(f"代理仓库不可用,待重算记录数按 0 处理: {e}")
|
||||
repository = None
|
||||
|
||||
if repository is not None:
|
||||
try:
|
||||
pending = len(await repository.get_ips_missing_geo_codes())
|
||||
except Exception as e:
|
||||
logger.warning(f"统计待重算归属地的记录数失败(按 0 处理): {e}")
|
||||
|
||||
return {
|
||||
"available": available,
|
||||
"files": describe_databases(),
|
||||
"pending_recompute": pending,
|
||||
}
|
||||
|
||||
|
||||
async def start_geo_update() -> dict:
|
||||
task_id = batch_tasks.new_task_id()
|
||||
batch_tasks.spawn(
|
||||
batch_tasks.update_geo_databases_task(task_id, get_geodb()), task_id
|
||||
)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "queued",
|
||||
"message": get_message('pool_geo_update_task_created', get_language()),
|
||||
}
|
||||
|
||||
|
||||
async def start_geo_recompute() -> dict:
|
||||
language = get_language()
|
||||
if get_geo_resolver() is None:
|
||||
raise ApiError(503, get_message('pool_geo_recompute_disabled', language))
|
||||
|
||||
task_id = batch_tasks.new_task_id()
|
||||
batch_tasks.spawn(batch_tasks.recompute_geo_task(task_id), task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "queued",
|
||||
"message": get_message('pool_geo_recompute_task_created', language),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_geo_status",
|
||||
"start_geo_update",
|
||||
"start_geo_recompute",
|
||||
]
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.routes.plugins
|
||||
功能描述:抓取插件的管理操作接口:列出状态、启停、设置运行周期与检测地址、设置验证策略、立即执行、热重载。
|
||||
职责边界:负责:插件管理操作的参数校验与结果组装;不负责:HTTP 路由注册与请求解析(见 modules.proxypool_api)、插件加载、调度与落盘(见 PluginManager)。
|
||||
关键依赖:core.application.api.dependencies(插件管理器、语言)、core.application.batch_tasks、modules.modules(文案表)。
|
||||
已知限制:
|
||||
1. 本模块主动判定的参数错误必须是 InvalidParameterException(带文案键)或 ApiError(400),管理器抛出的同类异常须原样穿透,不得吞成 500。
|
||||
2. 请求体值原样透传:reval_interval_minutes 经 int() 转换兜底;minutes 与 test_url 未转换,类型错误会落 500。
|
||||
3. skip_validation 只对已加载插件开放,落到伪插件名 manual_import 会让手动导入全部跳过验证,面板上无从发现。
|
||||
4. test_url 空串或全空白表示继承全局检测地址,reval_interval_minutes 为 0 表示继承全局重验证周期。
|
||||
5. 立即执行只创建后台任务即返回,进度须按 task_id 轮询;任务状态为进程内存态且有 TTL,进程重启后不可查。
|
||||
6. 热重载的可预期失败(插件不存在、执行中、加载返回 None)返回 200 + success=False,仅未预期异常报 500。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from core.application import batch_tasks
|
||||
from core.application.api.dependencies import get_language, get_plugin_manager
|
||||
from core.application.api.http_types import ApiError
|
||||
from core.domain.exceptions import InvalidParameterException
|
||||
from modules.modules import get_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLUGIN_ACTION_MESSAGES: dict[bool, tuple[str, str]] = {
|
||||
True: ('pool_plugin_enable_failed', 'pool_plugin_enabled'),
|
||||
False: ('pool_plugin_disable_failed', 'pool_plugin_disabled'),
|
||||
}
|
||||
|
||||
|
||||
async def list_plugins():
|
||||
status_dict = get_plugin_manager().get_plugin_status()
|
||||
return [
|
||||
{
|
||||
"name": status.name,
|
||||
"enabled": status.enabled,
|
||||
"interval_minutes": status.interval_minutes,
|
||||
"last_run": status.last_run.isoformat() if status.last_run else None,
|
||||
"next_run": status.next_run.isoformat() if status.next_run else None,
|
||||
"last_error": (
|
||||
get_message(status.last_error_key, get_language())
|
||||
if status.last_error_key else status.last_error
|
||||
),
|
||||
"is_loaded": status.is_loaded,
|
||||
"test_url": status.test_url,
|
||||
"reval_enabled": status.reval_enabled,
|
||||
"reval_interval_minutes": status.reval_interval_minutes,
|
||||
"skip_validation": status.skip_validation,
|
||||
}
|
||||
for status in status_dict.values()
|
||||
]
|
||||
|
||||
|
||||
async def set_plugin_enabled(plugin_name: str, enabled: bool):
|
||||
manager = get_plugin_manager()
|
||||
action = "启用" if enabled else "禁用"
|
||||
language = get_language()
|
||||
failed_key, done_key = _PLUGIN_ACTION_MESSAGES[enabled]
|
||||
|
||||
try:
|
||||
if enabled:
|
||||
manager.enable_plugin(plugin_name)
|
||||
else:
|
||||
manager.disable_plugin(plugin_name)
|
||||
except Exception as e:
|
||||
logger.error(f"{action}插件失败: {e}", exc_info=True)
|
||||
raise ApiError(500, get_message(failed_key, language, str(e)))
|
||||
|
||||
return {"success": True, "message": get_message(done_key, language, plugin_name)}
|
||||
|
||||
|
||||
async def set_plugin_interval(plugin_name: str, minutes: int):
|
||||
if minutes < 1:
|
||||
raise InvalidParameterException(
|
||||
'param_interval_must_be_positive', minutes)
|
||||
|
||||
language = get_language()
|
||||
try:
|
||||
get_plugin_manager().set_plugin_interval(plugin_name, minutes)
|
||||
except InvalidParameterException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"设置插件运行周期失败: {e}", exc_info=True)
|
||||
raise ApiError(500, get_message('pool_plugin_interval_failed', language, str(e)))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_plugin_interval_set', language, plugin_name, minutes),
|
||||
}
|
||||
|
||||
|
||||
async def set_plugin_test_url(plugin_name: str, test_url: str = ""):
|
||||
language = get_language()
|
||||
try:
|
||||
get_plugin_manager().set_plugin_test_url(plugin_name, test_url)
|
||||
except ValueError as e:
|
||||
raise ApiError(400, str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"设置插件测试地址失败: {e}", exc_info=True)
|
||||
raise ApiError(500, get_message('pool_plugin_test_url_failed', language, str(e)))
|
||||
|
||||
if (test_url or "").strip():
|
||||
message = get_message('pool_plugin_test_url_set', language, plugin_name, test_url)
|
||||
else:
|
||||
message = get_message('pool_plugin_test_url_inherited', language, plugin_name)
|
||||
return {"success": True, "message": message}
|
||||
|
||||
|
||||
async def set_plugin_validation(plugin_name: str, reval_enabled: bool = True,
|
||||
reval_interval_minutes: int = 0,
|
||||
skip_validation: bool = False):
|
||||
language = get_language()
|
||||
manager = get_plugin_manager()
|
||||
|
||||
try:
|
||||
reval_interval_minutes = int(reval_interval_minutes)
|
||||
except (TypeError, ValueError):
|
||||
raise ApiError(400, get_message(
|
||||
'param_reval_interval_not_int', language, reval_interval_minutes))
|
||||
|
||||
if skip_validation and plugin_name not in manager.get_plugin_status():
|
||||
raise ApiError(404, get_message('pool_plugin_not_loaded', language, plugin_name))
|
||||
|
||||
try:
|
||||
manager.set_plugin_validation(
|
||||
plugin_name, reval_enabled, reval_interval_minutes, bool(skip_validation)
|
||||
)
|
||||
except InvalidParameterException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise ApiError(400, str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"设置插件验证策略失败: {e}", exc_info=True)
|
||||
raise ApiError(500, get_message('pool_plugin_validation_failed', language, str(e)))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_plugin_validation_set', language, plugin_name),
|
||||
}
|
||||
|
||||
|
||||
async def run_plugin(plugin_name: str):
|
||||
manager = get_plugin_manager()
|
||||
language = get_language()
|
||||
if plugin_name not in manager.get_plugin_status():
|
||||
raise ApiError(404, get_message('pool_plugin_not_loaded', language, plugin_name))
|
||||
|
||||
task_id = batch_tasks.new_task_id()
|
||||
batch_tasks.spawn(batch_tasks.run_plugin_task(task_id, plugin_name), task_id)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "running",
|
||||
"message": get_message('pool_plugin_run_task_created', language, plugin_name),
|
||||
}
|
||||
|
||||
|
||||
async def reload_plugin(plugin_name: str):
|
||||
language = get_language()
|
||||
try:
|
||||
success = await get_plugin_manager().reload_plugin(plugin_name)
|
||||
except Exception as e:
|
||||
logger.error(f"热重载插件失败: {e}", exc_info=True)
|
||||
raise ApiError(500, get_message('pool_plugin_reload_failed', language, str(e)))
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_plugin_reload_success', language, plugin_name),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"message": get_message('pool_plugin_reload_rejected', language, plugin_name),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_plugins",
|
||||
"set_plugin_enabled",
|
||||
"set_plugin_interval",
|
||||
"set_plugin_test_url",
|
||||
"set_plugin_validation",
|
||||
"run_plugin",
|
||||
"reload_plugin",
|
||||
]
|
||||
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.routes.proxies
|
||||
功能描述:代理池的查询、随机取用、计数、统计、来源、导入导出与收藏操作;不含 Web 框架依赖,返回普通字典或 RawResponse 供适配层序列化。
|
||||
职责边界:负责:查询参数校验、筛选条件组装、结果序列化格式(json/text/csv)与导出文件名;不负责:HTTP 路由注册与请求解析
|
||||
(modules.proxypool_api)、数据访问(ProxyRepository)、代理验证(core.application.batch_tasks)。
|
||||
关键依赖:core.application.api.dependencies(仓库与界面语言)、core.application.api.http_types、
|
||||
core.domain.models、core.domain.exceptions、core.application.batch_tasks、modules.modules。
|
||||
已知限制:
|
||||
1. 参数错误应抛 InvalidParameterException(带文案键);裸 ValueError 仍渲染 400 但丢掉文案键,只有非 ValueError 才落 500。
|
||||
2. EXPORT_LIMIT(50000)同时约束 page_size、limit 与导出;传 limit 时 page 被强制为 1,等效取前 limit 条。
|
||||
3. _proxy_to_dict 的归属地 NULL 表示「还没查过」而非「未知」,不要改填文案,前端据此回退中文列;exit_ip_differs 在列表与随机查询响应透出,导出不带。
|
||||
4. 随机取用按页粒度抽样,各条概率不严格相等,计数非 0 但页为空仍报 404;toggle_favorite 无幂等参数,重复调用在两状态间来回切换。
|
||||
5. CSV 导出带 UTF-8 BOM,_csv_safe 给以 = + - @ 开头的字段加前导单引号防公式注入;表头列数与顺序是对外契约。
|
||||
6. import_proxies 全部解析失败 success=False,全部已存在 success=True、proxy_count=0;
|
||||
message 与 proxy_count 要同看。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from core.application import batch_tasks
|
||||
from core.application.api.dependencies import (
|
||||
get_language,
|
||||
get_proxy_repository,
|
||||
)
|
||||
from core.application.api.http_types import ApiError, RawResponse, json_download
|
||||
from core.domain.exceptions import InvalidParameterException
|
||||
from core.domain.models import SUPPORTED_PROTOCOLS
|
||||
from modules.modules import get_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXPORT_LIMIT = 50000
|
||||
|
||||
|
||||
def _proxy_to_dict(proxy) -> dict:
|
||||
return {
|
||||
"id": proxy.id,
|
||||
"proxy_url": proxy.proxy_url,
|
||||
"protocol": proxy.protocol,
|
||||
"ip": proxy.ip,
|
||||
"port": proxy.port,
|
||||
"username": proxy.username,
|
||||
"password": proxy.password,
|
||||
"real_ip": proxy.real_ip,
|
||||
"exit_ip_differs": proxy.exit_ip_differs,
|
||||
"region": proxy.region,
|
||||
"country": proxy.country,
|
||||
"province": proxy.province,
|
||||
"city": proxy.city,
|
||||
"region_en": proxy.region_en,
|
||||
"country_en": proxy.country_en,
|
||||
"province_en": proxy.province_en,
|
||||
"city_en": proxy.city_en,
|
||||
"delay_ms": proxy.delay_ms,
|
||||
"validated_at": proxy.validated_at.isoformat() if proxy.validated_at else None,
|
||||
"source_plugin": proxy.source_plugin,
|
||||
"is_favorite": proxy.is_favorite,
|
||||
"is_valid": proxy.is_valid,
|
||||
"health_score": proxy.health_score,
|
||||
"anonymity_level": proxy.anonymity_level,
|
||||
"anonymity_fallback": proxy.anonymity_fallback,
|
||||
"supports_https": proxy.supports_https,
|
||||
"supports_http": proxy.supports_http,
|
||||
"quality_assessed_at": (proxy.quality_assessed_at.isoformat()
|
||||
if proxy.quality_assessed_at else None),
|
||||
"success_rate": round(proxy.success_rate, 3),
|
||||
"avg_delay_ms": proxy.avg_delay_ms,
|
||||
"total_checks": proxy.total_checks or 0,
|
||||
"probe_success_rate": round(proxy.probe_success_rate, 3),
|
||||
"probe_total_count": proxy.probe_total_count or 0,
|
||||
}
|
||||
|
||||
|
||||
def _build_filter(
|
||||
protocol: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
min_delay: Optional[int] = None,
|
||||
max_delay: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
ip: Optional[str] = None,
|
||||
is_favorite: Optional[bool] = None,
|
||||
anonymity_level: Optional[str] = None,
|
||||
min_health_score: Optional[float] = None,
|
||||
supports_https: Optional[bool] = None,
|
||||
):
|
||||
from core.domain.models import ANONYMITY_LEVELS, ProxyFilter
|
||||
|
||||
if protocol and protocol.lower() not in SUPPORTED_PROTOCOLS:
|
||||
raise InvalidParameterException('param_invalid_protocol', protocol)
|
||||
|
||||
if status and status.lower() not in ('valid', 'invalid'):
|
||||
raise InvalidParameterException('param_invalid_status', status)
|
||||
|
||||
if min_delay is not None and min_delay < 0:
|
||||
raise InvalidParameterException('param_negative_min_delay', min_delay)
|
||||
|
||||
if max_delay is not None and max_delay < 0:
|
||||
raise InvalidParameterException('param_negative_max_delay', max_delay)
|
||||
|
||||
if min_delay is not None and max_delay is not None and min_delay > max_delay:
|
||||
raise InvalidParameterException(
|
||||
'param_delay_range_reversed', min_delay, max_delay)
|
||||
|
||||
if min_health_score is not None and min_health_score < 0:
|
||||
raise InvalidParameterException(
|
||||
'param_negative_min_health', min_health_score)
|
||||
|
||||
if anonymity_level and anonymity_level.lower() not in ANONYMITY_LEVELS:
|
||||
raise InvalidParameterException('param_invalid_anonymity', anonymity_level)
|
||||
|
||||
is_valid = None
|
||||
if status:
|
||||
is_valid = status.lower() == 'valid'
|
||||
|
||||
return ProxyFilter(
|
||||
protocol=protocol.lower() if protocol else None,
|
||||
region=region,
|
||||
min_delay=min_delay,
|
||||
max_delay=max_delay,
|
||||
is_valid=is_valid,
|
||||
source=source,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
ip=ip,
|
||||
is_favorite=is_favorite,
|
||||
anonymity_level=anonymity_level.lower() if anonymity_level else None,
|
||||
min_health_score=min_health_score,
|
||||
supports_https=supports_https,
|
||||
)
|
||||
|
||||
|
||||
async def get_proxies(
|
||||
protocol: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
min_delay: Optional[int] = None,
|
||||
max_delay: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
sort_by: Optional[str] = None,
|
||||
sort_order: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
format: str = "json",
|
||||
ip: Optional[str] = None,
|
||||
is_favorite: Optional[bool] = None,
|
||||
anonymity_level: Optional[str] = None,
|
||||
min_health_score: Optional[float] = None,
|
||||
supports_https: Optional[bool] = None,
|
||||
):
|
||||
if format.lower() not in ('json', 'text'):
|
||||
raise InvalidParameterException(
|
||||
'param_invalid_export_format', format, 'json / text')
|
||||
|
||||
if page < 1:
|
||||
raise InvalidParameterException('param_page_must_be_positive', page)
|
||||
|
||||
if page_size < 1:
|
||||
raise InvalidParameterException(
|
||||
'param_page_size_must_be_positive', page_size)
|
||||
if page_size > EXPORT_LIMIT:
|
||||
raise InvalidParameterException(
|
||||
'param_page_size_too_large', EXPORT_LIMIT, page_size)
|
||||
|
||||
if limit is not None:
|
||||
if limit < 1:
|
||||
raise InvalidParameterException('param_limit_must_be_positive', limit)
|
||||
if limit > EXPORT_LIMIT:
|
||||
raise InvalidParameterException('param_limit_too_large', EXPORT_LIMIT, limit)
|
||||
page_size = limit
|
||||
page = 1
|
||||
|
||||
filter_obj = _build_filter(
|
||||
protocol=protocol, region=region, min_delay=min_delay, max_delay=max_delay,
|
||||
status=status, source=source, sort_by=sort_by, sort_order=sort_order,
|
||||
ip=ip, is_favorite=is_favorite, anonymity_level=anonymity_level,
|
||||
min_health_score=min_health_score, supports_https=supports_https,
|
||||
)
|
||||
|
||||
proxies = await get_proxy_repository().find(filter_obj, page=page, page_size=page_size)
|
||||
|
||||
if format.lower() == 'text':
|
||||
return RawResponse(content="\n".join(proxy.proxy_url for proxy in proxies))
|
||||
|
||||
return [_proxy_to_dict(proxy) for proxy in proxies]
|
||||
|
||||
|
||||
async def get_random_proxy(
|
||||
protocol: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
min_delay: Optional[int] = None,
|
||||
max_delay: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
format: str = "json",
|
||||
ip: Optional[str] = None,
|
||||
is_favorite: Optional[bool] = None,
|
||||
anonymity_level: Optional[str] = None,
|
||||
min_health_score: Optional[float] = None,
|
||||
supports_https: Optional[bool] = None,
|
||||
):
|
||||
language = get_language()
|
||||
if format.lower() not in ('json', 'text'):
|
||||
raise ApiError(400, get_message('pool_random_proxy_bad_format', language))
|
||||
|
||||
filter_obj = _build_filter(
|
||||
protocol=protocol, region=region, min_delay=min_delay, max_delay=max_delay,
|
||||
status=status, source=source, ip=ip, is_favorite=is_favorite,
|
||||
anonymity_level=anonymity_level, min_health_score=min_health_score,
|
||||
supports_https=supports_https,
|
||||
)
|
||||
|
||||
repository = get_proxy_repository()
|
||||
total_count = await repository.count(filter_obj)
|
||||
|
||||
if total_count == 0:
|
||||
raise ApiError(404, get_message('pool_no_matching_proxies', language))
|
||||
|
||||
rand_page_size = 100
|
||||
total_pages = (total_count + rand_page_size - 1) // rand_page_size
|
||||
proxies = await repository.find(
|
||||
filter_obj, page=random.randint(1, total_pages), page_size=rand_page_size
|
||||
)
|
||||
|
||||
if not proxies:
|
||||
raise ApiError(404, get_message('pool_no_matching_proxies', language))
|
||||
|
||||
proxy = random.choice(proxies)
|
||||
|
||||
if format.lower() == 'text':
|
||||
return RawResponse(content=proxy.proxy_url)
|
||||
|
||||
return _proxy_to_dict(proxy)
|
||||
|
||||
|
||||
async def get_proxies_count(
|
||||
protocol: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
min_delay: Optional[int] = None,
|
||||
max_delay: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
ip: Optional[str] = None,
|
||||
is_favorite: Optional[bool] = None,
|
||||
anonymity_level: Optional[str] = None,
|
||||
min_health_score: Optional[float] = None,
|
||||
supports_https: Optional[bool] = None,
|
||||
):
|
||||
filter_obj = _build_filter(
|
||||
protocol=protocol, region=region, min_delay=min_delay, max_delay=max_delay,
|
||||
status=status, source=source, ip=ip, is_favorite=is_favorite,
|
||||
anonymity_level=anonymity_level, min_health_score=min_health_score,
|
||||
supports_https=supports_https,
|
||||
)
|
||||
|
||||
return {"count": await get_proxy_repository().count(filter_obj)}
|
||||
|
||||
|
||||
async def get_stats():
|
||||
return await get_proxy_repository().get_stats()
|
||||
|
||||
|
||||
async def get_sources():
|
||||
return {"sources": await get_proxy_repository().get_sources()}
|
||||
|
||||
|
||||
async def import_proxies(proxies: list[str]):
|
||||
from core.domain.models import Proxy
|
||||
|
||||
if not proxies:
|
||||
raise InvalidParameterException('param_proxy_list_empty')
|
||||
|
||||
language = get_language()
|
||||
repository = get_proxy_repository()
|
||||
|
||||
parsed = []
|
||||
for proxy_url in proxies:
|
||||
candidate = proxy_url.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
parsed.append(Proxy.from_url(candidate, "manual_import"))
|
||||
except Exception as e:
|
||||
logger.warning(f"解析代理失败 {candidate}: {e}")
|
||||
|
||||
if not parsed:
|
||||
return {
|
||||
"success": False,
|
||||
"message": get_message('pool_import_no_valid_proxy', language),
|
||||
"proxy_count": 0,
|
||||
}
|
||||
|
||||
unique_proxies = []
|
||||
for proxy in parsed:
|
||||
if not await repository.find_by_ip_port(proxy.ip, proxy.port):
|
||||
unique_proxies.append(proxy)
|
||||
|
||||
if not unique_proxies:
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_import_duplicated', language),
|
||||
"proxy_count": 0,
|
||||
}
|
||||
|
||||
task_id = batch_tasks.new_task_id()
|
||||
batch_tasks.spawn(
|
||||
batch_tasks.validate_and_save_proxies_task(
|
||||
task_id, "manual_import", [p.proxy_url for p in unique_proxies]
|
||||
),
|
||||
task_id,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_import_validating', language, len(unique_proxies)),
|
||||
"proxy_count": len(unique_proxies),
|
||||
"task_id": task_id,
|
||||
}
|
||||
|
||||
|
||||
async def export_proxies(
|
||||
protocol: Optional[str] = None,
|
||||
region: Optional[str] = None,
|
||||
min_delay: Optional[int] = None,
|
||||
max_delay: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
ip: Optional[str] = None,
|
||||
is_favorite: Optional[bool] = None,
|
||||
anonymity_level: Optional[str] = None,
|
||||
min_health_score: Optional[float] = None,
|
||||
supports_https: Optional[bool] = None,
|
||||
format: str = "txt",
|
||||
):
|
||||
if format.lower() not in ('txt', 'json', 'csv'):
|
||||
raise InvalidParameterException(
|
||||
'param_invalid_export_format', format, 'txt / json / csv')
|
||||
|
||||
filter_obj = _build_filter(
|
||||
protocol=protocol, region=region, min_delay=min_delay, max_delay=max_delay,
|
||||
status=status, source=source, ip=ip, is_favorite=is_favorite,
|
||||
anonymity_level=anonymity_level, min_health_score=min_health_score,
|
||||
supports_https=supports_https,
|
||||
)
|
||||
|
||||
proxies = await get_proxy_repository().find(
|
||||
filter_obj, page=1, page_size=EXPORT_LIMIT
|
||||
)
|
||||
|
||||
if not proxies:
|
||||
raise ApiError(404, get_message('pool_no_matching_proxies', get_language()))
|
||||
|
||||
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
export_format = format.lower()
|
||||
|
||||
if export_format == 'txt':
|
||||
return RawResponse(
|
||||
content="\n".join(p.proxy_url for p in proxies),
|
||||
filename=f"proxies_{stamp}.txt",
|
||||
)
|
||||
|
||||
if export_format == 'json':
|
||||
return json_download(
|
||||
[
|
||||
{
|
||||
"protocol": p.protocol,
|
||||
"ip": p.ip,
|
||||
"port": p.port,
|
||||
"username": p.username,
|
||||
"password": p.password,
|
||||
"url": p.proxy_url,
|
||||
"region": p.region,
|
||||
"region_en": p.region_en,
|
||||
"delay_ms": p.delay_ms,
|
||||
"is_valid": p.is_valid,
|
||||
"real_ip": p.real_ip,
|
||||
"source_plugin": p.source_plugin,
|
||||
"validated_at": p.validated_at.isoformat() if p.validated_at else None,
|
||||
}
|
||||
for p in proxies
|
||||
],
|
||||
filename=f"proxies_{stamp}.json",
|
||||
)
|
||||
|
||||
return RawResponse(
|
||||
content='' + _render_csv(proxies, get_language()),
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
filename=f"proxies_{stamp}.csv",
|
||||
)
|
||||
|
||||
|
||||
_CSV_FORMULA_PREFIXES = ('=', '+', '-', '@')
|
||||
|
||||
|
||||
def _csv_safe(value) -> str:
|
||||
text = '' if value is None else str(value)
|
||||
if text[:1] in _CSV_FORMULA_PREFIXES:
|
||||
return "'" + text
|
||||
return text
|
||||
|
||||
|
||||
def _render_csv(proxies, language: str = "cn") -> str:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
get_message('csv_col_protocol', language),
|
||||
get_message('csv_col_ip', language),
|
||||
get_message('csv_col_port', language),
|
||||
get_message('csv_col_username', language),
|
||||
get_message('csv_col_password', language),
|
||||
get_message('csv_col_proxy_url', language),
|
||||
get_message('csv_col_region', language),
|
||||
get_message('csv_col_delay_ms', language),
|
||||
get_message('csv_col_status', language),
|
||||
get_message('csv_col_real_ip', language),
|
||||
get_message('csv_col_source', language),
|
||||
get_message('csv_col_validated_at', language),
|
||||
])
|
||||
for p in proxies:
|
||||
writer.writerow([_csv_safe(cell) for cell in (
|
||||
p.protocol,
|
||||
p.ip,
|
||||
p.port,
|
||||
p.username or '',
|
||||
p.password or '',
|
||||
p.proxy_url,
|
||||
p.region,
|
||||
p.delay_ms if p.delay_ms else '',
|
||||
get_message('csv_status_valid' if p.is_valid else 'csv_status_invalid',
|
||||
language),
|
||||
p.real_ip or '',
|
||||
p.source_plugin,
|
||||
p.validated_at.strftime('%Y-%m-%d %H:%M:%S') if p.validated_at else '',
|
||||
)])
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
async def toggle_favorite(proxy_id: int):
|
||||
repository = get_proxy_repository()
|
||||
language = get_language()
|
||||
|
||||
proxy = await repository.get_by_id(proxy_id)
|
||||
if not proxy:
|
||||
raise ApiError(404, get_message('pool_proxy_not_found', language))
|
||||
|
||||
new_favorite = not getattr(proxy, 'is_favorite', False)
|
||||
await repository.update(proxy_id, {'is_favorite': int(new_favorite)})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"is_favorite": new_favorite,
|
||||
"message": get_message(
|
||||
'pool_favorite_added' if new_favorite else 'pool_favorite_removed', language
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_proxies",
|
||||
"get_random_proxy",
|
||||
"get_proxies_count",
|
||||
"get_stats",
|
||||
"get_sources",
|
||||
"import_proxies",
|
||||
"export_proxies",
|
||||
"toggle_favorite",
|
||||
]
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.api.routes.validation
|
||||
功能描述:代理池批量操作的业务入口:批量验证、全面检测、批量删除、使用期反馈、本机出口 IP 查询,以及后台任务的状态查询与取消。
|
||||
只实现业务逻辑、不依赖 Web 框架:返回普通字典(适配层负责序列化),参数错误抛带文案键的异常,业务性错误抛 ApiError。
|
||||
职责边界:负责:批量操作的参数校验与任务创建、删除时同步去重缓存、使用期反馈的失败计数与失效标记;不负责:HTTP 路由注册与请求解析(见 modules.proxypool_api)。
|
||||
后台任务执行与状态表见 core.application.batch_tasks,入库判据见 core.application.ingest。
|
||||
关键依赖:core.application.batch_tasks、core.application.api.dependencies(仓库/验证器/去重缓存/配置/语言)、
|
||||
core.application.api.http_types.ApiError、core.domain.models、modules.modules。
|
||||
已知限制:
|
||||
1. _use_failure_streak 是模块级内存表,只跑在池自己的事件循环内、无需加锁;进程重启后重新累计,达到 10000 条后的下一次计数整表清空。
|
||||
2. test_url 非空且不可归因(can_attribute_failure)的失败被忽略;达阈值写 is_valid=0、清零 total_checks,不写 validated_at。
|
||||
3. total_checks=0 让记录重回重验证候选(find_without_checks 按 0 选取,绕过 only_valid_proxies);测通后 is_valid 翻回真。
|
||||
4. 有去重缓存时删除前必须先取出 (ip, port) 再删,失效代理要分页扫,否则缓存残留产生幽灵去重;缓存同步失败只记警告,删除结果照常返回。
|
||||
5. 任务接口语义:创建返回 running/queued 快照而非结果;cancel 对已结束任务报 success=False;查询对不存在或过期任务报 404。
|
||||
6. 批量操作静默忽略不存在的 id(validate_batch_proxies 全部 id 缺失才报 404);get_host_ip 在验证器缺少 endpoints 时静默返回空基准。
|
||||
7. validate_batch_proxies 的目标须按 id 与地址成对收集,拆成平行列表会因缺失 id 错位、把结果写到别的代理。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from core.application import batch_tasks
|
||||
from core.application.api.dependencies import (
|
||||
get_proxy_repository, get_proxy_validator, get_dedup_cache, get_language,
|
||||
get_config_manager,
|
||||
)
|
||||
from core.application.api.http_types import ApiError
|
||||
from core.domain.exceptions import InvalidParameterException
|
||||
from modules.modules import get_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PAIR_SCAN_PAGE_SIZE = 5000
|
||||
|
||||
_use_failure_streak: dict[tuple, int] = {}
|
||||
|
||||
_USE_STREAK_MAX_ENTRIES = 10000
|
||||
|
||||
|
||||
def _remember_use_failure(key: tuple) -> int:
|
||||
if len(_use_failure_streak) >= _USE_STREAK_MAX_ENTRIES:
|
||||
_use_failure_streak.clear()
|
||||
streak = _use_failure_streak.get(key, 0) + 1
|
||||
_use_failure_streak[key] = streak
|
||||
return streak
|
||||
|
||||
|
||||
async def report_proxy_use(ip: str, port, ok: bool, test_url: str = "") -> None:
|
||||
config = get_config_manager().get_config()
|
||||
settings = getattr(config, "use_feedback", None)
|
||||
if settings is None or not getattr(settings, "enabled", False):
|
||||
return
|
||||
|
||||
repository = get_proxy_repository()
|
||||
proxy = await repository.find_by_ip_port(ip, port)
|
||||
if proxy is None:
|
||||
return
|
||||
|
||||
key = (str(ip), int(port))
|
||||
|
||||
if ok:
|
||||
if _use_failure_streak.pop(key, 0):
|
||||
logger.info(f"代理 {ip}:{port} 使用期恢复可用,连续失败计数清零")
|
||||
return
|
||||
|
||||
if test_url and not get_proxy_validator().can_attribute_failure(test_url):
|
||||
logger.debug(f"代理 {ip}:{port} 使用期失败不可归因(本机链路或测试地址不可达),已忽略")
|
||||
return
|
||||
|
||||
threshold = max(1, int(getattr(settings, "invalid_threshold", 3) or 1))
|
||||
streak = _remember_use_failure(key)
|
||||
|
||||
if streak < threshold:
|
||||
logger.debug(f"代理 {ip}:{port} 使用期连续失败 {streak}/{threshold} 次")
|
||||
return
|
||||
|
||||
try:
|
||||
written = await repository.update(proxy.id, {"is_valid": 0, "total_checks": 0})
|
||||
except Exception as e:
|
||||
logger.error(f"代理 {ip}:{port} 标记失效时写库失败(计数保留,下次重试): {e}")
|
||||
return
|
||||
|
||||
_use_failure_streak.pop(key, None)
|
||||
if written:
|
||||
logger.warning(
|
||||
f"代理 {ip}:{port} 使用期连续失败 {threshold} 次,已标记为失效"
|
||||
f"(列入下一轮重验证候选)"
|
||||
)
|
||||
else:
|
||||
logger.error(f"代理 {ip}:{port} 标记失效失败:记录已不存在")
|
||||
|
||||
_VALID_VALIDATION_MODES = ("auto", "liveness", "full")
|
||||
|
||||
|
||||
|
||||
async def validate_all_proxies(is_valid_filter: bool):
|
||||
task_id = batch_tasks.new_task_id()
|
||||
batch_tasks.spawn(
|
||||
batch_tasks.validate_all_proxies_task(task_id, is_valid_filter), task_id
|
||||
)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "queued",
|
||||
"message": get_message('pool_validation_task_created', get_language()),
|
||||
}
|
||||
|
||||
|
||||
async def update_all_geo():
|
||||
task_id = batch_tasks.new_task_id()
|
||||
batch_tasks.spawn(batch_tasks.update_all_geo_task(task_id), task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "queued",
|
||||
"message": get_message('pool_full_check_task_created', get_language()),
|
||||
}
|
||||
|
||||
|
||||
async def validate_batch_proxies(proxy_ids: list[int], mode: str = "auto"):
|
||||
if not proxy_ids:
|
||||
raise InvalidParameterException('param_proxy_ids_empty')
|
||||
if mode not in _VALID_VALIDATION_MODES:
|
||||
raise InvalidParameterException(
|
||||
'param_invalid_validation_mode', mode, ' / '.join(_VALID_VALIDATION_MODES)
|
||||
)
|
||||
|
||||
language = get_language()
|
||||
repository = get_proxy_repository()
|
||||
targets = []
|
||||
for proxy_id in proxy_ids:
|
||||
proxy = await repository.get_by_id(proxy_id)
|
||||
if proxy:
|
||||
targets.append((proxy_id, proxy.proxy_url))
|
||||
|
||||
if not targets:
|
||||
raise ApiError(404, get_message('pool_validation_no_valid_proxy', language))
|
||||
|
||||
task_id = batch_tasks.new_task_id()
|
||||
batch_tasks.spawn(
|
||||
batch_tasks.validate_selected_proxies_task(task_id, targets, mode),
|
||||
task_id,
|
||||
)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "running",
|
||||
"message": get_message('pool_validating_proxies', language, len(targets)),
|
||||
}
|
||||
|
||||
|
||||
async def delete_invalid_proxies():
|
||||
from core.domain.models import ProxyFilter
|
||||
|
||||
language = get_language()
|
||||
repository = get_proxy_repository()
|
||||
dedup_cache = get_dedup_cache()
|
||||
|
||||
pairs = []
|
||||
if dedup_cache is not None:
|
||||
page = 1
|
||||
while True:
|
||||
batch = await repository.find(
|
||||
ProxyFilter(is_valid=False), page=page, page_size=_PAIR_SCAN_PAGE_SIZE
|
||||
)
|
||||
if not batch:
|
||||
break
|
||||
pairs.extend((proxy.ip, proxy.port) for proxy in batch)
|
||||
if len(batch) < _PAIR_SCAN_PAGE_SIZE:
|
||||
break
|
||||
page += 1
|
||||
|
||||
count = await repository.delete_invalid()
|
||||
|
||||
if count == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_no_invalid_proxies', language),
|
||||
"deleted_count": 0,
|
||||
}
|
||||
|
||||
await _sync_dedup_cache_removal(dedup_cache, pairs, "失效代理")
|
||||
|
||||
logger.info(f"已删除 {count} 个失效代理")
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_invalid_proxies_deleted', language, count),
|
||||
"deleted_count": count,
|
||||
}
|
||||
|
||||
|
||||
async def delete_batch_proxies(proxy_ids: list[int]):
|
||||
if not proxy_ids:
|
||||
raise InvalidParameterException('param_proxy_ids_empty')
|
||||
|
||||
language = get_language()
|
||||
repository = get_proxy_repository()
|
||||
dedup_cache = get_dedup_cache()
|
||||
|
||||
pairs = []
|
||||
if dedup_cache is not None:
|
||||
for proxy_id in proxy_ids:
|
||||
proxy = await repository.get_by_id(proxy_id)
|
||||
if proxy is not None:
|
||||
pairs.append((proxy.ip, proxy.port))
|
||||
|
||||
deleted_count = await repository.delete_batch(proxy_ids)
|
||||
await _sync_dedup_cache_removal(dedup_cache, pairs, "批量删除代理")
|
||||
|
||||
logger.info(f"批量删除了 {deleted_count} 个代理")
|
||||
return {
|
||||
"success": True,
|
||||
"message": get_message('pool_proxies_deleted', language, deleted_count),
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
|
||||
|
||||
async def _sync_dedup_cache_removal(dedup_cache, pairs, scene: str) -> None:
|
||||
if dedup_cache is None or not pairs:
|
||||
return
|
||||
|
||||
try:
|
||||
await dedup_cache.remove_batch(pairs)
|
||||
logger.info(f"已从去重缓存中移除 {len(pairs)} 个键({scene})")
|
||||
except Exception as e:
|
||||
logger.warning(f"{scene}后同步去重缓存失败(非致命): {e}")
|
||||
|
||||
|
||||
async def get_host_ip():
|
||||
validator = get_proxy_validator()
|
||||
endpoints = getattr(validator, "endpoints", None)
|
||||
if endpoints is None:
|
||||
return {
|
||||
"host_public_ip": None,
|
||||
"host_public_ipv4": None,
|
||||
"host_public_ipv6": None,
|
||||
"source": "",
|
||||
"check_anonymity": True,
|
||||
}
|
||||
|
||||
resolved = getattr(endpoints, "host_public_ips", None)
|
||||
ips = list(resolved()) if callable(resolved) else []
|
||||
ipv4 = next((ip for ip in ips if ":" not in ip), None)
|
||||
ipv6 = next((ip for ip in ips if ":" in ip), None)
|
||||
|
||||
return {
|
||||
"host_public_ip": endpoints.host_public_ip(),
|
||||
"host_public_ipv4": ipv4,
|
||||
"host_public_ipv6": ipv6,
|
||||
"source": endpoints.host_ip_source(),
|
||||
"check_anonymity": bool(getattr(endpoints, "check_anonymity", True)),
|
||||
}
|
||||
|
||||
|
||||
async def get_task_status(task_id: str):
|
||||
batch_tasks.cleanup_task_status()
|
||||
task_store = batch_tasks.background_tasks_status
|
||||
|
||||
if task_id not in task_store:
|
||||
raise ApiError(404, get_message('pool_task_not_found', get_language()))
|
||||
|
||||
return task_store[task_id]
|
||||
|
||||
|
||||
async def cancel_task(task_id: str):
|
||||
language = get_language()
|
||||
if task_id not in batch_tasks.background_tasks_status:
|
||||
raise ApiError(404, get_message('pool_task_not_found', language))
|
||||
|
||||
cancelled = await batch_tasks.cancel_task(task_id)
|
||||
if not cancelled:
|
||||
return {
|
||||
"success": False,
|
||||
"message": get_message('pool_task_already_finished', language),
|
||||
}
|
||||
|
||||
return {"success": True, "message": get_message('pool_task_cancelled', language)}
|
||||
|
||||
|
||||
async def list_all_tasks(status: Optional[str] = None):
|
||||
batch_tasks.cleanup_task_status()
|
||||
task_store = batch_tasks.background_tasks_status
|
||||
|
||||
wanted = status.lower() if status else None
|
||||
tasks = []
|
||||
for task_id, task_data in task_store.items():
|
||||
task_info = {"task_id": task_id}
|
||||
if isinstance(task_data, dict):
|
||||
task_info.update(task_data)
|
||||
else:
|
||||
task_info["data"] = str(task_data)
|
||||
|
||||
if wanted and task_info.get("status", "") != wanted:
|
||||
continue
|
||||
tasks.append(task_info)
|
||||
|
||||
return {"tasks": tasks, "total": len(tasks)}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"validate_all_proxies",
|
||||
"update_all_geo",
|
||||
"validate_batch_proxies",
|
||||
"delete_invalid_proxies",
|
||||
"delete_batch_proxies",
|
||||
"get_host_ip",
|
||||
"get_task_status",
|
||||
"list_all_tasks",
|
||||
"cancel_task",
|
||||
]
|
||||
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.app
|
||||
功能描述:代理池的应用编排层:装配 DI 容器,按依赖顺序启动与关闭全部核心服务,并把配置热更新推送给各服务。
|
||||
职责边界:负责:DI 容器装配与解析、服务生命周期编排、配置变更扇出;不负责:API 路由(core.application.api)、配置读写(config_manager)、各服务内部逻辑。
|
||||
关键依赖:core.infrastructure(DI 容器、配置管理器、连接池、去重缓存、HTTP 客户端、离线库)、core.services、core.data、
|
||||
core.database、core.config、core.paths、core.interfaces、core.application.batch_tasks、
|
||||
core.application.api.dependencies(全局访问点 set_backup_manager、set_data_cleaner、
|
||||
set_geo_resolver)、core.backup 与 core.data_cleaner(均在方法内延迟导入)。
|
||||
已知限制:
|
||||
1. 一个进程只应创建一个 Application 实例并只 initialize() 一次,重复初始化会让旧实例已启动的服务继续运行并与新实例并行。
|
||||
2. 启动顺序不可调整(后启动者依赖前者已就绪),关闭是启动的逆序;批量任务与归属地流水线必须先于写队列停止,否则结果会写向已停止的队列。
|
||||
3. 关闭时单个服务失败只记 error 后继续,正常返回不代表资源都已释放;initialize() 中途失败不回滚已启动服务,未初始化时 shutdown() 安全。
|
||||
4. 服务与后台任务绑定在 initialize() 调用方的事件循环上,跨线程必须用 loop.call_soon_threadsafe 调度回来。
|
||||
5. 数据库路径锚定在池子项目根,不随宿主工作目录变化;配置路径不存在时按需建出目录使用,仅当目录无法创建且默认库存在时才回退默认库。
|
||||
6. 可选服务(备份、维护器)启动失败只记 warning、不阻断初始化;shutdown() 不清空容器、连接池、去重缓存、备份管理器等引用。
|
||||
7. 配置热更新停用备份时,须先摘掉全局访问点(set_backup_manager(None))再停实例,避免接口层在停止过程中取到正在关闭的实例。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from core.config import Config
|
||||
from core.paths import POOL_BASE_DIR, resolve_pool_path
|
||||
from core.application import batch_tasks
|
||||
from core.application.api.dependencies import (
|
||||
set_backup_manager,
|
||||
set_data_cleaner,
|
||||
set_geo_resolver,
|
||||
)
|
||||
from core.infrastructure.di_container import DIContainer
|
||||
from core.interfaces.infrastructure import IConfigManager, IWriteQueue, IHttpClient
|
||||
from core.interfaces.services import IGeoIPService, IPluginManager, IProxyValidator
|
||||
from core.interfaces.repository import IProxyRepository, IPluginConfigRepository
|
||||
from core.infrastructure.config_manager import ConfigManager
|
||||
from core.infrastructure.http_client import AioHttpClient
|
||||
from core.infrastructure.connection_pool import ConnectionPool
|
||||
from core.infrastructure.dedup_cache import DedupCache
|
||||
from core.infrastructure.geodb import GeoDatabase
|
||||
from core.data.repositories import ProxyRepository, PluginConfigRepository
|
||||
from core.data.write_queue import AsyncWriteQueue
|
||||
from core.services.geoip import GeoIPService
|
||||
from core.services.geo_resolver import GeoResolver
|
||||
from core.services.validator import ProxyValidator
|
||||
from core.services.validation_endpoints import ValidationEndpoints
|
||||
from core.services.plugin_manager import PluginManager
|
||||
from core.services.auto_revalidator import AutoRevalidator
|
||||
from core.services.health_scorer import HealthScorer
|
||||
from core.database import init_database
|
||||
from core.domain.exceptions import ApplicationException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Application:
|
||||
def __init__(self, config: Optional[Config] = None):
|
||||
self._config: Config = config if config is not None else Config()
|
||||
self._container: Optional[DIContainer] = None
|
||||
self._config_manager: Optional[IConfigManager] = None
|
||||
self._auto_revalidator: Optional[AutoRevalidator] = None
|
||||
self._geo_resolver: Optional[GeoResolver] = None
|
||||
self._connection_pool: Optional[ConnectionPool] = None
|
||||
self._dedup_cache: Optional[DedupCache] = None
|
||||
self._backup_manager = None
|
||||
self._data_cleaner = None
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
enable_backup: bool = True,
|
||||
enable_cleaner: bool = True,
|
||||
) -> None:
|
||||
try:
|
||||
logger.info("开始初始化应用...")
|
||||
|
||||
logger.info("初始化配置管理器...")
|
||||
config_manager = ConfigManager(self._config)
|
||||
self._config_manager = config_manager
|
||||
config = config_manager.get_config()
|
||||
|
||||
db_path = self._resolve_database_path(config.database.path)
|
||||
config.database.path = str(db_path)
|
||||
|
||||
logger.info("初始化数据库: %s", db_path)
|
||||
await init_database(str(db_path))
|
||||
|
||||
logger.info("初始化连接池...")
|
||||
pool = ConnectionPool(
|
||||
db_path=str(db_path),
|
||||
max_readers=config.database.pool_max_readers,
|
||||
)
|
||||
await pool.initialize()
|
||||
self._connection_pool = pool
|
||||
|
||||
logger.info("初始化去重缓存...")
|
||||
dedup_cache = DedupCache()
|
||||
await dedup_cache.load_from_db(pool)
|
||||
self._dedup_cache = dedup_cache
|
||||
|
||||
logger.info("配置依赖注入容器...")
|
||||
self._container = self._configure_container(
|
||||
config_manager, config, pool, dedup_cache
|
||||
)
|
||||
|
||||
config_manager.subscribe(self._on_config_changed)
|
||||
|
||||
logger.info("启动核心服务...")
|
||||
|
||||
write_queue = self._container.resolve(IWriteQueue)
|
||||
await write_queue.start()
|
||||
logger.info("写入队列已启动")
|
||||
|
||||
geoip_service = self._container.resolve(IGeoIPService)
|
||||
geoip_service.load_database()
|
||||
geodb = self._container.resolve(GeoDatabase)
|
||||
logger.info(
|
||||
"GeoIP 服务已初始化(离线归属地库: %s)",
|
||||
"可用" if geodb.available else "不可用,归属地将一律为未知",
|
||||
)
|
||||
|
||||
self._geo_resolver = GeoResolver(
|
||||
geoip_service,
|
||||
write_queue,
|
||||
proxy_repository=self._container.resolve(IProxyRepository),
|
||||
geodb=geodb,
|
||||
)
|
||||
await self._geo_resolver.start()
|
||||
set_geo_resolver(self._geo_resolver)
|
||||
|
||||
plugin_manager = self._container.resolve(IPluginManager)
|
||||
await plugin_manager.start()
|
||||
logger.info("插件管理器已启动")
|
||||
|
||||
logger.info("启动自动重新验证服务...")
|
||||
proxy_repo = self._container.resolve(IProxyRepository)
|
||||
validator = self._container.resolve(IProxyValidator)
|
||||
|
||||
scorer = HealthScorer()
|
||||
|
||||
self._auto_revalidator = AutoRevalidator(
|
||||
repository=proxy_repo,
|
||||
validator=validator,
|
||||
geo_resolver=self._geo_resolver,
|
||||
write_queue=write_queue,
|
||||
config_manager=self._config_manager,
|
||||
health_scorer=scorer,
|
||||
dedup_cache=self._dedup_cache,
|
||||
plugin_config_repo=self._container.resolve(IPluginConfigRepository),
|
||||
)
|
||||
await self._auto_revalidator.start()
|
||||
logger.info("自动重新验证服务已启动")
|
||||
|
||||
await self._start_optional_services(config, enable_backup, enable_cleaner)
|
||||
|
||||
logger.info("应用初始化完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"应用初始化失败: {e}", exc_info=True)
|
||||
raise ApplicationException(f"应用初始化失败: {str(e)}") from e
|
||||
|
||||
async def _start_backup_manager(self, config) -> None:
|
||||
try:
|
||||
from core.backup import DatabaseBackup
|
||||
self._backup_manager = DatabaseBackup(
|
||||
config.database.path,
|
||||
backup_interval_hours=config.database.backup_interval_hours,
|
||||
retention_days=config.database.backup_retention_days,
|
||||
)
|
||||
await self._backup_manager.start()
|
||||
set_backup_manager(self._backup_manager)
|
||||
logger.info("数据库备份管理器已启动")
|
||||
except Exception as e:
|
||||
self._backup_manager = None
|
||||
set_backup_manager(None)
|
||||
logger.warning(f"启动备份管理器失败(非致命): {e}")
|
||||
|
||||
async def _stop_backup_manager(self) -> None:
|
||||
manager, self._backup_manager = self._backup_manager, None
|
||||
set_backup_manager(None)
|
||||
if manager is None:
|
||||
return
|
||||
try:
|
||||
await manager.stop()
|
||||
logger.info("数据库备份管理器已停止(配置里关掉了自动备份)")
|
||||
except Exception as e:
|
||||
logger.error(f"停止备份管理器失败: {e}")
|
||||
|
||||
async def _start_optional_services(
|
||||
self, config, enable_backup: bool, enable_cleaner: bool
|
||||
) -> None:
|
||||
if enable_backup and config.database.backup_enabled:
|
||||
await self._start_backup_manager(config)
|
||||
|
||||
if enable_cleaner and self._connection_pool is not None:
|
||||
try:
|
||||
from core.data_cleaner import DataCleaner
|
||||
self._data_cleaner = DataCleaner(self._connection_pool)
|
||||
set_data_cleaner(self._data_cleaner)
|
||||
logger.info("数据库维护器已就绪")
|
||||
except Exception as e:
|
||||
logger.warning(f"启动数据清理器失败(非致命): {e}")
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
try:
|
||||
logger.info("开始关闭应用...")
|
||||
|
||||
if self._container:
|
||||
if self._auto_revalidator:
|
||||
try:
|
||||
logger.info("停止自动重新验证服务...")
|
||||
await self._auto_revalidator.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"停止自动重新验证服务失败: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
logger.info("停止插件管理器...")
|
||||
plugin_manager = self._container.resolve(IPluginManager)
|
||||
await plugin_manager.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"停止插件管理器失败: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
logger.info("取消在跑的后台批量任务...")
|
||||
await batch_tasks.cancel_running_tasks()
|
||||
except Exception as e:
|
||||
logger.error(f"取消后台批量任务失败: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
logger.info("停止归属地解析流水线...")
|
||||
if self._geo_resolver:
|
||||
await self._geo_resolver.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"停止归属地解析流水线失败: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
logger.info("停止写入队列...")
|
||||
write_queue = self._container.resolve(IWriteQueue)
|
||||
await write_queue.stop()
|
||||
logger.info("写入队列已停止")
|
||||
except Exception as e:
|
||||
logger.error(f"停止写入队列失败: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
logger.info("关闭 GeoIP 服务...")
|
||||
geoip_service = self._container.resolve(IGeoIPService)
|
||||
geoip_service.close()
|
||||
logger.info("GeoIP 服务已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭 GeoIP 服务失败: {e}", exc_info=True)
|
||||
|
||||
try:
|
||||
logger.info("关闭 HTTP 客户端...")
|
||||
http_client = self._container.resolve(IHttpClient)
|
||||
await http_client.close()
|
||||
logger.info("HTTP 客户端已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭 HTTP 客户端失败: {e}", exc_info=True)
|
||||
|
||||
await self._stop_optional_services()
|
||||
|
||||
if self._connection_pool:
|
||||
try:
|
||||
logger.info("关闭连接池...")
|
||||
await self._connection_pool.close()
|
||||
logger.info("连接池已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭连接池失败: {e}", exc_info=True)
|
||||
|
||||
logger.info("应用已关闭")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"应用关闭失败: {e}", exc_info=True)
|
||||
raise ApplicationException(f"应用关闭失败: {str(e)}") from e
|
||||
|
||||
async def _stop_optional_services(self) -> None:
|
||||
if self._backup_manager is not None:
|
||||
try:
|
||||
logger.info("停止备份管理器...")
|
||||
await self._backup_manager.stop()
|
||||
logger.info("备份管理器已停止")
|
||||
except Exception as e:
|
||||
logger.error(f"停止备份管理器失败: {e}", exc_info=True)
|
||||
|
||||
def get_container(self) -> DIContainer:
|
||||
if self._container is None:
|
||||
raise RuntimeError("容器未初始化,请先调用 initialize()")
|
||||
return self._container
|
||||
|
||||
async def apply_config(self, config: Config) -> None:
|
||||
if self._config_manager is None:
|
||||
raise RuntimeError("配置管理器未初始化,请先调用 initialize()")
|
||||
await self._config_manager.apply(config)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_database_path(configured_path: str):
|
||||
resolved = resolve_pool_path(configured_path)
|
||||
if resolved.exists():
|
||||
return resolved
|
||||
|
||||
try:
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
return resolved
|
||||
except OSError as e:
|
||||
fallback = POOL_BASE_DIR / "data" / "proxies.db"
|
||||
if fallback.exists() and fallback != resolved:
|
||||
logger.error(
|
||||
"配置的数据库路径不可用(%s,%s),已回退到默认库 %s —— 请检查配置",
|
||||
resolved, e, fallback,
|
||||
)
|
||||
return fallback
|
||||
return resolved
|
||||
|
||||
@property
|
||||
def connection_pool(self) -> Optional[ConnectionPool]:
|
||||
return self._connection_pool
|
||||
|
||||
@property
|
||||
def dedup_cache(self) -> Optional[DedupCache]:
|
||||
return self._dedup_cache
|
||||
|
||||
def _configure_container(
|
||||
self,
|
||||
config_manager: ConfigManager,
|
||||
config,
|
||||
pool: ConnectionPool,
|
||||
dedup_cache: DedupCache,
|
||||
) -> DIContainer:
|
||||
container = DIContainer()
|
||||
|
||||
container.register_instance(IConfigManager, config_manager)
|
||||
|
||||
http_client = AioHttpClient(
|
||||
default_timeout=config.plugins.request_timeout_seconds
|
||||
)
|
||||
container.register_instance(IHttpClient, http_client)
|
||||
|
||||
proxy_repo = ProxyRepository(pool)
|
||||
plugin_config_repo = PluginConfigRepository(pool)
|
||||
container.register_instance(IProxyRepository, proxy_repo)
|
||||
container.register_instance(IPluginConfigRepository, plugin_config_repo)
|
||||
|
||||
write_queue = AsyncWriteQueue(
|
||||
proxy_repository=proxy_repo,
|
||||
batch_size=config.write_queue.batch_size,
|
||||
dedup_cache=dedup_cache,
|
||||
max_queue_size=config.write_queue.max_queue_size,
|
||||
max_retries=config.write_queue.max_retries,
|
||||
)
|
||||
container.register_instance(IWriteQueue, write_queue)
|
||||
|
||||
geodb = GeoDatabase()
|
||||
container.register_instance(GeoDatabase, geodb)
|
||||
|
||||
geoip_service = GeoIPService(geodb=geodb)
|
||||
container.register_instance(IGeoIPService, geoip_service)
|
||||
|
||||
validator = ProxyValidator(
|
||||
http_client, config_manager, ValidationEndpoints(http_client, config_manager)
|
||||
)
|
||||
container.register_instance(IProxyValidator, validator)
|
||||
|
||||
plugin_manager = PluginManager(
|
||||
plugin_config_repo=plugin_config_repo,
|
||||
proxy_repo=proxy_repo,
|
||||
http_client=http_client,
|
||||
config=config,
|
||||
dedup_cache=dedup_cache,
|
||||
)
|
||||
container.register_instance(IPluginManager, plugin_manager)
|
||||
|
||||
logger.info("依赖注入容器配置完成")
|
||||
return container
|
||||
|
||||
async def _apply_backup_config(self, new_config) -> None:
|
||||
if new_config.database.backup_enabled:
|
||||
if self._backup_manager is None:
|
||||
await self._start_backup_manager(new_config)
|
||||
else:
|
||||
try:
|
||||
self._backup_manager.apply_config(new_config)
|
||||
except Exception as e:
|
||||
logger.error(f"备份管理器应用配置失败: {e}")
|
||||
elif self._backup_manager is not None:
|
||||
await self._stop_backup_manager()
|
||||
|
||||
async def _on_config_changed(self, new_config) -> None:
|
||||
logger.info("检测到配置变更,应用新配置...")
|
||||
|
||||
try:
|
||||
if self._container:
|
||||
try:
|
||||
write_queue = self._container.resolve(IWriteQueue)
|
||||
write_queue.apply_config(new_config)
|
||||
except Exception as e:
|
||||
logger.error(f"写入队列应用配置失败: {e}")
|
||||
|
||||
try:
|
||||
validator = self._container.resolve(IProxyValidator)
|
||||
validator.apply_config(new_config)
|
||||
except Exception as e:
|
||||
logger.error(f"验证器应用配置失败: {e}")
|
||||
|
||||
try:
|
||||
plugin_manager = self._container.resolve(IPluginManager)
|
||||
plugin_manager.apply_config(new_config)
|
||||
except Exception as e:
|
||||
logger.error(f"插件管理器应用配置失败: {e}")
|
||||
|
||||
try:
|
||||
http_client = self._container.resolve(IHttpClient)
|
||||
http_client.apply_config(new_config)
|
||||
except Exception as e:
|
||||
logger.error(f"HTTP 客户端应用配置失败: {e}")
|
||||
|
||||
await self._apply_backup_config(new_config)
|
||||
|
||||
logger.info("新配置已应用到所有服务")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"应用新配置失败: {e}", exc_info=True)
|
||||
@@ -0,0 +1,748 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.batch_tasks
|
||||
功能描述:代理池的后台批量任务与任务状态表:Web 层经本模块 spawn 启动五类批量任务与两类维护任务,执行体把进度写入进程内状态表,供前端按 task_id 轮询。
|
||||
职责边界:负责:任务登记、取消与状态写入,状态表的 TTL 与条数淘汰,以及各批量与维护任务的执行体;
|
||||
不负责:任务的发起与调度(路由层)、单个代理的验证算法(ProxyValidator)、验证结果的入库与落地(ingest、validation_apply)。
|
||||
关键依赖:core.application.api.dependencies、core.application.ingest、
|
||||
core.services.validation_apply、core.services.validation_policy、
|
||||
core.services.health_scorer、core.domain.models、
|
||||
core.infrastructure.geodb、modules.modules。
|
||||
已知限制:
|
||||
1. 取消是协作式的,只在下一个 await 点生效:在跑的一批先收尾(可能已写入),未开始的候选直接丢弃,不保证零写入,且取消状态写出后重新抛出 CancelledError。
|
||||
2. asyncio.to_thread 中的离线归属地库下载不可取消:等待方退出后下载线程仍会跑完,仍可能完成文件换入。
|
||||
3. 状态表是进程内内存态:进程重启即丢;被 TTL 淘汰的 id 再查是「不存在」而非「已完成」;终态(含 cancelled)保留 1 小时,条数上限 200,运行中的任务不参与淘汰。
|
||||
4. 状态表只在池的事件循环线程内读写,跨线程进度更新必须经 loop.call_soon_threadsafe 回投事件循环。
|
||||
5. 未经 spawn 登记的任务在关闭流程中无法定向取消,只能落到兜底的 cancel-all。
|
||||
6. status.message 按写入时的界面语言渲染:切换语言只影响之后的写入,已写出的快照不会回溯翻译。
|
||||
7. 终态集合必须包含 cancelled:漏掉它,取消过的条目既过不了 TTL、也进不了条数淘汰,状态表只增不减。
|
||||
8. 验证任务入参 targets 必须保持 (proxy_id, proxy_url) 成对:调用方会跳过已删除的 id,拆成两个平行列表会错位,验证结果会写到别的代理头上。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from core.services.validation_apply import (
|
||||
APPLY_APPLIED,
|
||||
build_update_operation,
|
||||
dispatch_validation_action,
|
||||
)
|
||||
from core.application.ingest import IngestProgress, ingest_proxies
|
||||
from modules.modules import sanitize_proxy
|
||||
|
||||
|
||||
def _message(key: str, *args) -> str:
|
||||
from core.application.api.dependencies import get_language
|
||||
from modules.modules import get_message
|
||||
|
||||
return get_message(key, get_language(), *args)
|
||||
|
||||
|
||||
def _language() -> str:
|
||||
from core.application.api.dependencies import get_language
|
||||
|
||||
return get_language()
|
||||
|
||||
|
||||
_STAGE_MESSAGE_KEYS = {
|
||||
"prescreen": "task_stage_prescreen_text",
|
||||
"validate": "task_stage_validate_text",
|
||||
"ingest": "task_stage_ingest_text",
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
background_tasks_status: dict[str, dict] = {}
|
||||
_MAX_TASK_STATUS = 200
|
||||
|
||||
_TERMINAL_TASK_STATUSES = ('completed', 'failed', 'cancelled')
|
||||
|
||||
_running_tasks: set = set()
|
||||
|
||||
_task_handles: dict = {}
|
||||
|
||||
def spawn(coro, task_id: Optional[str] = None):
|
||||
task = asyncio.create_task(coro)
|
||||
_running_tasks.add(task)
|
||||
if task_id:
|
||||
_task_handles[task_id] = task
|
||||
|
||||
def _forget(finished):
|
||||
_running_tasks.discard(finished)
|
||||
if task_id and _task_handles.get(task_id) is finished:
|
||||
del _task_handles[task_id]
|
||||
|
||||
task.add_done_callback(_forget)
|
||||
return task
|
||||
|
||||
|
||||
async def cancel_task(task_id: str) -> bool:
|
||||
task = _task_handles.get(task_id)
|
||||
if task is None or task.done():
|
||||
return False
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
async def cancel_running_tasks() -> None:
|
||||
tasks = [task for task in _running_tasks if not task.done()]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info("已取消 %d 个在跑的后台任务", len(tasks))
|
||||
|
||||
_TASK_STATUS_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
def new_task_id() -> str:
|
||||
cleanup_task_status()
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def cleanup_task_status():
|
||||
now = datetime.now()
|
||||
|
||||
expired_keys = []
|
||||
for task_id, status_info in background_tasks_status.items():
|
||||
if status_info.get('status') not in _TERMINAL_TASK_STATUSES:
|
||||
continue
|
||||
completed_at_str = status_info.get('completed_at')
|
||||
if not completed_at_str:
|
||||
continue
|
||||
try:
|
||||
completed_at = datetime.fromisoformat(completed_at_str)
|
||||
if (now - completed_at).total_seconds() > _TASK_STATUS_TTL_SECONDS:
|
||||
expired_keys.append(task_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
for key in expired_keys:
|
||||
del background_tasks_status[key]
|
||||
|
||||
if len(background_tasks_status) > _MAX_TASK_STATUS:
|
||||
settled = [
|
||||
(k, v) for k, v in background_tasks_status.items()
|
||||
if v.get('status') in _TERMINAL_TASK_STATUSES
|
||||
]
|
||||
settled.sort(key=lambda item: item[1].get('completed_at', ''))
|
||||
for k, _ in settled[:len(settled) // 2]:
|
||||
del background_tasks_status[k]
|
||||
|
||||
|
||||
async def validate_and_save_proxies_task(
|
||||
task_id: str, plugin_name: str, unique_proxies: list[str]
|
||||
):
|
||||
total = len(unique_proxies)
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message(_STAGE_MESSAGE_KEYS["prescreen"]),
|
||||
"stage": "prescreen",
|
||||
"progress": 0,
|
||||
"total": total,
|
||||
"stage_done": 0,
|
||||
"stage_total": total,
|
||||
"rate": 0.0,
|
||||
"eta_seconds": None,
|
||||
"started_at": datetime.now().isoformat(),
|
||||
"valid_count": 0,
|
||||
"invalid_count": 0,
|
||||
"inconclusive_count": 0,
|
||||
"prescreened_out": 0,
|
||||
"incomplete_count": 0,
|
||||
"failed_count": 0,
|
||||
}
|
||||
|
||||
def _on_progress(snapshot: IngestProgress) -> None:
|
||||
status = background_tasks_status.get(task_id)
|
||||
if status is None:
|
||||
return
|
||||
stats = snapshot.stats
|
||||
status["stage"] = snapshot.stage
|
||||
stage_key = _STAGE_MESSAGE_KEYS.get(snapshot.stage)
|
||||
if stage_key:
|
||||
status["message"] = _message(stage_key)
|
||||
status["stage_done"] = snapshot.done
|
||||
status["stage_total"] = snapshot.total
|
||||
status["in_flight"] = snapshot.in_flight
|
||||
status["rate"] = round(snapshot.rate, 1)
|
||||
status["eta_seconds"] = (
|
||||
round(snapshot.eta_seconds) if snapshot.eta_seconds is not None else None
|
||||
)
|
||||
status["progress"] = (
|
||||
stats.prescreened_out + stats.saved + stats.invalid
|
||||
+ stats.incomplete + stats.inconclusive + stats.failed
|
||||
)
|
||||
status["valid_count"] = stats.saved
|
||||
status["invalid_count"] = stats.invalid
|
||||
status["inconclusive_count"] = stats.inconclusive
|
||||
status["prescreened_out"] = stats.prescreened_out
|
||||
status["incomplete_count"] = stats.incomplete
|
||||
status["failed_count"] = stats.failed
|
||||
|
||||
try:
|
||||
stats = await ingest_proxies(
|
||||
unique_proxies, source_plugin=plugin_name, on_progress=_on_progress
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
status = background_tasks_status.get(task_id, {})
|
||||
background_tasks_status[task_id] = {
|
||||
**status,
|
||||
"status": "cancelled",
|
||||
"message": _message('task_cancelled_with_counts', _counts_text(status)),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
logger.info("验证并保存代理任务被取消: %s", task_id)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"验证并保存代理任务失败: {e}", exc_info=True)
|
||||
background_tasks_status[task_id] = {
|
||||
**background_tasks_status.get(task_id, {}),
|
||||
"status": "failed",
|
||||
"message": _message('task_failed', str(e)),
|
||||
"total": total,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message(
|
||||
'task_plugin_run_done', plugin_name, stats.summary(_language())
|
||||
),
|
||||
"stage": "done",
|
||||
"progress": stats.total,
|
||||
"total": stats.total,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
"started_at": background_tasks_status.get(task_id, {}).get("started_at"),
|
||||
"valid_count": stats.saved,
|
||||
"invalid_count": stats.invalid,
|
||||
"inconclusive_count": stats.inconclusive,
|
||||
"prescreened_out": stats.prescreened_out,
|
||||
"incomplete_count": stats.incomplete,
|
||||
"failed_count": stats.failed,
|
||||
}
|
||||
|
||||
|
||||
def _counts_text(status: dict) -> str:
|
||||
return _message(
|
||||
'task_counts_saved_unusable',
|
||||
status.get('valid_count', 0), status.get('invalid_count', 0),
|
||||
)
|
||||
|
||||
|
||||
async def validate_selected_proxies_task(
|
||||
task_id: str, targets: list[tuple[int, str]], mode: str = "auto"
|
||||
):
|
||||
from core.application.api.dependencies import (
|
||||
get_geo_resolver, get_proxy_repository, get_proxy_validator, get_config_manager,
|
||||
get_plugin_manager,
|
||||
)
|
||||
from core.domain.models import ValidationMode
|
||||
from core.services.validation_policy import pick_validation_mode
|
||||
|
||||
try:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message('task_validating'),
|
||||
"progress": 0,
|
||||
"total": len(targets),
|
||||
}
|
||||
|
||||
repository = get_proxy_repository()
|
||||
validator = get_proxy_validator()
|
||||
geo_resolver = get_geo_resolver()
|
||||
config_manager = get_config_manager()
|
||||
|
||||
completed_count = 0
|
||||
valid_count = 0
|
||||
inconclusive_count = 0
|
||||
|
||||
async def validate_and_update(proxy_id: int, proxy_url: str) -> None:
|
||||
nonlocal completed_count, valid_count, inconclusive_count
|
||||
try:
|
||||
config = config_manager.get_config()
|
||||
proxy = await repository.get_by_id(proxy_id)
|
||||
validation_mode = (
|
||||
pick_validation_mode(proxy, config)
|
||||
if mode == "auto" else ValidationMode(mode)
|
||||
)
|
||||
test_url = get_plugin_manager().resolve_test_url(proxy.source_plugin)
|
||||
result = await validator.validate_proxy(
|
||||
proxy_url, validation_mode, test_url=test_url
|
||||
)
|
||||
|
||||
action, applied = await dispatch_validation_action(
|
||||
result, mode=validation_mode, proxy=proxy,
|
||||
geo_sink=geo_resolver.schedule,
|
||||
sink=lambda applied: repository.update(proxy_id, dict(applied.fields)),
|
||||
)
|
||||
if action != APPLY_APPLIED:
|
||||
inconclusive_count += 1
|
||||
return
|
||||
|
||||
if result.is_valid:
|
||||
valid_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"验证代理 {sanitize_proxy(proxy_url)} 失败: {e}")
|
||||
finally:
|
||||
completed_count += 1
|
||||
background_tasks_status[task_id]["progress"] = completed_count
|
||||
|
||||
tasks = [
|
||||
validate_and_update(proxy_id, proxy_url)
|
||||
for proxy_id, proxy_url in targets
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message(
|
||||
'task_validate_selected_done', valid_count, len(targets),
|
||||
) + (_message('task_inconclusive_suffix', inconclusive_count)
|
||||
if inconclusive_count else ""),
|
||||
"progress": len(targets),
|
||||
"total": len(targets),
|
||||
"inconclusive_count": inconclusive_count,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
except asyncio.CancelledError:
|
||||
status = background_tasks_status.get(task_id, {})
|
||||
background_tasks_status[task_id] = {
|
||||
**status,
|
||||
"status": "cancelled",
|
||||
"message": _message('task_cancelled_with_counts', _counts_text(status)),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
logger.info("验证选中代理的任务被取消: %s", task_id)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"批量验证任务失败: {e}", exc_info=True)
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "failed",
|
||||
"message": _message('task_failed', str(e)),
|
||||
"progress": 0,
|
||||
"total": len(targets),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def validate_all_proxies_task(task_id: str, is_valid_filter: bool):
|
||||
from core.application.api.dependencies import (
|
||||
get_proxy_repository, get_proxy_validator, get_plugin_manager,
|
||||
get_write_queue, get_geo_resolver, get_config_manager
|
||||
)
|
||||
from core.domain.models import ProxyFilter, DBOperation
|
||||
from core.services.health_scorer import HealthScorer
|
||||
from core.services.validation_policy import pick_validation_mode
|
||||
|
||||
try:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message('task_validating'),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
repository = get_proxy_repository()
|
||||
validator = get_proxy_validator()
|
||||
write_queue = get_write_queue()
|
||||
geo_resolver = get_geo_resolver()
|
||||
config_manager = get_config_manager()
|
||||
scorer = HealthScorer()
|
||||
|
||||
filter_obj = ProxyFilter(is_valid=is_valid_filter)
|
||||
|
||||
all_proxies = []
|
||||
page = 1
|
||||
page_size = 5000
|
||||
while True:
|
||||
batch = await repository.find(filter_obj, page=page, page_size=page_size)
|
||||
if not batch:
|
||||
break
|
||||
all_proxies.extend(batch)
|
||||
if len(batch) < page_size:
|
||||
break
|
||||
page += 1
|
||||
|
||||
proxies = all_proxies
|
||||
|
||||
if not proxies:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message('task_no_proxies_to_validate'),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
total = len(proxies)
|
||||
background_tasks_status[task_id]["total"] = total
|
||||
|
||||
completed_count = 0
|
||||
inconclusive_count = 0
|
||||
|
||||
async def validate_and_update(proxy) -> None:
|
||||
nonlocal completed_count, inconclusive_count
|
||||
|
||||
try:
|
||||
proxy_url = proxy.proxy_url
|
||||
config = config_manager.get_config()
|
||||
mode = pick_validation_mode(proxy, config)
|
||||
test_url = get_plugin_manager().resolve_test_url(proxy.source_plugin)
|
||||
|
||||
result = await validator.validate_proxy(proxy_url, mode, test_url=test_url)
|
||||
|
||||
action, applied = await dispatch_validation_action(
|
||||
result, mode=mode, proxy=proxy, scorer=scorer,
|
||||
geo_sink=geo_resolver.schedule,
|
||||
sink=lambda applied: write_queue.enqueue(
|
||||
build_update_operation(proxy, applied)),
|
||||
)
|
||||
if action != APPLY_APPLIED:
|
||||
inconclusive_count += 1
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"验证代理 {proxy.ip}:{proxy.port} 失败: {e}")
|
||||
finally:
|
||||
completed_count += 1
|
||||
background_tasks_status[task_id]["progress"] = completed_count
|
||||
|
||||
tasks = [validate_and_update(proxy) for proxy in proxies]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message('task_validate_all_done', total)
|
||||
+ (_message('task_inconclusive_among_suffix', inconclusive_count)
|
||||
if inconclusive_count else ""),
|
||||
"progress": total,
|
||||
"total": total,
|
||||
"inconclusive_count": inconclusive_count,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
except asyncio.CancelledError:
|
||||
status = background_tasks_status.get(task_id, {})
|
||||
background_tasks_status[task_id] = {
|
||||
**status,
|
||||
"status": "cancelled",
|
||||
"message": _message('task_cancelled_with_counts', _counts_text(status)),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
logger.info("批量验证任务被取消: %s", task_id)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"批量验证任务失败: {e}", exc_info=True)
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "failed",
|
||||
"message": _message('task_validate_failed', str(e)),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def update_all_geo_task(task_id: str):
|
||||
from core.application.api.dependencies import (
|
||||
get_geo_resolver, get_proxy_repository, get_write_queue, get_proxy_validator,
|
||||
get_plugin_manager,
|
||||
)
|
||||
from core.domain.models import DBOperation, ProxyFilter
|
||||
from core.services.health_scorer import HealthScorer
|
||||
|
||||
try:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message('task_checking'),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
repository = get_proxy_repository()
|
||||
write_queue = get_write_queue()
|
||||
geo_resolver = get_geo_resolver()
|
||||
validator = get_proxy_validator()
|
||||
scorer = HealthScorer()
|
||||
|
||||
proxies = []
|
||||
page = 1
|
||||
page_size = 5000
|
||||
while True:
|
||||
batch = await repository.find(ProxyFilter(), page=page, page_size=page_size)
|
||||
if not batch:
|
||||
break
|
||||
proxies.extend(batch)
|
||||
if len(batch) < page_size:
|
||||
break
|
||||
page += 1
|
||||
|
||||
if not proxies:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message('task_pool_empty'),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
total = len(proxies)
|
||||
background_tasks_status[task_id]["total"] = total
|
||||
inconclusive_count = 0
|
||||
completed_count = 0
|
||||
|
||||
async def validate_and_update(proxy) -> None:
|
||||
nonlocal completed_count, inconclusive_count
|
||||
|
||||
from core.domain.models import ValidationMode
|
||||
try:
|
||||
test_url = get_plugin_manager().resolve_test_url(proxy.source_plugin)
|
||||
result = await validator.validate_proxy(
|
||||
proxy.proxy_url, ValidationMode.FULL, test_url=test_url
|
||||
)
|
||||
|
||||
action, applied = await dispatch_validation_action(
|
||||
result, mode=ValidationMode.FULL, proxy=proxy, scorer=scorer,
|
||||
geo_sink=geo_resolver.schedule,
|
||||
sink=lambda applied: write_queue.enqueue(
|
||||
build_update_operation(proxy, applied)),
|
||||
)
|
||||
if action != APPLY_APPLIED:
|
||||
inconclusive_count += 1
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"检测代理 {proxy.ip}:{proxy.port} 失败: {e}")
|
||||
finally:
|
||||
completed_count += 1
|
||||
background_tasks_status[task_id]["progress"] = completed_count
|
||||
|
||||
tasks = [validate_and_update(proxy) for proxy in proxies]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message('task_check_done', total)
|
||||
+ (_message('task_inconclusive_among_suffix', inconclusive_count)
|
||||
if inconclusive_count else ""),
|
||||
"progress": total,
|
||||
"total": total,
|
||||
"inconclusive_count": inconclusive_count,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
except asyncio.CancelledError:
|
||||
status = background_tasks_status.get(task_id, {})
|
||||
background_tasks_status[task_id] = {
|
||||
**status,
|
||||
"status": "cancelled",
|
||||
"message": _message('task_cancelled_with_counts', _counts_text(status)),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
logger.info("代理检测任务被取消: %s", task_id)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"代理检测任务失败: {e}", exc_info=True)
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "failed",
|
||||
"message": _message('task_check_failed', str(e)),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def run_plugin_task(task_id: str, plugin_name: str):
|
||||
from core.application.api.dependencies import (
|
||||
get_plugin_manager, get_config_manager,
|
||||
)
|
||||
|
||||
manager = get_plugin_manager()
|
||||
try:
|
||||
fetch_timeout = get_config_manager().get_config().plugins.execution_timeout_seconds
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message(
|
||||
'task_plugin_fetching', plugin_name, max(1, fetch_timeout // 60)
|
||||
),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"started_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
proxies = await manager.execute_plugin(plugin_name)
|
||||
|
||||
if not proxies:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message('task_plugin_no_new_proxies', plugin_name),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
unique_proxies = await manager.deduplicate_proxies(proxies)
|
||||
|
||||
await validate_and_save_proxies_task(task_id, plugin_name, unique_proxies)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
status = background_tasks_status.get(task_id, {})
|
||||
background_tasks_status[task_id] = {
|
||||
**status,
|
||||
"status": "cancelled",
|
||||
"message": _message(
|
||||
'task_plugin_fetch_cancelled', plugin_name, _counts_text(status)
|
||||
),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"插件抓取任务失败 {plugin_name}: {e}", exc_info=True)
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "failed",
|
||||
"message": _message('task_plugin_failed', plugin_name, e),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def update_geo_databases_task(task_id: str, live=None) -> None:
|
||||
from core.infrastructure.geodb import update_databases
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message('task_geo_downloading'),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
def report(name: str, done: int, total: int) -> None:
|
||||
def apply() -> None:
|
||||
status = background_tasks_status.get(task_id)
|
||||
if status is None:
|
||||
return
|
||||
pct = int(done * 100 / total) if total else 0
|
||||
status["message"] = _message('task_geo_downloading_file', name, pct)
|
||||
status["progress"] = done
|
||||
status["total"] = total or 0
|
||||
|
||||
loop.call_soon_threadsafe(apply)
|
||||
|
||||
try:
|
||||
results = await asyncio.to_thread(update_databases, None, progress=report, live=live)
|
||||
except asyncio.CancelledError:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "cancelled",
|
||||
"message": _message('task_geo_update_cancelled'),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"更新离线归属地库失败: {e}", exc_info=True)
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "failed",
|
||||
"message": _message('task_geo_update_failed', e),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
failed = [name for name, outcome in results.items() if outcome.startswith("失败")]
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": (
|
||||
_message('task_geo_update_done')
|
||||
if not failed
|
||||
else _message('task_geo_update_partial', '、'.join(failed))
|
||||
),
|
||||
"results": results,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
_RECOMPUTE_POLL_SECONDS = 1.0
|
||||
|
||||
|
||||
async def recompute_geo_task(task_id: str) -> None:
|
||||
from core.application.api.dependencies import get_geo_resolver, get_proxy_repository
|
||||
|
||||
resolver = get_geo_resolver()
|
||||
if resolver is None:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "failed",
|
||||
"message": _message('task_geo_recompute_disabled'),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message('task_geo_picking'),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
repository = get_proxy_repository()
|
||||
ips = await repository.get_ips_missing_geo_codes()
|
||||
if not ips:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message('task_geo_nothing_to_recompute'),
|
||||
"progress": 0,
|
||||
"total": 0,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
accepted = sum(1 for ip in ips if resolver.schedule(ip))
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "running",
|
||||
"message": _message('task_geo_queued', accepted),
|
||||
"progress": 0,
|
||||
"total": accepted,
|
||||
}
|
||||
|
||||
while resolver.stats().get("pending", 0) > 0:
|
||||
await asyncio.sleep(_RECOMPUTE_POLL_SECONDS)
|
||||
snapshot = resolver.stats()
|
||||
done = max(0, accepted - snapshot.get("pending", 0))
|
||||
background_tasks_status[task_id]["progress"] = done
|
||||
background_tasks_status[task_id]["message"] = _message(
|
||||
'task_geo_resolving', done, accepted
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "cancelled",
|
||||
"message": _message('task_geo_recompute_cancelled'),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"重算归属地失败: {e}", exc_info=True)
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "failed",
|
||||
"message": _message('task_geo_recompute_failed', e),
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
return
|
||||
|
||||
background_tasks_status[task_id] = {
|
||||
"status": "completed",
|
||||
"message": _message('task_geo_recompute_done', accepted),
|
||||
"progress": accepted,
|
||||
"total": accepted,
|
||||
"completed_at": datetime.now().isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.application.ingest
|
||||
功能描述:代理入库流水线:URL 解析 → TCP 初筛 → FULL 验证 → 只写可用记录,并把出口 IP 交给归属地调度。
|
||||
职责边界:负责:解析、初筛、逐条 FULL 验证、写库、跳过验证的直接入库、分阶段进度上报;不负责:抓取(plugin_manager)、
|
||||
验证算法(validator)、归属地查询与写回(geo_resolver)、任务状态与前端展示(batch_tasks)、去重(调用方)。
|
||||
关键依赖:core.application.api.dependencies(函数内延迟导入)、core.domain.models、
|
||||
core.services.validation_apply、core.services.health_scorer、modules.modules。
|
||||
已知限制:
|
||||
1. 入库判据为本轮确认可用;解析失败、初筛淘汰、不可用与无结论均不写库;活下来但拿不到出口 IP 时,可归因于代理的不入库,不可归因(我方回显端点不可达)的仍入库待重验证。
|
||||
2. skip_validation 插件直接入库且跳过初筛;upsert 命中已有 ip:port 时不覆盖出口 IP、延迟与匿名度,但会覆盖验证状态并使成功/失败/总检测计数归零。
|
||||
3. 初筛只回答端口是否可连,黑洞代理能过关;初筛并发按验证并发 ×4 推导并在 [64, 2000] 截断,超时取 connect_timeout_seconds,非正数退化为 2 秒。
|
||||
4. 进度为当前阶段口径:done/total 仅描述本阶段;速率取 20 秒窗口,跨度不足 3 秒或无完成样本时报 0;回调按 0.25 秒节流且异常被吞。
|
||||
5. 本模块不做去重,调用方负责;归属地按 real_ip 调度,不依赖刚插入行的 id。
|
||||
6. 验证链路的意外异常计入 failed 并继续处理其余候选;每条候选恰好落一个分支,计数须闭合。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional, Sequence
|
||||
|
||||
from core.domain.models import DBOperation, ProbeKind, Proxy, ValidationMode
|
||||
from modules.modules import sanitize_proxy
|
||||
from core.services.validation_apply import (
|
||||
APPLY_INCONCLUSIVE,
|
||||
decide_validation_action,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STAGE_PRESCREEN = "prescreen"
|
||||
STAGE_VALIDATE = "validate"
|
||||
STAGE_INGEST = "ingest"
|
||||
|
||||
_PRESCREEN_CONCURRENCY_FACTOR = 4
|
||||
|
||||
_PRESCREEN_MIN_CONCURRENCY = 64
|
||||
|
||||
_PRESCREEN_MAX_CONCURRENCY = 2000
|
||||
|
||||
_PRESCREEN_DEFAULT_TIMEOUT = 2.0
|
||||
|
||||
_PROGRESS_INTERVAL_SECONDS = 0.25
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngestStats:
|
||||
total: int = 0
|
||||
prescreened_out: int = 0
|
||||
validated: int = 0
|
||||
saved: int = 0
|
||||
invalid: int = 0
|
||||
incomplete: int = 0
|
||||
inconclusive: int = 0
|
||||
failed: int = 0
|
||||
|
||||
def summary(self, language: str = "cn") -> str:
|
||||
from modules.modules import get_message
|
||||
|
||||
parts = [get_message('ingest_summary_candidates', language, self.total)]
|
||||
if self.prescreened_out:
|
||||
parts.append(get_message(
|
||||
'ingest_summary_prescreened_out', language, self.prescreened_out))
|
||||
parts.append(get_message('ingest_summary_validated', language, self.validated))
|
||||
parts.append(get_message('ingest_summary_saved', language, self.saved))
|
||||
if self.invalid:
|
||||
parts.append(get_message('ingest_summary_invalid', language, self.invalid))
|
||||
if self.incomplete:
|
||||
parts.append(get_message(
|
||||
'ingest_summary_incomplete', language, self.incomplete))
|
||||
if self.inconclusive:
|
||||
parts.append(get_message(
|
||||
'ingest_summary_inconclusive', language, self.inconclusive))
|
||||
if self.failed:
|
||||
parts.append(get_message('ingest_summary_failed', language, self.failed))
|
||||
return get_message('list_separator', language).join(parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngestProgress:
|
||||
stage: str
|
||||
done: int
|
||||
total: int
|
||||
rate: float
|
||||
eta_seconds: Optional[float]
|
||||
in_flight: int
|
||||
stats: IngestStats
|
||||
|
||||
|
||||
ProgressCallback = Callable[[IngestProgress], None]
|
||||
|
||||
|
||||
class _RateMeter:
|
||||
WINDOW = 20.0
|
||||
|
||||
MIN_SPAN = 3.0
|
||||
|
||||
def __init__(self):
|
||||
self._samples: list[tuple[float, int]] = []
|
||||
self._warmup = 1
|
||||
|
||||
def set_warmup(self, completions: int) -> None:
|
||||
self._warmup = max(1, completions)
|
||||
|
||||
def mark(self, done: int, now: float) -> None:
|
||||
self._samples.append((now, done))
|
||||
cutoff = now - self.WINDOW
|
||||
while len(self._samples) > 2 and self._samples[0][0] < cutoff:
|
||||
self._samples.pop(0)
|
||||
|
||||
def rate(self, now: float) -> float:
|
||||
if len(self._samples) < 2:
|
||||
return 0.0
|
||||
if self._samples[-1][1] < self._warmup:
|
||||
return 0.0
|
||||
first_at, first_done = self._samples[0]
|
||||
elapsed = now - first_at
|
||||
if elapsed < self.MIN_SPAN:
|
||||
return 0.0
|
||||
return max(0.0, (self._samples[-1][1] - first_done) / elapsed)
|
||||
|
||||
def eta(self, done: int, total: int, now: float) -> Optional[float]:
|
||||
rate = self.rate(now)
|
||||
if rate <= 0:
|
||||
return None
|
||||
return max(0.0, (total - done) / rate)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._samples.clear()
|
||||
|
||||
|
||||
async def ingest_proxies(
|
||||
proxies: Sequence[str],
|
||||
*,
|
||||
source_plugin: str,
|
||||
on_progress: Optional[ProgressCallback] = None,
|
||||
) -> IngestStats:
|
||||
from core.application.api.dependencies import (
|
||||
get_config_manager,
|
||||
get_geo_resolver,
|
||||
get_plugin_manager,
|
||||
get_proxy_validator,
|
||||
get_write_queue,
|
||||
)
|
||||
|
||||
stats = IngestStats(total=len(proxies))
|
||||
if not proxies:
|
||||
return stats
|
||||
|
||||
plugin_manager = get_plugin_manager()
|
||||
|
||||
candidates: list[Proxy] = []
|
||||
for proxy_url in proxies:
|
||||
try:
|
||||
candidates.append(Proxy.from_url(proxy_url, source_plugin))
|
||||
except Exception as e:
|
||||
logger.debug("跳过无法解析的代理 %s: %s", sanitize_proxy(proxy_url), e)
|
||||
stats.failed += 1
|
||||
|
||||
if plugin_manager.should_skip_validation(source_plugin):
|
||||
return await _ingest_without_validation(
|
||||
candidates, source_plugin, stats, on_progress
|
||||
)
|
||||
|
||||
validator = get_proxy_validator()
|
||||
write_queue = get_write_queue()
|
||||
geo_resolver = get_geo_resolver()
|
||||
test_url = plugin_manager.resolve_test_url(source_plugin)
|
||||
|
||||
config = get_config_manager().get_config()
|
||||
prescreen_concurrency = min(
|
||||
_PRESCREEN_MAX_CONCURRENCY,
|
||||
max(
|
||||
_PRESCREEN_MIN_CONCURRENCY,
|
||||
int(config.validator.max_concurrent) * _PRESCREEN_CONCURRENCY_FACTOR,
|
||||
),
|
||||
)
|
||||
prescreen_timeout = float(config.validator.connect_timeout_seconds)
|
||||
if prescreen_timeout <= 0:
|
||||
prescreen_timeout = _PRESCREEN_DEFAULT_TIMEOUT
|
||||
|
||||
reporter = _ProgressReporter(on_progress)
|
||||
|
||||
reporter.start_stage(STAGE_PRESCREEN, len(candidates), prescreen_concurrency, stats)
|
||||
prescreen_sem = asyncio.Semaphore(prescreen_concurrency)
|
||||
survivors: list[Proxy] = []
|
||||
|
||||
async def prescreen(proxy: Proxy) -> None:
|
||||
async with prescreen_sem:
|
||||
if await _tcp_reachable(proxy.ip, proxy.port, prescreen_timeout):
|
||||
survivors.append(proxy)
|
||||
else:
|
||||
stats.prescreened_out += 1
|
||||
reporter.tick(stats)
|
||||
|
||||
if candidates:
|
||||
await asyncio.gather(*(prescreen(p) for p in candidates), return_exceptions=True)
|
||||
|
||||
reporter.finish_stage(stats)
|
||||
logger.info(
|
||||
"初筛完成: %d 个候选 -> %d 个端口可达(并发 %d,超时 %.1fs)",
|
||||
len(candidates), len(survivors), prescreen_concurrency, prescreen_timeout,
|
||||
)
|
||||
|
||||
stats.validated = len(survivors)
|
||||
validate_concurrency = max(1, int(config.validator.max_concurrent))
|
||||
reporter.start_stage(
|
||||
STAGE_VALIDATE, len(survivors), validate_concurrency, stats
|
||||
)
|
||||
|
||||
async def validate_one(proxy: Proxy) -> None:
|
||||
try:
|
||||
result = await validator.validate_proxy(
|
||||
proxy.proxy_url, ValidationMode.FULL, test_url=test_url
|
||||
)
|
||||
|
||||
action, applied = decide_validation_action(
|
||||
result, mode=ValidationMode.FULL,
|
||||
skip=_skip_reason_for_new_record,
|
||||
)
|
||||
if action == APPLY_INCONCLUSIVE:
|
||||
stats.inconclusive += 1
|
||||
return
|
||||
if action == SKIP_INVALID:
|
||||
stats.invalid += 1
|
||||
return
|
||||
if action == SKIP_INCOMPLETE:
|
||||
stats.incomplete += 1
|
||||
logger.debug(
|
||||
"代理 %s:%s 通过存活检测但拿不到出口 IP,不入库",
|
||||
proxy.ip, proxy.port,
|
||||
)
|
||||
return
|
||||
|
||||
proxy_data = {
|
||||
'protocol': proxy.protocol,
|
||||
'ip': proxy.ip,
|
||||
'port': proxy.port,
|
||||
'username': proxy.username,
|
||||
'password': proxy.password,
|
||||
'source_plugin': source_plugin,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
}
|
||||
proxy_data.update(applied.fields)
|
||||
|
||||
await write_queue.enqueue(DBOperation(
|
||||
operation_type="insert",
|
||||
table="proxies",
|
||||
data=proxy_data,
|
||||
))
|
||||
stats.saved += 1
|
||||
|
||||
if result.real_ip and geo_resolver is not None:
|
||||
geo_resolver.schedule(result.real_ip)
|
||||
except Exception as e:
|
||||
logger.warning("处理代理 %s:%s 失败: %s", proxy.ip, proxy.port, e)
|
||||
stats.failed += 1
|
||||
finally:
|
||||
reporter.tick(stats)
|
||||
|
||||
if survivors:
|
||||
await asyncio.gather(*(validate_one(p) for p in survivors))
|
||||
|
||||
reporter.finish_stage(stats)
|
||||
logger.info("入库完成(来源 %s): %s", source_plugin, stats.summary())
|
||||
return stats
|
||||
|
||||
|
||||
class _ProgressReporter:
|
||||
def __init__(self, callback: Optional[ProgressCallback]):
|
||||
self._callback = callback
|
||||
self._stage = ""
|
||||
self._stage_total = 0
|
||||
self._concurrency = 1
|
||||
self._done = 0
|
||||
self._last_emit = 0.0
|
||||
self._meter = _RateMeter()
|
||||
|
||||
def start_stage(
|
||||
self, stage: str, total: int, concurrency: int, stats: IngestStats
|
||||
) -> None:
|
||||
self._stage = stage
|
||||
self._stage_total = total
|
||||
self._concurrency = max(1, concurrency)
|
||||
self._done = 0
|
||||
self._meter.reset()
|
||||
self._meter.set_warmup(min(self._concurrency, total))
|
||||
self._meter.mark(0, time.monotonic())
|
||||
self._emit(stats, force=True)
|
||||
|
||||
def tick(self, stats: IngestStats) -> None:
|
||||
self._done += 1
|
||||
self._meter.mark(self._done, time.monotonic())
|
||||
self._emit(stats)
|
||||
|
||||
def finish_stage(self, stats: IngestStats) -> None:
|
||||
self._emit(stats, force=True)
|
||||
|
||||
def _emit(self, stats: IngestStats, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
if not force and now - self._last_emit < _PROGRESS_INTERVAL_SECONDS:
|
||||
return
|
||||
self._last_emit = now
|
||||
|
||||
if self._callback is None:
|
||||
return
|
||||
try:
|
||||
self._callback(IngestProgress(
|
||||
stage=self._stage,
|
||||
done=self._done,
|
||||
total=self._stage_total,
|
||||
rate=self._meter.rate(now),
|
||||
eta_seconds=self._meter.eta(self._done, self._stage_total, now),
|
||||
in_flight=min(
|
||||
max(0, self._stage_total - self._done), self._concurrency
|
||||
),
|
||||
stats=stats,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.debug("入库进度回调异常(忽略): %s", e)
|
||||
|
||||
|
||||
async def _ingest_without_validation(
|
||||
candidates: Sequence[Proxy],
|
||||
source_plugin: str,
|
||||
stats: IngestStats,
|
||||
on_progress: Optional[ProgressCallback] = None,
|
||||
) -> IngestStats:
|
||||
from core.application.api.dependencies import get_write_queue
|
||||
from core.services.health_scorer import HealthScorer
|
||||
|
||||
reporter = _ProgressReporter(on_progress)
|
||||
reporter.start_stage(STAGE_INGEST, len(candidates), 1, stats)
|
||||
|
||||
if not candidates:
|
||||
reporter.finish_stage(stats)
|
||||
return stats
|
||||
|
||||
write_queue = get_write_queue()
|
||||
scorer = HealthScorer()
|
||||
now = datetime.now()
|
||||
|
||||
for proxy in candidates:
|
||||
try:
|
||||
proxy_data = {
|
||||
'protocol': proxy.protocol,
|
||||
'ip': proxy.ip,
|
||||
'port': proxy.port,
|
||||
'username': proxy.username,
|
||||
'password': proxy.password,
|
||||
'source_plugin': source_plugin,
|
||||
'created_at': now.isoformat(),
|
||||
'is_valid': 1,
|
||||
'validated_at': now.isoformat(),
|
||||
'total_checks': 0,
|
||||
'success_count': 0,
|
||||
'failure_count': 0,
|
||||
'anonymity_level': 'unverified',
|
||||
'health_score': scorer.calculate(proxy),
|
||||
}
|
||||
await write_queue.enqueue(DBOperation(
|
||||
operation_type="insert",
|
||||
table="proxies",
|
||||
data=proxy_data,
|
||||
))
|
||||
stats.saved += 1
|
||||
except Exception as e:
|
||||
logger.warning("直接入库代理 %s:%s 失败: %s", proxy.ip, proxy.port, e)
|
||||
stats.failed += 1
|
||||
finally:
|
||||
reporter.tick(stats)
|
||||
|
||||
reporter.finish_stage(stats)
|
||||
logger.info("直接入库完成(来源 %s,未做任何验证): %s", source_plugin, stats.summary())
|
||||
return stats
|
||||
|
||||
|
||||
SKIP_INVALID = "invalid"
|
||||
SKIP_INCOMPLETE = "incomplete"
|
||||
|
||||
|
||||
def _skip_reason_for_new_record(result) -> str:
|
||||
if not result.is_valid:
|
||||
return SKIP_INVALID
|
||||
if not result.real_ip and _identity_attributable(result):
|
||||
return SKIP_INCOMPLETE
|
||||
return ""
|
||||
|
||||
def _identity_attributable(result) -> bool:
|
||||
return any(
|
||||
probe.kind is ProbeKind.TUNNEL and probe.ok is not None
|
||||
for probe in result.probes
|
||||
)
|
||||
|
||||
|
||||
async def _tcp_reachable(ip: str, port: int, timeout: float) -> bool:
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(ip, port), timeout=timeout
|
||||
)
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("初筛异常 %s:%s: %s", ip, port, e)
|
||||
return False
|
||||
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.backup
|
||||
功能描述:SQLite 数据库的定时备份、过期备份清理与从备份文件恢复;备份文件位于池根目录 data/backups/ 下,按保留天数自动淘汰。
|
||||
职责边界:负责:按间隔创建一致性备份、清理超期备份、列出备份、从备份文件恢复数据库;不负责:数据库连接管理、备份间隔与保留天数的来源(见 core.config)。
|
||||
关键依赖:核心模块 core.paths.resolve_pool_path、第三方 aiosqlite;标准库 asyncio、sqlite3、shutil、os。
|
||||
已知限制:
|
||||
1. apply_config 只改内存中的间隔与保留天数,下一次循环复查时生效,不会立刻补做备份;stop() 不回滚已开始的单次备份。
|
||||
2. restore 只返回布尔值,失败原因与分类码通过 last_restore_error、last_restore_error_code 读取。
|
||||
3. 换库前必须删净原库的 -wal 与 -shm 边车,主路径删不掉则以 wal_locked 放弃恢复;回滚路径为尽力而为,不因边车删除失败中止:旧 WAL 帧会污染新库。
|
||||
4. restore_backup_file 只接受备份目录下的纯文件名,含路径分隔符的名称被拒绝。
|
||||
5. _is_backup_due 在无备份文件或目录读取失败时返回 True(宁可多备份),循环异常后等待 300 秒重试。
|
||||
6. db_path 与 backup_dir 按池根目录解析;备份目录在构造时创建,创建失败抛 OSError。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.paths import resolve_pool_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DUE_CHECK_INTERVAL = 60.0
|
||||
|
||||
|
||||
class DatabaseBackup:
|
||||
def __init__(
|
||||
self,
|
||||
db_path: str,
|
||||
backup_dir: str = "data/backups",
|
||||
backup_interval_hours: int = 24,
|
||||
retention_days: int = 7
|
||||
):
|
||||
self._db_path = resolve_pool_path(db_path)
|
||||
self._backup_dir = resolve_pool_path(backup_dir)
|
||||
self._backup_interval_hours = backup_interval_hours
|
||||
self._retention_days = retention_days
|
||||
self._backup_task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._last_restore_error: Optional[str] = None
|
||||
self._last_restore_error_code: str = ""
|
||||
|
||||
self._backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(
|
||||
f"数据库备份管理器已初始化: db={db_path}, "
|
||||
f"backup_dir={backup_dir}, interval={backup_interval_hours}h, "
|
||||
f"retention={retention_days}d"
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
logger.warning("数据库备份任务已经在运行")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._backup_task = asyncio.create_task(self._auto_backup_loop())
|
||||
logger.info("数据库自动备份任务已启动")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
|
||||
if self._backup_task:
|
||||
self._backup_task.cancel()
|
||||
try:
|
||||
await self._backup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("数据库自动备份任务已停止")
|
||||
|
||||
async def _auto_backup_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
if not self._is_backup_due():
|
||||
await asyncio.sleep(_DUE_CHECK_INTERVAL)
|
||||
continue
|
||||
|
||||
await self.backup()
|
||||
|
||||
await self.cleanup_old_backups()
|
||||
|
||||
await asyncio.sleep(self._backup_interval_hours * 3600)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("自动备份任务被取消")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"自动备份任务错误: {e}", exc_info=True)
|
||||
await asyncio.sleep(300)
|
||||
|
||||
def _is_backup_due(self) -> bool:
|
||||
try:
|
||||
latest_mtime = max(
|
||||
(
|
||||
f.stat().st_mtime
|
||||
for f in self._backup_dir.glob("proxies_backup_*.db")
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
if latest_mtime is None:
|
||||
return True
|
||||
|
||||
return time.time() - latest_mtime >= self._backup_interval_hours * 3600
|
||||
|
||||
async def backup(self) -> Optional[Path]:
|
||||
if not self._db_path.exists():
|
||||
logger.error(f"数据库文件不存在: {self._db_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_filename = f"proxies_backup_{timestamp}.db"
|
||||
backup_path = self._backup_dir / backup_filename
|
||||
|
||||
def _sqlite_backup():
|
||||
import sqlite3
|
||||
src = sqlite3.connect(str(self._db_path))
|
||||
try:
|
||||
dst = sqlite3.connect(str(backup_path))
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
finally:
|
||||
src.close()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _sqlite_backup)
|
||||
|
||||
if not backup_path.exists():
|
||||
logger.error(f"备份文件创建失败: {backup_path}")
|
||||
return None
|
||||
|
||||
backup_size = backup_path.stat().st_size
|
||||
logger.info(
|
||||
f"数据库备份成功: {backup_path} "
|
||||
f"(大小: {backup_size / 1024:.2f} KB)"
|
||||
)
|
||||
|
||||
return backup_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库备份失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def cleanup_old_backups(self) -> int:
|
||||
try:
|
||||
cutoff_time = datetime.now() - timedelta(days=self._retention_days)
|
||||
|
||||
backup_files = list(self._backup_dir.glob("proxies_backup_*.db"))
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for backup_file in backup_files:
|
||||
try:
|
||||
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
|
||||
if file_mtime < cutoff_time:
|
||||
backup_file.unlink()
|
||||
deleted_count += 1
|
||||
logger.info(f"已删除过期备份: {backup_file}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"删除备份文件失败 {backup_file}: {e}")
|
||||
continue
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"清理完成,删除了 {deleted_count} 个过期备份")
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"清理过期备份失败: {e}", exc_info=True)
|
||||
return 0
|
||||
|
||||
def list_backups(self) -> list[dict]:
|
||||
try:
|
||||
backup_files = list(self._backup_dir.glob("proxies_backup_*.db"))
|
||||
|
||||
backups = []
|
||||
for backup_file in backup_files:
|
||||
try:
|
||||
stat = backup_file.stat()
|
||||
backups.append({
|
||||
'path': str(backup_file),
|
||||
'filename': backup_file.name,
|
||||
'size': stat.st_size,
|
||||
'created_at': datetime.fromtimestamp(stat.st_mtime)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"获取备份文件信息失败 {backup_file}: {e}")
|
||||
continue
|
||||
|
||||
backups.sort(key=lambda x: x['created_at'], reverse=True)
|
||||
|
||||
return backups
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"列出备份文件失败: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def apply_config(self, config) -> None:
|
||||
old_interval = self._backup_interval_hours
|
||||
old_retention = self._retention_days
|
||||
|
||||
self._backup_interval_hours = config.database.backup_interval_hours
|
||||
self._retention_days = config.database.backup_retention_days
|
||||
|
||||
logger.info(
|
||||
f"备份管理器配置已更新: "
|
||||
f"interval={old_interval}->{self._backup_interval_hours}h, "
|
||||
f"retention={old_retention}->{self._retention_days}d"
|
||||
)
|
||||
|
||||
async def restore_backup_file(self, filename: str) -> bool:
|
||||
self._last_restore_error = None
|
||||
self._last_restore_error_code = ""
|
||||
name = str(filename).strip()
|
||||
if (
|
||||
not name
|
||||
or not name.endswith(".db")
|
||||
or Path(name).name != name
|
||||
or "/" in name
|
||||
or "\\" in name
|
||||
):
|
||||
logger.warning("拒绝非法的备份文件名: %r", filename)
|
||||
return False
|
||||
|
||||
backup_path = self._backup_dir / name
|
||||
if not backup_path.is_file():
|
||||
logger.warning("备份文件不存在: %s", backup_path)
|
||||
return False
|
||||
|
||||
return await self.restore(str(backup_path))
|
||||
|
||||
def _drop_wal_sidecars(self) -> bool:
|
||||
remaining: list[str] = []
|
||||
for suffix in ('-wal', '-shm'):
|
||||
sidecar = Path(str(self._db_path) + suffix)
|
||||
try:
|
||||
sidecar.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.warning("删除 %s 失败: %s", sidecar, e)
|
||||
remaining.append(sidecar.name)
|
||||
return not remaining
|
||||
|
||||
@property
|
||||
def last_restore_error(self) -> Optional[str]:
|
||||
return self._last_restore_error or ""
|
||||
|
||||
@property
|
||||
def last_restore_error_code(self) -> str:
|
||||
return self._last_restore_error_code
|
||||
|
||||
async def restore(self, backup_path: str) -> bool:
|
||||
self._last_restore_error = None
|
||||
self._last_restore_error_code = ""
|
||||
backup_file = Path(backup_path)
|
||||
staged = self._db_path.with_suffix('.db.restoring')
|
||||
temp_backup: Optional[Path] = None
|
||||
|
||||
def fail(reason: str, code: str) -> bool:
|
||||
self._last_restore_error = reason
|
||||
self._last_restore_error_code = code
|
||||
logger.error(reason)
|
||||
return False
|
||||
|
||||
if not backup_file.exists():
|
||||
return fail(f"备份文件不存在: {backup_path}", "invalid_backup")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
if not await self._verify_database(backup_file):
|
||||
return fail(
|
||||
f"备份文件不是有效的 SQLite 数据库: {backup_path}",
|
||||
"invalid_backup",
|
||||
)
|
||||
|
||||
if self._db_path.exists():
|
||||
temp_backup = self._db_path.with_suffix('.db.temp')
|
||||
await loop.run_in_executor(
|
||||
None, shutil.copy2, str(self._db_path), str(temp_backup)
|
||||
)
|
||||
logger.info(f"已创建临时备份: {temp_backup}")
|
||||
|
||||
await loop.run_in_executor(
|
||||
None, shutil.copy2, str(backup_file), str(staged)
|
||||
)
|
||||
if not self._drop_wal_sidecars():
|
||||
return fail(
|
||||
"无法删除原数据库的 WAL 边车文件(可能仍有进程持有数据库),"
|
||||
"带着旧 WAL 换库会损坏新库,已放弃恢复",
|
||||
"wal_locked",
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None, os.replace, str(staged), str(self._db_path)
|
||||
)
|
||||
|
||||
if not await self._verify_database(self._db_path):
|
||||
logger.error("恢复后的数据库验证失败,回滚到原数据库")
|
||||
|
||||
if temp_backup and temp_backup.exists():
|
||||
await loop.run_in_executor(
|
||||
None, shutil.copy2, str(temp_backup), str(staged)
|
||||
)
|
||||
self._drop_wal_sidecars()
|
||||
await loop.run_in_executor(
|
||||
None, os.replace, str(staged), str(self._db_path)
|
||||
)
|
||||
|
||||
return fail("恢复后的数据库验证失败,已回滚到原数据库", "verify_failed")
|
||||
|
||||
logger.info(f"数据库恢复成功: {backup_path} -> {self._db_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._last_restore_error = str(e)
|
||||
self._last_restore_error_code = "error"
|
||||
logger.error(f"数据库恢复失败: {e}", exc_info=True)
|
||||
return False
|
||||
finally:
|
||||
for leftover in (staged, temp_backup):
|
||||
if leftover is None:
|
||||
continue
|
||||
try:
|
||||
leftover.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.warning("清理临时文件 %s 失败: %s", leftover, e)
|
||||
|
||||
async def _verify_database(self, db_path: Path) -> bool:
|
||||
try:
|
||||
import aiosqlite
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
async with db.execute("PRAGMA integrity_check") as cursor:
|
||||
result = await cursor.fetchone()
|
||||
if result and result[0] != 'ok':
|
||||
logger.error(f"数据库完整性检查失败: {result[0]}")
|
||||
return False
|
||||
|
||||
async with db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
) as cursor:
|
||||
tables = [row[0] for row in await cursor.fetchall()]
|
||||
|
||||
required_tables = ['proxies', 'plugin_configs']
|
||||
for table in required_tables:
|
||||
if table not in tables:
|
||||
logger.error(f"数据库缺少必需的表: {table}")
|
||||
return False
|
||||
|
||||
async with db.execute("SELECT COUNT(*) FROM proxies") as cursor:
|
||||
count = (await cursor.fetchone())[0]
|
||||
logger.info(f"数据库验证成功,包含 {count} 条代理记录")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库验证失败: {e}", exc_info=True)
|
||||
return False
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.config
|
||||
功能描述:代理池的配置数据模型:定义 Config 与各配置节 dataclass 及默认值,并把「节名 → 字段名」嵌套字典覆盖式解析为 Config 实例。
|
||||
职责边界:负责:配置模型定义、默认值与嵌套字典解析;不负责:配置文件读写与热重载(infrastructure.config_manager)、
|
||||
取值校验与类型转换(modules.pool_config_ini)。
|
||||
关键依赖:标准库 dataclasses、logging;不依赖内部模块。
|
||||
已知限制:
|
||||
1. 无类型转换与取值校验:值按原样写入,类型错误要到使用处才暴露;未知节名、非 dict 的节与节内未知字段均被静默忽略。
|
||||
2. 某节构造抛 TypeError/ValueError 时该节整体退回默认值并记 warning,其余节照常解析。
|
||||
3. 默认值即出厂契约:modules.pool_config_ini 读取本模块默认值渲染 config.ini 的 [Pool] 段,改默认值须同步出厂配置。
|
||||
4. database.path 与 logging.file_path 默认是相对路径,使用前须经 core.paths.resolve_pool_path 锚定,否则落到进程工作目录。
|
||||
5. validator 的列表字段(identity_echo_apis 等)按传入列表原样保存,元素类型与可用性由调用方保证。
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseConfig:
|
||||
path: str = "data/proxies.db"
|
||||
backup_enabled: bool = True
|
||||
backup_interval_hours: int = 24
|
||||
backup_retention_days: int = 7
|
||||
pool_max_readers: int = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidatorConfig:
|
||||
target_url: str = "https://www.baidu.com"
|
||||
check_anonymity: bool = True
|
||||
host_public_ip: str = ""
|
||||
timeout_seconds: int = 5
|
||||
connect_timeout_seconds: float = 2.0
|
||||
max_concurrent: int = 400
|
||||
identity_echo_apis: list[str] = field(default_factory=lambda: [
|
||||
"https://httpbin.org/anything",
|
||||
"https://httpbingo.org/anything",
|
||||
"https://eu.httpbin.org/anything",
|
||||
])
|
||||
plain_identity_echo_apis: list[str] = field(default_factory=lambda: [
|
||||
"http://postman-echo.com/get",
|
||||
])
|
||||
ip_check_apis: list[str] = field(default_factory=lambda: [
|
||||
"https://icanhazip.com",
|
||||
"https://checkip.amazonaws.com",
|
||||
"https://api.ip.sb/ip",
|
||||
"https://realip.cc/simple",
|
||||
"https://httpbin.org/ip",
|
||||
"https://api.ipify.org",
|
||||
"https://ipinfo.io/json",
|
||||
"https://ipwho.is/",
|
||||
"https://www.cloudflare.com/cdn-cgi/trace",
|
||||
])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginsConfig:
|
||||
scan_interval_seconds: int = 60
|
||||
default_interval_minutes: int = 60
|
||||
execution_timeout_seconds: int = 300
|
||||
request_timeout_seconds: int = 30
|
||||
max_parallel_plugins: int = 3
|
||||
max_retries_on_failure: int = 2
|
||||
retry_base_delay_seconds: float = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoRevalidationConfig:
|
||||
enabled: bool = True
|
||||
interval_minutes: int = 360
|
||||
only_valid_proxies: bool = True
|
||||
run_on_start: bool = True
|
||||
full_recheck_interval_minutes: int = 1440
|
||||
|
||||
|
||||
@dataclass
|
||||
class AutoCleanupConfig:
|
||||
enabled: bool = True
|
||||
max_failures: int = 3
|
||||
min_health_score: float = 10.0
|
||||
min_checks_before_cleanup: int = 3
|
||||
max_age_days_if_invalid: int = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class UseFeedbackConfig:
|
||||
enabled: bool = True
|
||||
invalid_threshold: int = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class WriteQueueConfig:
|
||||
batch_size: int = 100
|
||||
max_retries: int = 3
|
||||
max_queue_size: int = 10000
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingConfig:
|
||||
file_path: str = "logs/proxy_pool.log"
|
||||
max_bytes: int = 10485760
|
||||
backup_count: int = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
database: DatabaseConfig = field(default_factory=DatabaseConfig)
|
||||
validator: ValidatorConfig = field(default_factory=ValidatorConfig)
|
||||
plugins: PluginsConfig = field(default_factory=PluginsConfig)
|
||||
write_queue: WriteQueueConfig = field(default_factory=WriteQueueConfig)
|
||||
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
||||
auto_revalidation: AutoRevalidationConfig = field(default_factory=AutoRevalidationConfig)
|
||||
auto_cleanup: AutoCleanupConfig = field(default_factory=AutoCleanupConfig)
|
||||
use_feedback: UseFeedbackConfig = field(default_factory=UseFeedbackConfig)
|
||||
|
||||
|
||||
def build_config(data: dict[str, Any] | None = None) -> Config:
|
||||
config = Config()
|
||||
if not data:
|
||||
return config
|
||||
|
||||
for config_field in dataclasses.fields(Config):
|
||||
section_name = config_field.name
|
||||
section_data = data.get(section_name)
|
||||
if not isinstance(section_data, dict):
|
||||
continue
|
||||
|
||||
sub_cls = config_field.type
|
||||
if isinstance(sub_cls, str):
|
||||
sub_cls = type(getattr(config, section_name))
|
||||
|
||||
if not dataclasses.is_dataclass(sub_cls):
|
||||
continue
|
||||
|
||||
default_instance = getattr(config, section_name)
|
||||
sub_kwargs = {}
|
||||
for sub_field in dataclasses.fields(sub_cls):
|
||||
if sub_field.name in section_data:
|
||||
sub_kwargs[sub_field.name] = section_data[sub_field.name]
|
||||
else:
|
||||
sub_kwargs[sub_field.name] = getattr(default_instance, sub_field.name)
|
||||
|
||||
try:
|
||||
setattr(config, section_name, sub_cls(**sub_kwargs))
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning("配置节 %s 解析失败,使用默认值: %s", section_name, e)
|
||||
|
||||
return config
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.data
|
||||
功能描述:数据访问层的包入口:仓储与写入队列分别定义在 core.data.repositories 与 core.data.write_queue;
|
||||
本文件不含代码,仅作包标记。
|
||||
职责边界:负责:作为 core.data 包的标记存在;不负责:任何再导出,使用方按各自的子模块路径导入。
|
||||
关键依赖:无。
|
||||
已知限制:无。
|
||||
"""
|
||||
@@ -0,0 +1,827 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.data.repositories
|
||||
功能描述:proxies 与 plugin_configs 两张表的 SQLite 仓储实现,向上暴露领域对象
|
||||
(Proxy / PluginConfig)与少量统计、查询结果,不暴露数据库行与游标;
|
||||
行与对象转换、列白名单、SQL 拼装都收在模块内部。
|
||||
职责边界:负责:两张表的增删改查、行到领域对象的映射、写入前的字段合法性校验;不负责:
|
||||
连接与事务管理(见 ConnectionPool)、业务规则(见 auto_revalidator)、缓存同步(见 DedupCache 的调用方)。
|
||||
关键依赖:aiosqlite、core.interfaces.repository、core.domain.models、core.domain.exceptions。
|
||||
已知限制:
|
||||
1. 插入列集合分散在 _INSERT_COLUMNS、_proxy_values、_row_to_proxy 与 write_queue._dict_to_proxy,增列须同步;错位静默写错列。
|
||||
2. 排序字段与 update_where 的键会拼进 SQL,因此都走白名单;白名单外的键被拒绝或回落,不会拼进 SQL。
|
||||
3. 老库可能缺后加的列,读取靠 _safe_get 兜底,手写 SQL(如 get_stats)要自己接住 OperationalError;两个同名 _safe_get 的 NULL 语义不同。
|
||||
4. upsert 冲突时 _PRESERVED_ON_CONFLICT 的列保留原值,其余列里地理、出口 IP、匿名度、探测计数仅在确实测到时覆盖,占位默认值不覆盖。
|
||||
5. 异常映射:IntegrityError 只在 add/add_batch/save 转 DataValidationException,其余位置转 RepositoryException。
|
||||
6. 数据库错误转 DatabaseConnectionException;find/count 的 InvalidParameterException 原样上抛。
|
||||
7. find 排序回落:sort_by 不在白名单退回 delay_ms,sort_order 非 asc/desc 退回 asc;排序键为 NULL 的行统一排最后。
|
||||
"""
|
||||
|
||||
import aiosqlite
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from core.interfaces.repository import IProxyRepository, IPluginConfigRepository
|
||||
from core.domain.models import (
|
||||
ANONYMITY_LEVELS,
|
||||
SUPPORTED_PROTOCOLS,
|
||||
Proxy,
|
||||
PluginConfig,
|
||||
ProxyFilter,
|
||||
)
|
||||
from core.domain.exceptions import (
|
||||
RepositoryException,
|
||||
DataValidationException,
|
||||
DatabaseConnectionException,
|
||||
InvalidParameterException,
|
||||
QueryBuildException
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INSERT_COLUMNS = (
|
||||
"protocol, ip, port, username, password, region, country, province, city, "
|
||||
"region_en, country_en, province_en, city_en, "
|
||||
"delay_ms, is_valid, is_favorite, failure_count, real_ip, source_plugin, "
|
||||
"created_at, validated_at, health_score, success_count, total_checks, "
|
||||
"anonymity_level, anonymity_fallback, supports_https, supports_http, "
|
||||
"quality_assessed_at, "
|
||||
"avg_delay_ms, probe_success_count, probe_total_count, "
|
||||
"delay_variance, last_valid_at, total_valid_seconds, "
|
||||
"country_code, subdivision_code, geo_source"
|
||||
)
|
||||
|
||||
_PRESERVED_ON_CONFLICT = (
|
||||
"ip", "port", "is_favorite", "created_at",
|
||||
"delay_variance", "last_valid_at", "total_valid_seconds",
|
||||
)
|
||||
|
||||
_UNKNOWN_PLACEHOLDER = "未知"
|
||||
|
||||
_PLACEHOLDER_GUARDED_COLUMNS = ("region", "country", "province", "city")
|
||||
|
||||
_UNVERIFIED_GUARDED_COLUMNS = ("anonymity_level",)
|
||||
|
||||
_NULLABLE_MEASURED_COLUMNS = (
|
||||
"region_en", "country_en", "province_en", "city_en",
|
||||
"country_code", "subdivision_code", "geo_source",
|
||||
"real_ip", "delay_ms", "avg_delay_ms", "quality_assessed_at",
|
||||
)
|
||||
|
||||
_FALSEY_PLACEHOLDER_COLUMNS = (
|
||||
"anonymity_fallback", "supports_https", "supports_http",
|
||||
"probe_success_count", "probe_total_count",
|
||||
)
|
||||
|
||||
_WHERE_COLUMNS = frozenset({"id", "real_ip", "ip", "port", "source_plugin"})
|
||||
|
||||
_COLUMN_NAMES = tuple(
|
||||
column.strip() for column in _INSERT_COLUMNS.split(",")
|
||||
)
|
||||
|
||||
|
||||
def _conflict_set_expression(column: str) -> str:
|
||||
if column in _PLACEHOLDER_GUARDED_COLUMNS:
|
||||
return (f"CASE WHEN excluded.{column} IS NULL OR excluded.{column} = '{_UNKNOWN_PLACEHOLDER}' "
|
||||
f"THEN proxies.{column} ELSE excluded.{column} END")
|
||||
if column in _UNVERIFIED_GUARDED_COLUMNS:
|
||||
return (f"CASE WHEN excluded.{column} IS NULL OR excluded.{column} = 'unverified' "
|
||||
f"THEN proxies.{column} ELSE excluded.{column} END")
|
||||
if column in _NULLABLE_MEASURED_COLUMNS:
|
||||
return f"COALESCE(excluded.{column}, proxies.{column})"
|
||||
if column in _FALSEY_PLACEHOLDER_COLUMNS:
|
||||
return f"CASE WHEN excluded.{column} THEN excluded.{column} ELSE proxies.{column} END"
|
||||
return f"excluded.{column}"
|
||||
|
||||
|
||||
_UPSERT_PROXY_SQL = (
|
||||
f"INSERT INTO proxies ({_INSERT_COLUMNS}) "
|
||||
f"VALUES ({', '.join('?' for _ in _COLUMN_NAMES)}) "
|
||||
"ON CONFLICT(ip, port) DO UPDATE SET "
|
||||
+ ", ".join(
|
||||
f"{column} = {_conflict_set_expression(column)}"
|
||||
for column in _COLUMN_NAMES
|
||||
if column not in _PRESERVED_ON_CONFLICT
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _proxy_values(proxy: Proxy) -> tuple:
|
||||
return (
|
||||
proxy.protocol,
|
||||
proxy.ip,
|
||||
proxy.port,
|
||||
proxy.username,
|
||||
proxy.password,
|
||||
proxy.region,
|
||||
proxy.country,
|
||||
proxy.province,
|
||||
proxy.city,
|
||||
proxy.region_en,
|
||||
proxy.country_en,
|
||||
proxy.province_en,
|
||||
proxy.city_en,
|
||||
proxy.delay_ms,
|
||||
int(proxy.is_valid),
|
||||
int(proxy.is_favorite),
|
||||
proxy.failure_count,
|
||||
proxy.real_ip,
|
||||
proxy.source_plugin,
|
||||
proxy.created_at.isoformat() if proxy.created_at else datetime.now().isoformat(),
|
||||
proxy.validated_at.isoformat() if proxy.validated_at else None,
|
||||
proxy.health_score,
|
||||
proxy.success_count,
|
||||
proxy.total_checks,
|
||||
proxy.anonymity_level,
|
||||
int(proxy.anonymity_fallback),
|
||||
int(proxy.supports_https),
|
||||
int(proxy.supports_http),
|
||||
proxy.quality_assessed_at.isoformat() if proxy.quality_assessed_at else None,
|
||||
proxy.avg_delay_ms,
|
||||
proxy.probe_success_count,
|
||||
proxy.probe_total_count,
|
||||
proxy.delay_variance,
|
||||
proxy.last_valid_at.isoformat() if proxy.last_valid_at else None,
|
||||
proxy.total_valid_seconds,
|
||||
proxy.country_code,
|
||||
proxy.subdivision_code,
|
||||
proxy.geo_source,
|
||||
)
|
||||
|
||||
|
||||
class ProxyRepository(IProxyRepository):
|
||||
def __init__(self, connection_pool: 'ConnectionPool'):
|
||||
self._pool = connection_pool
|
||||
|
||||
async def add(self, proxy: Proxy) -> int:
|
||||
try:
|
||||
self._validate_proxy(proxy)
|
||||
|
||||
async with self._pool.acquire_writer() as db:
|
||||
cursor = await db.execute(_UPSERT_PROXY_SQL, _proxy_values(proxy))
|
||||
return cursor.lastrowid
|
||||
except aiosqlite.IntegrityError as e:
|
||||
raise DataValidationException(f"代理数据完整性错误: {str(e)}").with_message_key(
|
||||
"pool_repo_integrity_error", str(e))
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DataValidationException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"添加代理失败: {str(e)}")
|
||||
|
||||
async def add_batch(self, proxies: List[Proxy]) -> int:
|
||||
if not proxies:
|
||||
return 0
|
||||
|
||||
try:
|
||||
for proxy in proxies:
|
||||
self._validate_proxy(proxy)
|
||||
|
||||
async with self._pool.acquire_writer() as db:
|
||||
await db.executemany(
|
||||
_UPSERT_PROXY_SQL,
|
||||
[_proxy_values(proxy) for proxy in proxies],
|
||||
)
|
||||
return len(proxies)
|
||||
except aiosqlite.IntegrityError as e:
|
||||
raise DataValidationException(f"代理数据完整性错误: {str(e)}")
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DataValidationException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"批量添加代理失败: {str(e)}")
|
||||
|
||||
async def update(self, proxy_id: int, updates: dict) -> bool:
|
||||
if not updates:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._validate_update_fields(updates)
|
||||
|
||||
set_clause = ', '.join([f"{key} = ?" for key in updates.keys()])
|
||||
values = list(updates.values())
|
||||
values.append(proxy_id)
|
||||
|
||||
async with self._pool.acquire_writer() as db:
|
||||
cursor = await db.execute(
|
||||
f"UPDATE proxies SET {set_clause} WHERE id = ?",
|
||||
values
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DataValidationException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"更新代理失败: {str(e)}")
|
||||
|
||||
async def update_where(self, where: dict, updates: dict) -> int:
|
||||
if not updates or not where:
|
||||
return 0
|
||||
|
||||
invalid_where = set(where.keys()) - _WHERE_COLUMNS
|
||||
if invalid_where:
|
||||
raise DataValidationException(
|
||||
f"不允许作为更新条件的字段: {', '.join(sorted(invalid_where))}"
|
||||
).with_message_key("pool_field_where_not_allowed",
|
||||
", ".join(sorted(invalid_where)))
|
||||
|
||||
try:
|
||||
self._validate_update_fields(updates)
|
||||
|
||||
set_clause = ', '.join(f"{key} = ?" for key in updates.keys())
|
||||
where_clause = ' AND '.join(f"{key} = ?" for key in where.keys())
|
||||
values = list(updates.values()) + list(where.values())
|
||||
|
||||
async with self._pool.acquire_writer() as db:
|
||||
cursor = await db.execute(
|
||||
f"UPDATE proxies SET {set_clause} WHERE {where_clause}",
|
||||
values
|
||||
)
|
||||
return cursor.rowcount
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DataValidationException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"按条件更新代理失败: {str(e)}")
|
||||
|
||||
async def update_batch(self, updates: list[tuple[int, dict]]) -> int:
|
||||
if not updates:
|
||||
return 0
|
||||
|
||||
try:
|
||||
async with self._pool.acquire_writer() as db:
|
||||
total_affected = 0
|
||||
for proxy_id, data in updates:
|
||||
if not data:
|
||||
continue
|
||||
self._validate_update_fields(data)
|
||||
set_clause = ', '.join(f"{k} = ?" for k in data.keys())
|
||||
values = list(data.values()) + [proxy_id]
|
||||
cursor = await db.execute(
|
||||
f"UPDATE proxies SET {set_clause} WHERE id = ?", values
|
||||
)
|
||||
total_affected += cursor.rowcount
|
||||
return total_affected
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DataValidationException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"批量更新代理失败: {str(e)}")
|
||||
|
||||
async def delete_batch(self, proxy_ids: list[int]) -> int:
|
||||
if not proxy_ids:
|
||||
return 0
|
||||
|
||||
try:
|
||||
async with self._pool.acquire_writer() as db:
|
||||
placeholders = ','.join('?' * len(proxy_ids))
|
||||
cursor = await db.execute(
|
||||
f"DELETE FROM proxies WHERE id IN ({placeholders})",
|
||||
proxy_ids
|
||||
)
|
||||
return cursor.rowcount
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"批量删除代理失败: {str(e)}")
|
||||
|
||||
async def delete_invalid(self) -> int:
|
||||
try:
|
||||
async with self._pool.acquire_writer() as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM proxies WHERE is_valid = 0"
|
||||
)
|
||||
return cursor.rowcount
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"删除无效代理失败: {str(e)}")
|
||||
|
||||
async def get_by_id(self, proxy_id: int) -> Optional[Proxy]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(
|
||||
"SELECT * FROM proxies WHERE id = ?",
|
||||
(proxy_id,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return self._row_to_proxy(row)
|
||||
return None
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询代理失败: {str(e)}")
|
||||
|
||||
async def find(self, filter: ProxyFilter, page: int = 1,
|
||||
page_size: int = 50) -> List[Proxy]:
|
||||
try:
|
||||
query, params = self._build_query(filter)
|
||||
|
||||
sort_by = filter.sort_by or 'delay_ms'
|
||||
sort_order = filter.sort_order or 'asc'
|
||||
|
||||
allowed_sort_fields = [
|
||||
'delay_ms', 'protocol', 'region', 'validated_at', 'created_at',
|
||||
'health_score', 'anonymity_level',
|
||||
'success_count', 'total_checks', 'avg_delay_ms',
|
||||
'failure_count', 'is_favorite', 'supports_https',
|
||||
'success_rate',
|
||||
'region_en',
|
||||
]
|
||||
if sort_by not in allowed_sort_fields:
|
||||
sort_by = 'delay_ms'
|
||||
|
||||
if sort_order.lower() not in ['asc', 'desc']:
|
||||
sort_order = 'asc'
|
||||
|
||||
if sort_by == 'success_rate':
|
||||
sort_expr = (
|
||||
"CASE WHEN total_checks = 0 THEN 0.0 "
|
||||
"ELSE CAST(success_count AS REAL) / total_checks END"
|
||||
)
|
||||
query += (
|
||||
f" ORDER BY ({sort_expr}) IS NULL,"
|
||||
f" ({sort_expr}) {sort_order.upper()}"
|
||||
)
|
||||
else:
|
||||
query += (
|
||||
f" ORDER BY {sort_by} IS NULL, {sort_by} {sort_order.upper()}"
|
||||
)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query += " LIMIT ? OFFSET ?"
|
||||
params.extend([page_size, offset])
|
||||
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(query, params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_proxy(row) for row in rows]
|
||||
except InvalidParameterException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise QueryBuildException(f"查询构建失败: {str(e)}").with_message_key(
|
||||
"pool_repo_query_build_failed", str(e))
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (QueryBuildException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询代理列表失败: {str(e)}")
|
||||
|
||||
async def count(self, filter: ProxyFilter) -> int:
|
||||
try:
|
||||
query, params = self._build_query(filter)
|
||||
|
||||
query = query.replace("SELECT *", "SELECT COUNT(*)", 1)
|
||||
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(query, params) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
except InvalidParameterException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise QueryBuildException(f"查询构建失败: {str(e)}")
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (QueryBuildException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"统计代理数量失败: {str(e)}")
|
||||
|
||||
async def get_geo_fields_by_real_ip(self, real_ip: str) -> Optional[dict]:
|
||||
columns = (
|
||||
"region, country, province, city, "
|
||||
"region_en, country_en, province_en, city_en, "
|
||||
"country_code, subdivision_code"
|
||||
)
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(
|
||||
f"SELECT {columns} FROM proxies WHERE real_ip = ? LIMIT 1",
|
||||
(real_ip,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
names = [c.strip() for c in columns.split(",")]
|
||||
return dict(zip(names, row))
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DatabaseConnectionException,):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询出口 IP 的现有归属地失败: {str(e)}")
|
||||
|
||||
async def get_ips_missing_geo_codes(self) -> List[str]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(
|
||||
"SELECT DISTINCT real_ip FROM proxies "
|
||||
"WHERE real_ip IS NOT NULL AND real_ip != '' "
|
||||
"AND country_code IS NULL"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [row[0] for row in rows]
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DatabaseConnectionException,):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询待补归属地代码的 IP 失败: {str(e)}")
|
||||
|
||||
async def find_without_checks(self) -> List[Proxy]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(
|
||||
"SELECT * FROM proxies WHERE total_checks = 0 OR total_checks IS NULL"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_proxy(row) for row in rows]
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DatabaseConnectionException,):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询未验证代理失败: {str(e)}")
|
||||
|
||||
async def get_existing_ip_ports(self) -> set[str]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute("SELECT ip, port FROM proxies") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return {f"{row[0]}:{row[1]}" for row in rows}
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"获取 IP 列表失败: {str(e)}")
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute("SELECT COUNT(*) FROM proxies") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
total_proxies = row[0] if row else 0
|
||||
|
||||
async with db.execute("SELECT COUNT(*) FROM proxies WHERE is_valid = 1") as cursor:
|
||||
row = await cursor.fetchone()
|
||||
valid_proxies = row[0] if row else 0
|
||||
|
||||
invalid_proxies = total_proxies - valid_proxies
|
||||
|
||||
try:
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM proxies WHERE anonymity_fallback = 1"
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
anonymity_fallback_proxies = row[0] if row else 0
|
||||
except aiosqlite.OperationalError as e:
|
||||
logger.warning("统计匿名度兜底条数失败(可能缺列): %s", e)
|
||||
anonymity_fallback_proxies = 0
|
||||
|
||||
protocol_distribution = {}
|
||||
async with db.execute("SELECT protocol, COUNT(*) FROM proxies GROUP BY protocol") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
protocol_distribution[row[0]] = row[1]
|
||||
|
||||
region_distribution = {}
|
||||
async with db.execute(
|
||||
"SELECT region, COUNT(*) as count FROM proxies GROUP BY region ORDER BY count DESC LIMIT 10"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
region_distribution[row[0]] = row[1]
|
||||
|
||||
return {
|
||||
'total_proxies': total_proxies,
|
||||
'valid_proxies': valid_proxies,
|
||||
'invalid_proxies': invalid_proxies,
|
||||
'anonymity_fallback_proxies': anonymity_fallback_proxies,
|
||||
'protocol_distribution': protocol_distribution,
|
||||
'region_distribution': region_distribution,
|
||||
}
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"获取统计信息失败: {str(e)}")
|
||||
|
||||
async def find_by_ip_port(self, ip: str, port: int) -> Optional[Proxy]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(
|
||||
"SELECT * FROM proxies WHERE ip = ? AND port = ?",
|
||||
(ip, port)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_proxy(row) if row else None
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询代理失败: {str(e)}")
|
||||
|
||||
async def get_sources(self) -> list[str]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(
|
||||
"SELECT DISTINCT source_plugin FROM proxies ORDER BY source_plugin"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
row['source_plugin'] for row in rows
|
||||
if row['source_plugin']
|
||||
]
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"获取代理来源列表失败: {str(e)}")
|
||||
|
||||
def _build_query(self, filter: ProxyFilter) -> tuple[str, list]:
|
||||
query = "SELECT * FROM proxies WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if filter.protocol:
|
||||
if filter.protocol not in SUPPORTED_PROTOCOLS:
|
||||
raise InvalidParameterException(
|
||||
'param_invalid_protocol', filter.protocol)
|
||||
query += " AND protocol = ?"
|
||||
params.append(filter.protocol)
|
||||
|
||||
if filter.region:
|
||||
if filter.region.startswith('-'):
|
||||
exclude_region = filter.region[1:]
|
||||
pattern = f"%{exclude_region}%"
|
||||
query += (" AND (region IS NULL OR region NOT LIKE ?)"
|
||||
" AND (region_en IS NULL OR region_en NOT LIKE ?)")
|
||||
params.append(pattern)
|
||||
params.append(pattern)
|
||||
else:
|
||||
pattern = f"%{filter.region}%"
|
||||
query += " AND (region LIKE ? OR region_en LIKE ?)"
|
||||
params.append(pattern)
|
||||
params.append(pattern)
|
||||
|
||||
if filter.ip:
|
||||
query += " AND ip LIKE ?"
|
||||
params.append(f"%{filter.ip}%")
|
||||
|
||||
if filter.min_delay is not None:
|
||||
if filter.min_delay < 0:
|
||||
raise InvalidParameterException(
|
||||
'param_negative_min_delay', filter.min_delay)
|
||||
query += " AND delay_ms >= ?"
|
||||
params.append(filter.min_delay)
|
||||
|
||||
if filter.max_delay is not None:
|
||||
if filter.max_delay < 0:
|
||||
raise InvalidParameterException(
|
||||
'param_negative_max_delay', filter.max_delay)
|
||||
query += " AND delay_ms <= ?"
|
||||
params.append(filter.max_delay)
|
||||
|
||||
if filter.is_valid is not None:
|
||||
query += " AND is_valid = ?"
|
||||
params.append(int(filter.is_valid))
|
||||
|
||||
if filter.is_favorite is not None:
|
||||
query += " AND is_favorite = ?"
|
||||
params.append(int(filter.is_favorite))
|
||||
|
||||
if filter.source:
|
||||
query += " AND source_plugin = ?"
|
||||
params.append(filter.source)
|
||||
|
||||
if filter.anonymity_level:
|
||||
if filter.anonymity_level not in ANONYMITY_LEVELS:
|
||||
raise InvalidParameterException(
|
||||
'param_invalid_anonymity', filter.anonymity_level)
|
||||
query += " AND anonymity_level = ?"
|
||||
params.append(filter.anonymity_level)
|
||||
|
||||
if filter.min_health_score is not None:
|
||||
if filter.min_health_score < 0:
|
||||
raise InvalidParameterException(
|
||||
'param_negative_min_health', filter.min_health_score)
|
||||
query += " AND health_score >= ?"
|
||||
params.append(filter.min_health_score)
|
||||
|
||||
if filter.supports_https is not None:
|
||||
query += " AND supports_https = ?"
|
||||
params.append(int(filter.supports_https))
|
||||
|
||||
return query, params
|
||||
|
||||
def _row_to_proxy(self, row: aiosqlite.Row) -> Proxy:
|
||||
def _safe_get(key, default=None):
|
||||
try:
|
||||
val = row[key]
|
||||
return val if val is not None else default
|
||||
except (KeyError, IndexError):
|
||||
return default
|
||||
|
||||
return Proxy(
|
||||
id=row['id'],
|
||||
protocol=row['protocol'],
|
||||
ip=row['ip'],
|
||||
port=row['port'],
|
||||
username=_safe_get('username'),
|
||||
password=_safe_get('password'),
|
||||
region=row['region'],
|
||||
country=row['country'],
|
||||
province=row['province'],
|
||||
city=row['city'],
|
||||
region_en=_safe_get('region_en'),
|
||||
country_en=_safe_get('country_en'),
|
||||
province_en=_safe_get('province_en'),
|
||||
city_en=_safe_get('city_en'),
|
||||
delay_ms=row['delay_ms'],
|
||||
is_valid=bool(row['is_valid']),
|
||||
is_favorite=bool(_safe_get('is_favorite', 0)),
|
||||
failure_count=int(_safe_get('failure_count', 0)),
|
||||
real_ip=row['real_ip'],
|
||||
source_plugin=row['source_plugin'],
|
||||
created_at=datetime.fromisoformat(row['created_at']) if row['created_at'] else None,
|
||||
validated_at=datetime.fromisoformat(row['validated_at']) if row['validated_at'] else None,
|
||||
health_score=float(_safe_get('health_score', 0.0)),
|
||||
success_count=int(_safe_get('success_count', 0)),
|
||||
total_checks=int(_safe_get('total_checks', 0)),
|
||||
anonymity_level=str(_safe_get('anonymity_level', 'unverified')),
|
||||
anonymity_fallback=bool(_safe_get('anonymity_fallback', 0)),
|
||||
supports_https=bool(_safe_get('supports_https', 0)),
|
||||
supports_http=bool(_safe_get('supports_http', 0)),
|
||||
quality_assessed_at=datetime.fromisoformat(_safe_get('quality_assessed_at')) if _safe_get('quality_assessed_at') else None,
|
||||
avg_delay_ms=float(_safe_get('avg_delay_ms')) if _safe_get('avg_delay_ms') is not None else None,
|
||||
delay_variance=float(_safe_get('delay_variance')) if _safe_get('delay_variance') is not None else None,
|
||||
last_valid_at=datetime.fromisoformat(_safe_get('last_valid_at')) if _safe_get('last_valid_at') else None,
|
||||
total_valid_seconds=float(_safe_get('total_valid_seconds', 0.0)),
|
||||
probe_success_count=int(_safe_get('probe_success_count', 0)),
|
||||
probe_total_count=int(_safe_get('probe_total_count', 0)),
|
||||
country_code=_safe_get('country_code'),
|
||||
subdivision_code=_safe_get('subdivision_code'),
|
||||
geo_source=_safe_get('geo_source'),
|
||||
)
|
||||
|
||||
def _validate_proxy(self, proxy: Proxy) -> None:
|
||||
if not proxy.protocol:
|
||||
raise DataValidationException("协议不能为空").with_message_key(
|
||||
"pool_field_protocol_required")
|
||||
|
||||
if proxy.protocol not in SUPPORTED_PROTOCOLS:
|
||||
raise DataValidationException(f"不支持的协议: {proxy.protocol}").with_message_key(
|
||||
"pool_field_protocol_unsupported", proxy.protocol)
|
||||
|
||||
if not proxy.ip:
|
||||
raise DataValidationException("IP 地址不能为空").with_message_key(
|
||||
"pool_field_ip_required")
|
||||
|
||||
if not proxy.port or proxy.port < 1 or proxy.port > 65535:
|
||||
raise DataValidationException(f"无效的端口: {proxy.port}").with_message_key(
|
||||
"pool_field_port_invalid", proxy.port)
|
||||
|
||||
if not proxy.source_plugin:
|
||||
raise DataValidationException("来源插件不能为空").with_message_key(
|
||||
"pool_field_source_required")
|
||||
|
||||
def _validate_update_fields(self, updates: dict) -> None:
|
||||
allowed_fields = {
|
||||
'protocol', 'ip', 'port', 'username', 'password',
|
||||
'region', 'country', 'province', 'city',
|
||||
'region_en', 'country_en', 'province_en', 'city_en',
|
||||
'delay_ms', 'is_valid', 'real_ip', 'source_plugin', 'validated_at',
|
||||
'failure_count', 'is_favorite',
|
||||
'health_score', 'success_count', 'total_checks',
|
||||
'anonymity_level', 'anonymity_fallback',
|
||||
'supports_https', 'supports_http',
|
||||
'quality_assessed_at', 'avg_delay_ms',
|
||||
'delay_variance', 'last_valid_at', 'total_valid_seconds',
|
||||
'probe_success_count', 'probe_total_count',
|
||||
'country_code', 'subdivision_code', 'geo_source',
|
||||
}
|
||||
|
||||
invalid_fields = set(updates.keys()) - allowed_fields
|
||||
if invalid_fields:
|
||||
raise DataValidationException(
|
||||
f"不允许更新的字段: {', '.join(invalid_fields)}"
|
||||
).with_message_key("pool_field_update_not_allowed", ", ".join(invalid_fields))
|
||||
|
||||
if 'protocol' in updates and updates['protocol'] not in SUPPORTED_PROTOCOLS:
|
||||
raise DataValidationException(f"不支持的协议: {updates['protocol']}").with_message_key(
|
||||
"pool_field_protocol_unsupported", updates['protocol'])
|
||||
|
||||
if 'port' in updates:
|
||||
port = updates['port']
|
||||
if not isinstance(port, int) or port < 1 or port > 65535:
|
||||
raise DataValidationException(f"无效的端口: {port}").with_message_key(
|
||||
"pool_field_port_invalid", port)
|
||||
|
||||
if 'is_valid' in updates and not isinstance(updates['is_valid'], (bool, int)):
|
||||
raise DataValidationException(f"无效的 is_valid 值: {updates['is_valid']}").with_message_key(
|
||||
"pool_field_is_valid_invalid", updates['is_valid'])
|
||||
|
||||
if 'anonymity_level' in updates:
|
||||
if updates['anonymity_level'] not in ANONYMITY_LEVELS:
|
||||
raise DataValidationException(f"无效的匿名等级: {updates['anonymity_level']}").with_message_key(
|
||||
"pool_field_anonymity_invalid", updates['anonymity_level'])
|
||||
|
||||
|
||||
class PluginConfigRepository(IPluginConfigRepository):
|
||||
def __init__(self, connection_pool: 'ConnectionPool'):
|
||||
self._pool = connection_pool
|
||||
|
||||
async def get(self, plugin_name: str) -> Optional[PluginConfig]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute(
|
||||
"SELECT * FROM plugin_configs WHERE name = ?",
|
||||
(plugin_name,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return self._row_to_config(row)
|
||||
return None
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询插件配置失败: {str(e)}")
|
||||
|
||||
async def save(self, config: PluginConfig) -> None:
|
||||
try:
|
||||
self._validate_config(config)
|
||||
|
||||
async with self._pool.acquire_writer() as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO plugin_configs
|
||||
(name, enabled, interval_minutes, last_run, next_run,
|
||||
reval_enabled, reval_interval_minutes, test_url, skip_validation)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
config.name,
|
||||
int(config.enabled),
|
||||
config.interval_minutes,
|
||||
config.last_run.isoformat() if config.last_run else None,
|
||||
config.next_run.isoformat() if config.next_run else None,
|
||||
int(config.reval_enabled),
|
||||
config.reval_interval_minutes,
|
||||
config.test_url or "",
|
||||
int(config.skip_validation),
|
||||
)
|
||||
)
|
||||
except aiosqlite.IntegrityError as e:
|
||||
raise DataValidationException(f"插件配置数据完整性错误: {str(e)}").with_message_key(
|
||||
"pool_repo_plugin_integrity_error", str(e))
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except (DataValidationException, DatabaseConnectionException):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"保存插件配置失败: {str(e)}")
|
||||
|
||||
async def get_all(self) -> List[PluginConfig]:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
async with db.execute("SELECT * FROM plugin_configs") as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_config(row) for row in rows]
|
||||
except aiosqlite.OperationalError as e:
|
||||
raise DatabaseConnectionException(f"数据库操作失败: {str(e)}")
|
||||
except Exception as e:
|
||||
raise RepositoryException(f"查询所有插件配置失败: {str(e)}")
|
||||
|
||||
def _row_to_config(self, row: aiosqlite.Row) -> PluginConfig:
|
||||
return PluginConfig(
|
||||
name=row['name'],
|
||||
enabled=bool(row['enabled']),
|
||||
interval_minutes=row['interval_minutes'],
|
||||
last_run=datetime.fromisoformat(row['last_run']) if row['last_run'] else None,
|
||||
next_run=datetime.fromisoformat(row['next_run']) if row['next_run'] else None,
|
||||
reval_enabled=bool(self._safe_get(row, 'reval_enabled', 1) or 0),
|
||||
reval_interval_minutes=int(self._safe_get(row, 'reval_interval_minutes', 0) or 0),
|
||||
test_url=str(self._safe_get(row, 'test_url', '') or ''),
|
||||
skip_validation=bool(self._safe_get(row, 'skip_validation', 0) or 0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_get(row: aiosqlite.Row, key: str, default):
|
||||
return row[key] if key in row.keys() else default
|
||||
|
||||
def _validate_config(self, config: PluginConfig) -> None:
|
||||
if not config.name:
|
||||
raise DataValidationException("插件名称不能为空").with_message_key(
|
||||
"pool_field_plugin_name_required")
|
||||
|
||||
if config.interval_minutes < 1:
|
||||
raise DataValidationException(f"运行间隔必须大于 0: {config.interval_minutes}").with_message_key(
|
||||
"pool_field_plugin_interval_invalid", config.interval_minutes)
|
||||
|
||||
if config.reval_interval_minutes < 0:
|
||||
raise DataValidationException(
|
||||
f"重验证间隔不能为负数: {config.reval_interval_minutes}"
|
||||
).with_message_key("pool_field_reval_interval_invalid",
|
||||
config.reval_interval_minutes)
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.data.write_queue
|
||||
功能描述:异步写入队列,把调用方零散的数据库写操作聚合为批量执行。enqueue 一条 DBOperation
|
||||
后立即返回,消费者凑够 batch_size 或等满 consumer_timeout 后按操作类型分组写库。
|
||||
职责边界:负责:写操作缓冲、按批分组执行、失败重试与指数退避、去重缓存同步维护、队列容量背压。
|
||||
不负责:DBOperation 构造与 where 语义(由上游决定)、具体 SQL 与事务实现(见 core.data.repositories)。
|
||||
关键依赖:core.interfaces.repository、core.interfaces.infrastructure、core.domain.models、
|
||||
core.domain.exceptions、core.infrastructure.dedup_cache(可选注入)、modules.modules。
|
||||
已知限制:
|
||||
1. enqueue 成功只代表入队、不代表落库;重试用尽、组内降级写不进、stop() 排空超时都会真丢弃条目并计入 dropped_count。
|
||||
2. 只处理 proxies 表的 insert/update/delete 条目,其余静默跳过;update 缺 where、delete 缺 where.id 时跳过并记 warning。
|
||||
3. 组间按「插入 → 更新 → 删除」固定顺序执行,不可更改:同批里刚插入的记录随后更新才匹配得到行;单组重试用尽不影响其余组,失败组汇总成一条 WriteQueueException 上报。
|
||||
4. 重试按分组独立,最多 max(1, max_retries) 次;第 k 次失败后等 _RETRY_BASE_DELAY*2^k 秒再试,k 从 0 起,首次尝试不等待;重试函数须可重复执行。
|
||||
5. 去重缓存只登记确实写入库的 (ip, port):把写失败的也登记会让后续抓取一直跳过这些地址,直到缓存重建。
|
||||
6. 须在同一事件循环内单消费者使用;队列满时 enqueue 抛 WriteQueueFullException;apply_config 只热更新 batch_size、max_retries。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from core.interfaces.infrastructure import IWriteQueue
|
||||
from core.interfaces.repository import IProxyRepository
|
||||
from core.domain.models import DBOperation, Proxy
|
||||
from core.domain.exceptions import WriteQueueException, WriteQueueFullException
|
||||
from modules.modules import sanitize_proxy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.infrastructure.dedup_cache import DedupCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_RETRIES: int = 3
|
||||
|
||||
_DRAIN_TIMEOUT: float = 15.0
|
||||
_RETRY_BASE_DELAY: float = 0.1
|
||||
|
||||
|
||||
class AsyncWriteQueue(IWriteQueue):
|
||||
def __init__(
|
||||
self,
|
||||
proxy_repository: IProxyRepository,
|
||||
batch_size: int = 100,
|
||||
consumer_timeout: float = 0.1,
|
||||
dedup_cache: 'DedupCache' = None,
|
||||
max_queue_size: int = 10000,
|
||||
max_retries: int = _MAX_RETRIES,
|
||||
):
|
||||
self._proxy_repository = proxy_repository
|
||||
self._max_queue_size = max(1, max_queue_size)
|
||||
self._queue: asyncio.Queue = asyncio.Queue(maxsize=self._max_queue_size)
|
||||
self._batch_size = max(1, batch_size)
|
||||
self._consumer_timeout = consumer_timeout
|
||||
self._dedup_cache: Optional['DedupCache'] = dedup_cache
|
||||
self._max_retries = max_retries
|
||||
self._consumer_task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._drain_timeout = _DRAIN_TIMEOUT
|
||||
self._dropped_operations = 0
|
||||
self._pending_operations = 0
|
||||
self._group_dropped = 0
|
||||
|
||||
@property
|
||||
def dropped_count(self) -> int:
|
||||
return self._dropped_operations
|
||||
|
||||
def _mark_done(self, count: int) -> None:
|
||||
for _ in range(count):
|
||||
self._pending_operations -= 1
|
||||
self._queue.task_done()
|
||||
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
logger.warning("AsyncWriteQueue 已经在运行")
|
||||
return
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
self._consumer_task = asyncio.create_task(self._consumer())
|
||||
logger.info("AsyncWriteQueue 已启动")
|
||||
except Exception as e:
|
||||
self._running = False
|
||||
raise WriteQueueException(f"启动写入队列失败: {str(e)}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
try:
|
||||
self._running = False
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._queue.join(), timeout=self._drain_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
abandoned = self._queue.qsize() + self._pending_operations
|
||||
self._dropped_operations += abandoned
|
||||
logger.error(
|
||||
"写入队列在 %.0f 秒内未能排空,放弃 %d 条待写操作"
|
||||
"(累计丢弃 %d 条),停止消费者",
|
||||
self._drain_timeout, abandoned, self._dropped_operations,
|
||||
)
|
||||
|
||||
if self._consumer_task:
|
||||
self._consumer_task.cancel()
|
||||
try:
|
||||
await self._consumer_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("AsyncWriteQueue 已停止")
|
||||
except Exception as e:
|
||||
raise WriteQueueException(f"停止写入队列失败: {str(e)}")
|
||||
|
||||
async def enqueue(self, operation: DBOperation) -> None:
|
||||
if not self._running:
|
||||
raise WriteQueueException("写入队列未启动或已停止")
|
||||
|
||||
try:
|
||||
self._queue.put_nowait(operation)
|
||||
except asyncio.QueueFull:
|
||||
raise WriteQueueFullException(
|
||||
f"写入队列已满(当前容量 {self._max_queue_size}),"
|
||||
f"请等待消费者处理后重试"
|
||||
)
|
||||
except Exception as e:
|
||||
raise WriteQueueException(f"加入队列失败: {str(e)}")
|
||||
|
||||
def apply_config(self, config: 'Config') -> None:
|
||||
old_batch_size = self._batch_size
|
||||
old_max_retries = self._max_retries
|
||||
|
||||
self._batch_size = max(1, config.write_queue.batch_size)
|
||||
self._max_retries = config.write_queue.max_retries
|
||||
|
||||
logger.info(
|
||||
f"AsyncWriteQueue 配置已更新: "
|
||||
f"batch_size={old_batch_size}->{self._batch_size}, "
|
||||
f"max_retries={old_max_retries}->{self._max_retries}"
|
||||
)
|
||||
|
||||
|
||||
async def _consumer(self) -> None:
|
||||
while True:
|
||||
operations: list[DBOperation] = []
|
||||
|
||||
try:
|
||||
while len(operations) < self._batch_size:
|
||||
try:
|
||||
operation = await asyncio.wait_for(
|
||||
self._queue.get(),
|
||||
timeout=self._consumer_timeout,
|
||||
)
|
||||
operations.append(operation)
|
||||
self._pending_operations += 1
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
if not operations:
|
||||
if not self._running:
|
||||
break
|
||||
continue
|
||||
|
||||
dropped = await self._execute_batch(operations)
|
||||
if dropped:
|
||||
self._dropped_operations += dropped
|
||||
|
||||
self._mark_done(len(operations))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
self._mark_done(len(operations))
|
||||
raise
|
||||
except Exception as e:
|
||||
self._dropped_operations += len(operations)
|
||||
logger.error(
|
||||
"丢弃 %d 条始终写不进去的操作(累计丢弃 %d 条): %s",
|
||||
len(operations), self._dropped_operations, e, exc_info=True,
|
||||
)
|
||||
self._mark_done(len(operations))
|
||||
|
||||
|
||||
async def _execute_batch(self, operations: list[DBOperation]) -> int:
|
||||
self._group_dropped = 0
|
||||
insert_proxy_ops: list[DBOperation] = []
|
||||
update_proxy_ops: list[DBOperation] = []
|
||||
delete_proxy_ops: list[DBOperation] = []
|
||||
|
||||
for op in operations:
|
||||
if op.table != "proxies":
|
||||
continue
|
||||
if op.operation_type == "insert":
|
||||
insert_proxy_ops.append(op)
|
||||
elif op.operation_type == "update":
|
||||
update_proxy_ops.append(op)
|
||||
elif op.operation_type == "delete":
|
||||
delete_proxy_ops.append(op)
|
||||
|
||||
failures: list[tuple[str, Exception]] = []
|
||||
|
||||
groups = (
|
||||
("批量插入代理", self._batch_insert_proxies, insert_proxy_ops),
|
||||
("批量更新代理", self._batch_update_proxies, update_proxy_ops),
|
||||
("批量删除代理", self._batch_delete_proxies, delete_proxy_ops),
|
||||
)
|
||||
|
||||
for label, handler, ops in groups:
|
||||
if not ops:
|
||||
continue
|
||||
try:
|
||||
await self._retry_with_backoff(handler, ops, group_label=label)
|
||||
except Exception as e:
|
||||
logger.error("%s 最终失败: %s", label, e)
|
||||
failures.append((label, e))
|
||||
|
||||
if failures:
|
||||
detail = "; ".join(f"{label}: {err}" for label, err in failures)
|
||||
raise WriteQueueException(
|
||||
f"批量写入有 {len(failures)} 组失败 —— {detail}"
|
||||
)
|
||||
|
||||
return self._group_dropped
|
||||
|
||||
|
||||
async def _batch_insert_proxies(self, ops: list[DBOperation]) -> None:
|
||||
data_list: list[dict] = []
|
||||
for op in ops:
|
||||
if isinstance(op.data, list):
|
||||
data_list.extend(op.data)
|
||||
else:
|
||||
data_list.append(op.data)
|
||||
|
||||
if not data_list:
|
||||
return
|
||||
|
||||
proxies = [self._dict_to_proxy(d) for d in data_list]
|
||||
|
||||
written: list[Proxy] = []
|
||||
try:
|
||||
inserted_count = await self._proxy_repository.add_batch(proxies)
|
||||
written = list(proxies)
|
||||
except Exception as e:
|
||||
logger.warning("批量插入失败(%s),改为逐条写入以避免整批丢失", e)
|
||||
inserted_count = 0
|
||||
for proxy in proxies:
|
||||
try:
|
||||
inserted_count += await self._proxy_repository.add_batch([proxy])
|
||||
written.append(proxy)
|
||||
except Exception as inner:
|
||||
logger.warning("跳过一条无法写入的代理 %s: %s",
|
||||
sanitize_proxy(proxy.proxy_url), inner)
|
||||
|
||||
if len(written) < len(proxies):
|
||||
lost = len(proxies) - len(written)
|
||||
self._group_dropped += lost
|
||||
logger.error(
|
||||
"批量插入丢行: %d/%d 条写不进库(累计丢弃 %d 条)",
|
||||
lost, len(proxies), self._dropped_operations + self._group_dropped,
|
||||
)
|
||||
|
||||
logger.debug(f"批量插入代理完成: {inserted_count} 条")
|
||||
|
||||
if self._dedup_cache is not None:
|
||||
dedup_pairs: list[tuple[str, int]] = [
|
||||
(p.ip, p.port) for p in written
|
||||
]
|
||||
if dedup_pairs:
|
||||
await self._dedup_cache.add_batch(dedup_pairs)
|
||||
|
||||
async def _batch_update_proxies(self, ops: list[DBOperation]) -> None:
|
||||
by_where: dict[tuple, list[DBOperation]] = {}
|
||||
for op in ops:
|
||||
if not op.where:
|
||||
logger.warning(f"跳过缺少 where 的更新操作: {op}")
|
||||
continue
|
||||
by_where.setdefault(tuple(sorted(op.where)), []).append(op)
|
||||
|
||||
for where_keys, group in by_where.items():
|
||||
if where_keys == ("id",):
|
||||
updates = [
|
||||
(op.where["id"], op.data) for op in group if op.data
|
||||
]
|
||||
if not updates:
|
||||
continue
|
||||
affected = await self._proxy_repository.update_batch(updates)
|
||||
logger.debug(
|
||||
f"批量更新代理完成: 提交 {len(updates)} 条,受影响 {affected} 行"
|
||||
)
|
||||
continue
|
||||
|
||||
for op in group:
|
||||
if not op.data:
|
||||
continue
|
||||
affected = await self._proxy_repository.update_where(
|
||||
dict(op.where), op.data
|
||||
)
|
||||
logger.debug(
|
||||
f"按条件更新代理完成: {op.where},受影响 {affected} 行"
|
||||
)
|
||||
|
||||
async def _batch_delete_proxies(self, ops: list[DBOperation]) -> None:
|
||||
proxy_ids: list[int] = []
|
||||
dedup_pairs: list[tuple[str, int]] = []
|
||||
|
||||
for op in ops:
|
||||
if not op.where or "id" not in op.where:
|
||||
logger.warning(f"跳过缺少 where.id 的删除操作: {op}")
|
||||
continue
|
||||
proxy_ids.append(op.where["id"])
|
||||
|
||||
ip = op.where.get("ip")
|
||||
port = op.where.get("port")
|
||||
if ip is not None and port is not None:
|
||||
dedup_pairs.append((ip, int(port)))
|
||||
|
||||
if not proxy_ids:
|
||||
return
|
||||
|
||||
deleted = await self._proxy_repository.delete_batch(proxy_ids)
|
||||
logger.debug(f"批量删除代理完成: 提交 {len(proxy_ids)} 条,实际删除 {deleted} 行")
|
||||
|
||||
if self._dedup_cache is not None and dedup_pairs:
|
||||
await self._dedup_cache.remove_batch(dedup_pairs)
|
||||
|
||||
|
||||
async def _retry_with_backoff(
|
||||
self,
|
||||
func,
|
||||
ops: list[DBOperation],
|
||||
group_label: str,
|
||||
) -> None:
|
||||
max_attempts = max(1, self._max_retries)
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await func(ops)
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < max_attempts - 1:
|
||||
delay = _RETRY_BASE_DELAY * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"{group_label}失败,第 {attempt + 1}/{max_attempts} 次尝试"
|
||||
f"({delay:.2f}s 后重试): {e}"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.error(
|
||||
f"{group_label}失败,已尝试 {max_attempts} 次后放弃: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _dict_to_proxy(self, data: dict) -> Proxy:
|
||||
from datetime import datetime
|
||||
|
||||
return Proxy(
|
||||
id=data.get("id"),
|
||||
protocol=data.get("protocol", ""),
|
||||
ip=data.get("ip", ""),
|
||||
port=data.get("port", 0),
|
||||
username=data.get("username"),
|
||||
password=data.get("password"),
|
||||
region=data.get("region", "未知"),
|
||||
country=data.get("country", "未知"),
|
||||
province=data.get("province", "未知"),
|
||||
city=data.get("city", "未知"),
|
||||
region_en=data.get("region_en"),
|
||||
country_en=data.get("country_en"),
|
||||
province_en=data.get("province_en"),
|
||||
city_en=data.get("city_en"),
|
||||
delay_ms=data.get("delay_ms"),
|
||||
is_valid=data.get("is_valid", True),
|
||||
is_favorite=data.get("is_favorite", False),
|
||||
failure_count=data.get("failure_count", 0),
|
||||
real_ip=data.get("real_ip"),
|
||||
source_plugin=data.get("source_plugin", ""),
|
||||
created_at=(
|
||||
datetime.fromisoformat(data["created_at"])
|
||||
if data.get("created_at")
|
||||
else None
|
||||
),
|
||||
validated_at=(
|
||||
datetime.fromisoformat(data["validated_at"])
|
||||
if data.get("validated_at")
|
||||
else None
|
||||
),
|
||||
health_score=data.get("health_score", 0.0),
|
||||
success_count=data.get("success_count", 0),
|
||||
total_checks=data.get("total_checks", 0),
|
||||
anonymity_level=data.get("anonymity_level", "unverified"),
|
||||
anonymity_fallback=bool(data.get("anonymity_fallback", 0)),
|
||||
supports_https=data.get("supports_https", False),
|
||||
supports_http=data.get("supports_http", False),
|
||||
quality_assessed_at=(
|
||||
datetime.fromisoformat(data["quality_assessed_at"])
|
||||
if data.get("quality_assessed_at")
|
||||
else None
|
||||
),
|
||||
avg_delay_ms=data.get("avg_delay_ms"),
|
||||
delay_variance=data.get("delay_variance"),
|
||||
last_valid_at=(
|
||||
datetime.fromisoformat(data["last_valid_at"])
|
||||
if data.get("last_valid_at")
|
||||
else None
|
||||
),
|
||||
total_valid_seconds=data.get("total_valid_seconds", 0.0),
|
||||
probe_success_count=data.get("probe_success_count", 0),
|
||||
probe_total_count=data.get("probe_total_count", 0),
|
||||
country_code=data.get("country_code"),
|
||||
subdivision_code=data.get("subdivision_code"),
|
||||
geo_source=data.get("geo_source"),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
模块名称:modules.proxypool.core.data_cleaner
|
||||
功能描述:代理池数据库的按需维护:优化(ANALYZE / REINDEX / VACUUM)与统计查询,供面板的「数据库」页在用户操作时调用。
|
||||
职责边界:负责:数据库优化与统计查询;不负责:删除代理(见 auto_revalidator 的自动清理判据)、批量落库(见 core.data.write_queue)与调度触发。
|
||||
关键依赖:ConnectionPool(读连接池与 db_path)、aiosqlite。
|
||||
已知限制:
|
||||
1. VACUUM 不能在事务内执行,优化走 aiosqlite.connect 底层连接而非连接池的 acquire_writer。
|
||||
2. 优化持锁时长不受限;池内写方等待上限约 5 秒后报错,重试仍失败的操作会被丢弃。
|
||||
3. 异常收敛为返回值(空字典 / False)并记日志,不向外抛;调用方拿到空字典时需按「无数据」处理。
|
||||
4. db_size_mb 在库文件不存在时为 0。
|
||||
5. 各方法按需调用,没有内部调度,调用频率与时机由使用者决定。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataCleaner:
|
||||
def __init__(self, connection_pool: 'ConnectionPool'):
|
||||
self._pool = connection_pool
|
||||
logger.info("数据库维护器已初始化")
|
||||
|
||||
async def get_database_stats(self) -> dict:
|
||||
try:
|
||||
async with self._pool.acquire_reader() as db:
|
||||
stats = {}
|
||||
|
||||
async with db.execute("SELECT COUNT(*) FROM proxies") as cursor:
|
||||
stats['total_proxies'] = (await cursor.fetchone())[0]
|
||||
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM proxies WHERE is_valid = 1"
|
||||
) as cursor:
|
||||
stats['valid_proxies'] = (await cursor.fetchone())[0]
|
||||
|
||||
async with db.execute(
|
||||
"SELECT COUNT(*) FROM proxies WHERE is_valid = 0"
|
||||
) as cursor:
|
||||
stats['invalid_proxies'] = (await cursor.fetchone())[0]
|
||||
|
||||
import os
|
||||
if os.path.exists(self._pool.db_path):
|
||||
stats['db_size_mb'] = os.path.getsize(self._pool.db_path) / (1024 * 1024)
|
||||
else:
|
||||
stats['db_size_mb'] = 0
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取数据库统计信息失败: {e}", exc_info=True)
|
||||
return {}
|
||||
|
||||
async def optimize_database(self) -> bool:
|
||||
try:
|
||||
logger.info("开始优化数据库...")
|
||||
|
||||
async with aiosqlite.connect(self._pool.db_path) as db:
|
||||
await db.execute("PRAGMA busy_timeout=5000")
|
||||
await db.execute("ANALYZE")
|
||||
await db.execute("REINDEX")
|
||||
await db.execute("VACUUM")
|
||||
await db.commit()
|
||||
|
||||
logger.info("数据库优化完成")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库优化失败: {e}", exc_info=True)
|
||||
return False
|
||||