diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 3ab78cc..0000000 --- a/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -*.js linguist-language=python -*.css linguist-language=python -*.html linguist-language=python diff --git a/API-EN.md b/API-EN.md new file mode 100644 index 0000000..00386aa --- /dev/null +++ b/API-EN.md @@ -0,0 +1,2057 @@ +# ProxyCat API Reference + +This document is the **sole authoritative reference** for ProxyCat's public HTTP APIs, covering two API sets: + +- **Panel API** (`/api/*`) — the panel's own API, served by `app.py`. +- **Pool API** (`/api/pool/*`) — a Flask blueprint registered by `modules/proxypool_api.py`. + +The two APIs **share one port and one authentication scheme**, but their **error response conventions +differ** (the panel mostly returns `HTTP 200 + status: "error"`, while the pool uses a real status code +plus `error_code`). Both conventions are spelled out in this document; read +[1. General Conventions](#1-general-conventions) before making any call. + +> Older versions kept the Pool API's field details in `modules/proxypool/API.md`; that file has since +> been merged into this document and now holds nothing but a pointer to this page. + +## Table of Contents + +- [Group Navigation](#group-navigation) +- [1. General Conventions](#1-general-conventions) +- [2. Panel API](#2-panel-api) +- [3. Pool API](#3-pool-api) +- [4. End-to-End Examples](#4-end-to-end-examples) +- [5. Notes](#5-notes) +- [Appendix A: Error Code Reference](#appendix-a-error-code-reference) +- [Appendix B: Endpoint Index](#appendix-b-endpoint-index) +- [Appendix C: Field Dictionary](#appendix-c-field-dictionary) + +## Group Navigation + +| Group | Endpoints | Description | +|---|---|---| +| [1. General Conventions](#1-general-conventions) | — | Addresses, authentication, the two error conventions, parameter parsing rules, general limits | +| [2. Panel API](#2-panel-api) | 33 | The panel's own `/api/*`: status, configuration, logs, access records | +| [3. Pool API](#3-pool-api) | 38 | The pool's `/api/pool/*`: fetching, validation, tasks and database maintenance | + +Quick lookup by task: + +| I want to… | Go here | +|---|---| +| See which proxies the service is currently using | [Get Runtime Status](#get-runtime-status-get-apistatus) | +| Change the configuration | [Save Configuration](#save-configuration-post-apiconfig) | +| Find out why a request failed | [Query Access Records](#query-access-records-get-apilogsrecords) | +| See which upstream a domain goes through and how its success rate looks | [Query Domain Statistics](#query-domain-statistics-get-apilogsdomains) | +| Grab a working proxy for another program | [Get One Proxy](#get-one-proxy-get-apipoolrandom) | +| Trigger a fetch by hand | [Run a Plugin Immediately](#run-a-plugin-immediately-post-apipoolpluginsnamerun) | +| Check how a long-running task is doing | [Query Task Status](#query-task-status-get-apipooltaskstask_id) | + +--- + +# 1. General Conventions + +## Service Address and Ports + +- Default address: `http://localhost:5001`; the port comes from `[Server] web_port` in `config/config.ini`. +- The panel and the pool have **no separate processes or ports**; both run inside the ProxyCat process + and start and stop with it. +- The proxy listening port (default `1080`) is a separate port: it only accepts HTTP and SOCKS5 proxy + requests and **serves no HTTP API at all**. + +## Authentication + +**Except for the endpoints explicitly marked "no authentication" below, every API call must carry the token:** + +``` +GET http://localhost:5001/api/status?token= +``` + +The token lives in `[Server] token` in `config/config.ini`. **Validation reads only the query-string +parameter `token`**; it does not read request headers or cookies. + +**An empty token lets everything through**, i.e. authentication is off. Anyone who can reach the panel +port can then read and write the configuration (including upstream credentials), view access records, +and start or stop the service. Configure it this way only for local use; a token is mandatory if you +expose the port to a network. + +> **The `config/config.ini` shipped in the repository has a non-empty token**, so using a fresh clone +> as-is gets you a 401. See [Quick Deployment in the README](README-EN.md#quick-deployment). + +**Only 4 API endpoints skip authentication** (`/` and `/static/` also skip the token check, see +[Appendix B](#appendix-b-endpoint-index)): + +| Method | Path | +|---|---| +| GET | `/api/version` | +| GET | `/api/ads` | +| POST | `/api/ads/dismiss` | +| POST | `/api/ads/reopen` | + +A failed authentication always returns **HTTP 401**: + +```json +{ + "status": "error", + "message": "Invalid access token" +} +``` + +## Two Sets of Error Conventions + +**This is the spot in this document most likely to trip you up: the two APIs express errors differently +and must be treated separately.** + +| Situation | Panel `/api/*` | Pool `/api/pool/*` | +|---|---|---| +| Success | `200` + `status: "success"`; a few read-only endpoints **return data directly, with no `status` field** | `200` + data; `status` appears only on errors | +| Business failure | **`200`** + `status: "error"` + `message` | **A real status code** (4xx/5xx) + `status: "error"` + `message` + `error_code` | +| Parameter error | `400`, returned only by `POST /api/config` | `400` + `error_code: "invalid_parameter"` | +| Not authenticated | `401` + `status: "error"` | `401` + `status: "error"` (the same hook) | +| Pool not running / pool too slow | Not applicable | `503` `pool_unavailable` / `504` `pool_timeout` | + +Two hard rules follow: + +1. **Always check the HTTP status code and the `status` field together.** On the panel side, looking at + the status code alone misses business failures, and looking at `status` alone misses authentication + and pool-side errors. +2. **Programmatic branching may rely only on `error_code` (pool side) or the HTTP status code plus + endpoint-specific flags (panel side) — never on `message`.** The `message` text follows the current + UI language (`[Server] language`), so the same error returns different text under different languages. + +## Parameter Parsing Rules (Pool API Only) + +The pool's adapter layer parses parameters from the **handler function's signature**, under these rules: + +| Rule | Description | +|---|---| +| Query parameters are a **whitelist** | Only query parameters that appear in the handler signature are parsed; **unknown parameters are silently ignored**. A misspelled parameter name raises no error — you just get the unfiltered, full result | +| Type coercion | Driven by type annotations; only `bool` / `int` / `float` are recognized, everything else passes through as a string | +| Empty string | An empty string for a non-string parameter (e.g. `?status=`) is the same as **omitting** that parameter | +| Request body | Must be a JSON object; a missing required field returns `400 invalid_parameter`, with the field names listed in `message` | +| Path parameters | Such as `{id}` in `/api/pool/proxies/{id}/favorite`; parsed by the route itself | + +## General Limits and Truncation + +| Location | Limit / Default | Description | +|---|---|---| +| `limit` of `GET /api/logs` | Clamped to 1-2000, default 200 | Out-of-range values are silently clamped, with no error | +| `lines` of `GET /api/logs/file` | Clamped to 1-5000, default 500 | Same as above | +| `limit` of `GET /api/logs/records` | Clamped to 1-500, default 100 | Same as above; `since` defaults to "1 hour ago" | +| Access record export | At most 50000 records per call | The truncation flag exists only in the JSON metadata (`truncated`, `true` once the count reaches the limit); the CSV carries no such flag | +| Pool `page_size` / `limit` / export | All bound by the 50000 cap | When `limit` is present, `page` is forced to 1, equivalent to "take the first `limit` rows" | +| `GET /api/version` | Reads a cache, **makes no request** | The check is done at process start and by a background task every 24 hours | + +## Blocking and Concurrency + +The panel is served by **waitress with 16 threads**. On every pool call, **the current web thread stays +occupied until the pool returns a result**, up to 60 seconds (the HEAVY tier). So 16 concurrent large +batch operations will drag the panel into unresponsiveness. When calling heavy endpoints, keep +concurrency in check and give your client a long enough timeout. + +The pool's three internal timeout tiers (they surface in error codes): + +| Tier | Time limit | Used for | +|---|---|---| +| QUERY | 10 seconds | Queries, statistics, status | +| MUTATION | 20 seconds | Start/stop, single-record edits | +| HEAVY | 60 seconds | Import, export, database optimization, manual plugin runs, batch validation / deletion | + +--- + +# 2. Panel API + +## Pages and Static Assets + +### Open the Panel (GET /web) + +**Authentication: a token is required.** + +`GET /web` returns the panel's single page. The token check is the same as for the other APIs, so +**with a token set, visiting `http://127.0.0.1:5001/` directly ends in a 401 after the redirect**. The +correct address is: + +``` +http://127.0.0.1:5001/?token= +``` + +`GET /` itself performs no check; it simply carries `?token=` through unchanged when redirecting to `/web`: + +| Request | Response | +|---|---| +| `GET /?token=abc` | `302` → `/web?token=abc` | +| `GET /` (no token) | `302` → `/web` | + +`GET /static/` serves the front-end assets under `web/static/`. Requests carrying the `?v=` +query parameter return `Cache-Control: public, max-age=31536000, immutable` (every static reference in +the page carries it, so after a release the parameter changes and browsers naturally fetch the new files); +requests without it return `Cache-Control: no-cache`, revalidating against the origin on every load. + +## Runtime Status + +### Get Runtime Status (GET /api/status) + +Takes no request parameters. Returns a live snapshot of the outbound proxy service. + +```json +{ + "current_proxy": "http://1.2.3.4:8080", + "mode": "cycle", + "port": 1080, + "interval": 300, + "time_left": 123.4, + "switch_countdown_mode": "time", + "total_proxies": 10, + "active_proxies": [ + { "url": "http://1.2.3.4:8080", "inflight": 3, "active": 2, "capacity": 0, "failures": 0, "suspect": false, "failure_ratio": 0.0, "last_used": 12.5 } + ], + "proxy_source_mode": "local", + "exit_count": 5, + "elastic_active": true, + "auth_required": false, + "display_level": 1, + "service_status": "running", + "language": "cn", + "request_interval": 0, + "pool_running": true, + "source_error": null +} +``` + +| Field | Description | +|---|---| +| `current_proxy` | The upstream exit **assigned most recently** (masked) — not "the current proxy": with several exits, requests are spread across the whole group by load; when a local source has no usable exit this is the localized "none" text, and for non-local sources it is an empty string in that case. See `active_proxies` for the state of the whole group | +| `mode` | The run mode. **The same key means two different things depending on the source**: the local source asks "who to pick" — `cycle` (down the list in order, ignoring load) / `loadbalance` (whichever is under the least pressure goes first); the API and pool sources ask "when to rotate" — `continuous` (continuous rotation) / `request` (on-request rotation). The value is normalized per source; after a source switch, a value that does not belong to the new group falls back to that group's default | +| `port` | The **actually bound** proxy port (taken from the listening socket); falls back to the configured value when the service is not running | +| `interval` | The time-based rotation interval (seconds) | +| `time_left` | **How much longer the soonest-expiring exit has left**; the unit follows `switch_countdown_mode`; `0` when the roster is empty. Whether exits are replaced by requests or proactively by the background depends on `mode`: under `continuous` (continuous rotation) the background replaces on every tick, so the number keeps counting down while idle; under `request` (on-request rotation) and the local source, exits that expire while idle are removed and the pool may be empty | +| `switch_countdown_mode` | The countdown measure: `time` (seconds, by each exit's own lifetime; reported when both `interval` and `request_interval` are set — seconds can draw a progress bar, and the remaining request count is given per exit by `requests_left`) / `request_count` (only `request_interval` is set; reports how many requests remain) / `per_request` (neither is set; restocks only when exits are missing or invalid). **`none` is never returned anymore**: lifetime applies to every source and all four rotation modes | +| `total_proxies` | The number of exits currently in use. The target is `exit_count` (`[Server] exit_count`); the number falls below the target when an exit is judged invalid or has not been replaced yet, and rises above it during peak auto-expansion (when `elastic_active` is also true) | +| `active_proxies` | Live state of each upstream exit: `url` (masked), `active` — **the number of connections currently using this exit** (requests in flight plus tunnels that recently relayed data; this is the "in use" figure shown by the panel and the CLI), `inflight` — connections still holding a slot (idle tunnels included, always ≥ `active`), `capacity` — the per-exit active connection allowance (`0` means unlimited; idle tunnels don't count), `failures` — consecutive failures, `suspect` — whether recent failures dominate (such exits give way first but remain usable), `failure_ratio` — the recent failure share, `expires_in` — remaining lifetime in seconds, `requests_left` — requests left to serve (both are `null` when that measure is not enabled), `last_used` / `added_at` / `expires_at` / `requests_served` (`time.monotonic()` values and counters, for comparison within the same process only) | +| `proxy_source_mode` | Proxy source: `local` / `api` / `pool` | +| `exit_count` | The exit pool's target capacity (`[Server] exit_count`); read it together with `total_proxies`. **When rotation happens depends on the mode**: under continuous rotation the pool holds this many even while idle; under request-driven rotation and the local source, exits that expire while idle are removed rather than replaced and the pool may drain, until the next request fetches a fresh set to fill it | +| `elastic_active` | Whether the exit count is above the target: a burst or peak auto-expansion is in progress, or the expansion has stopped but the surplus exits have not yet reached the end of their lifetime (each is retired one by one only after serving out its full lifetime) | +| `auth_required` | Whether the outbound proxy requires username / password authentication | +| `display_level` | Console verbosity (`[Server] display_level`) | +| `service_status` | `running` / `stopped` | +| `language` | The current UI language | +| `request_interval` | The threshold for switching by request count (an assignment count, not the number of requests served successfully). It takes effect together with `interval`; whichever threshold is reached first triggers the rotation | +| `pool_running` | Whether the proxy pool is running | +| `source_error` | The reason and time of the most recent failure to fetch exits from the source (`{"message": ..., "at": ...}`); `null` when nothing has failed | + +## Configuration Management + +### Read Configuration (GET /api/config) + +Returns every key and value of the `[Server]` and `[Pool]` sections. + +```json +{ + "status": "success", + "server": { "port": "1080", "web_port": "5001", "mode": "cycle" }, + "pool": { "validator.timeout_seconds": "10" } +} +``` + +- **The `token` key is stripped out** and never appears in the response — the panel has no need to + echo it back, keeping it out of the page source, browser cache and frontend logs. +- `[Users]` and `[api_credentials]` are not returned here; each has its own endpoint. +- Boolean values are normalized to `"true"` / `"false"`: `yes` / `on` / `1` in `config.ini` all + count as true at runtime, but the UI recognizes only `"true"`, and without normalization the + checkbox would show as unchecked — a single casual save by the user would switch the option off. + +### Save Configuration (POST /api/config) + +Either section in the request body may be omitted, but at least one must be non-empty: + +```json +{ + "server": { "mode": "loadbalance", "interval": "600" }, + "pool": { "validator.timeout_seconds": "15" } +} +``` + +Key names are validated against a whitelist; unknown keys and invalid values are rejected with **HTTP 400**. + +> `token` is also a valid `server` key: **the panel provides no editor for it, but this API accepts +> it** — so to change the token you can either call `POST /api/config` (effective immediately) or +> edit `config.ini` directly and restart the process. + +```json +{ + "status": "success", + "port_changed": false, + "web_port_changed": false, + "service_status": "running", + "applied": { "proxy": true, "pool": true } +} +``` + +| Field | Description | +|---|---| +| `port_changed` | Whether the proxy listening port changed. Hot-reloading the configuration does not rebind ports by itself; **the panel restarts the outbound proxy service based on this flag** (`restart` of `POST /api/service`) to move to the new port. When using the command-line entry point, the process must be restarted instead | +| `web_port_changed` | Whether the panel port changed. **The entire process must be restarted** for it to take effect; the current panel still runs on the old port | +| `applied.proxy` | Whether the new configuration was pushed to the outbound proxy service | +| `applied.pool` | Whether the new configuration was pushed to the proxy pool | + +An entry of `false` under `applied` means **the configuration was written to disk but the push +failed**; the response itself is still `success`. In that case, go by `applied` to judge whether +the change really took effect. + +On a validation failure (HTTP 400), besides `message` the response carries `details`, naming the +offending key and the reason rendered in the current UI language; the panel uses it to attach the +hint to the specific input box: + +```json +{ + "status": "error", + "message": "Invalid option port (current value: 70000): must not be greater than 65535", + "details": { "key": "port", "reason": "must not be greater than 65535" } +} +``` + +`details` is present only when both `key` and `reason_key` are set (validation exceptions for +`[Server]` and `[Pool]` all carry these two attributes); other exceptions have only `message`. +`message` is always retained, so older clients are unaffected. + +When saving the configuration, if the current proxy source is `pool`, no external pool address +(`pool_remote_url`) is configured and the pool is not running, the pool service is started +automatically along the way. + +## Language + +### Switch UI Language (POST /api/language) + +The language is a **server-side setting**, not a browser setting: it is written back to +`[Server] language` in `config.ini` and switches the wording of the whole process (including the +panel, the console banner, API `message` fields, and the headers and status columns of exported +files). + +```json +{ "language": "en" } +``` + +| Field | Type | Required | Values | +|---|---|---|---| +| `language` | string | No | `cn` / `en`; defaults to `cn` | + +```json +{ "status": "success", "language": "en" } +``` + +A value outside `cn` / `en` **still returns HTTP 200**; judge by `status`: + +```json +{ "status": "error", "message": "Unsupported language" } +``` + +> This endpoint follows the panel-side convention that business failures return 200 — do not go by +> the status code alone. + +## Local Proxies and Lists + +### Read / Write the Local Proxy List (GET/POST /api/proxies) + +**GET** returns the contents of the proxy list file under `config/`. + +```json +{ "proxies": ["http://1.2.3.4:8080", "socks5://5.6.7.8:1080"] } +``` + +A read failure returns an error, **not** an empty list — an empty list would make the UI show "no +proxies at all", and a single casual save by the user would wipe the proxy file: + +```json +{ "status": "error", "message": "Failed to load proxy file: ..." } +``` + +**POST** overwrites the whole file and hot-loads the outbound service immediately (without +re-checking availability): + +```json +{ "proxies": ["http://1.2.3.4:8080"] } +``` + +`proxies` **must be an array** (a string is torn into lines character by character, and an object +writes only its key names — either way the file is overwritten with garbage), otherwise: + +```json +{ "status": "error", "message": "The proxy list must be an array" } +``` + +The write may also fail when the file is held by another process (common on Windows): + +```json +{ "status": "error", "message": "..." } +``` + +### Check Proxy Availability (GET /api/check_proxies) + +| Parameter | Required | Default | Description | +|---|---|---|---| +| `test_url` | No | `https://www.baidu.com` | Target URL for the liveness check | + +Checks the **source list** entry by entry, with the concurrency taken from `[Server] check_concurrency`: + +- `local` source: every entry in `proxy_file` — the active roster is deliberately not checked, + because an exit that has been disabled by failures disappears from the roster, and those are + exactly the ones that most need a re-check. +- `api` / `pool` sources: there is no readable local list, so the exits currently in use are checked. + +```json +{ + "status": "success", + "valid_proxies": ["http://1.2.3.4:8080"], + "checked": 3, + "total": 1, + "message": "Proxy check completed, valid proxies: 1" +} +``` + +| Field | Description | +|---|---| +| `valid_proxies` | Addresses that passed the check | +| `checked` | How many were actually checked this time (the number of source-list entries) | +| `total` | The number that passed the check | + +The check only reports its findings and does not change the active roster — to put newly validated +exits into service, simply wait for the next batch refresh. + +The check takes longer as the proxy count grows, so the request may wait a while (internal timeout +30 seconds). + +### Read / Write the IP Whitelist / Blacklist (GET/POST /api/ip_lists) + +**GET**: + +```json +{ "whitelist": ["127.0.0.1"], "blacklist": ["1.2.3.4"] } +``` + +**POST**: + +```json +{ "type": "whitelist", "list": ["127.0.0.1"] } +``` + +| Field | Description | +|---|---| +| `type` | `whitelist` or `blacklist`; any other value returns an error | +| `list` | The complete list; written as a full overwrite | + +After the file is written, the outbound service is hot-loaded immediately — no restart needed. +Both lists validate the **client source IP** (the endpoint connecting to the proxy port): the +decision is made first as each new connection is established, and a client that fails it gets a +403 directly plus a logged "Unauthorized IP attempt" — the connection is not forwarded. When a +client matches both lists, the priority is set by `[Server] ip_auth_priority` (`whitelist` allows +/ `blacklist` blocks). + +### Read / Write the Bypass List (GET/POST /api/bypass_whitelist) + +**GET**: + +```json +{ "list": ["192.168.0.0/16"] } +``` + +**POST**: + +```json +{ "list": ["192.168.0.0/16"] } +``` + +**Target addresses** on the list are not forwarded through any upstream; they are connected to +directly from this host (taking no part in exit scheduling). This is a different matter from the +client-source-IP whitelist / blacklist above: adding a target to this list does not skip the +client-IP whitelist / blacklist check. + +## Service Control + +### Start / Stop the Outbound Proxy Service (POST /api/service) + +```json +{ "action": "start" } +``` + +| action | Description | +|---|---| +| `start` | Starts the service if it is not running; returns success directly if it is already running | +| `stop` | Stops the service | +| `restart` | Stops first, then starts | + +```json +{ "status": "success", "message": "Service started successfully", "service_status": "running" } +``` + +`start` / `stop` responses carry `service_status` (values `running` / `stopped`); a successful +`restart` does not carry that field. + +**This endpoint is serialized and has timeouts**: a start waits for the old thread to exit (up to +10 seconds) and then for the service to be ready (up to 5 seconds); a stop waits up to 10 seconds. +On timeout it returns `status` as `error` and **will not silently fall back to another port**. +Callers should therefore allow a sufficiently long request timeout. + +When the port is in use, it returns an error and does not start the service: + +```json +{ + "status": "error", + "message": "Port 1080 is already in use by another program, please choose a different port and retry", + "service_status": "stopped" +} +``` + +### Manually Switch the Upstream Proxy (GET /api/switch_proxy) + +Discards the existing standby entries and calls the source once immediately: **if the pool is not +full it is topped up; when exit lifetime is enabled (`interval` or `request_interval` non-zero), a +full pool has its soonest-expiring exit replaced** (when only `request_interval` is filled in, +exits have no time-based lifetime, so what actually gets replaced is the first entry in the +roster). This is not "switching to the next IP" (all exits are already in use at the same time), +nor a wholesale replacement. The response has four forms: + +**Switch succeeded** + +```json +{ + "status": "success", + "current_proxy": "http://1.2.3.4:8080", + "active_proxies": [ + { "url": "http://1.2.3.4:8080", "inflight": 0, "active": 0, "capacity": 0, "failures": 0, "suspect": false, "failure_ratio": 0.0, "last_used": 0.0 } + ], + "total_proxies": 1, + "message": "Upstream exit batch refreshed" +} +``` + +| Field | Description | +|---|---| +| `current_proxy` | The current exit (the most recently handed-out exit, masked); when it is replaced, falls back to the first entry in the roster | +| `active_proxies` | A snapshot of the current roster; field meanings are the same as `/api/status` | +| `total_proxies` | Number of exits in the current roster | + +"Success" is judged by whether the **roster is non-empty** after the refresh — if it was already +full and only one exit was rotated this time, that still counts as success. + +**In the cooldown period** (too soon after the last switch; no switching within `[Server] switch_cooldown`) + +```json +{ + "status": "error", + "cooldown": true, + "cooldown_remaining": 12, + "message": "..." +} +``` + +**A switch is already in progress** + +```json +{ "status": "error", "switching": true, "message": "..." } +``` + +**Switch failed** + +```json +{ "status": "error", "current_proxy": "...", "message": "..." } +``` + +Callers should use the `cooldown` / `switching` fields to tell "not switching right now" apart from +"genuinely failed"; both are `status: error`. + +## Logs + +By purpose, logs fall into four categories: `access` (proxy access), `proxy` (proxy lifecycle), +`main` (main program) and `pool` (proxy pool). Of these, `access` / `proxy` / `main` have +corresponding log files, while `pool` only goes into the in-memory ring buffer and `error.log`; its +own files live under `modules/proxypool/logs/` and are not browsed through this group of endpoints. + +### Query In-Memory Logs (GET /api/logs) + +| Parameter | Required | Default | Description | +|---|---|---|---| +| `category` | No | `all` | `all` / `access` / `proxy` / `main` / `pool`; any other value returns an error | +| `start` | No | `0` | **Number of entries to skip backwards from the newest one** | +| `limit` | No | `200` | Number of entries to return, clamped to 1-2000 | +| `level` | No | `ALL` | Filter by level | +| `search` | No | empty | Keyword filter | + +```json +{ + "logs": [ + { + "seq": 8821, + "time": "2026-09-30 12:00:00", + "level": "INFO", + "category": "main", + "message": "..." + } + ], + "total": 1234, + "status": "success" +} +``` + +- `seq` is a **globally incrementing sequence number, unique across categories**; when several + categories are merged into a single response, it restores their true order. Entries are listed in + ascending chronological order. +- `total` is the **total after filtering**, not the number of entries returned this time. +- The semantics of `start` are the opposite of ordinary pagination: it is an offset counted back + from the newest entry, and together with `limit` it is used to "page back through older logs". + +### Log Statistics (GET /api/logs/stats) + +```json +{ + "status": "success", + "stats": { + "total": 170, + "levels": { "INFO": 150, "WARNING": 20 }, + "categories": { "main": 100, "access": 50, "proxy": 20, "pool": 0 }, + "capacity": { "main": 20000, "access": 5000, "proxy": 20000, "pool": 20000 }, + "first_time": "2026-09-30 10:00:00", + "last_time": "2026-09-30 12:00:00" + } +} +``` + +`total` is the combined count of the four rings, and `capacity` is each ring's capacity (the +`access` ring is smaller). `first_time` / `last_time` are absent when the buffers are empty. + +### List Log Files (GET /api/logs/files) + +```json +{ + "status": "success", + "files": [ + { "name": "main.log", "category": "main", "size": 20480, + "modified": "2026-10-02 15:30:00", "current": true, "rotated": false } + ] +} +``` + +| Field | Description | +|---|---| +| `name` | File name (the panel uses it to call `/api/logs/file`) | +| `category` | `main` / `proxy` / `access` / **`error`**. `error` is the aggregated file for ERROR and above, and **must not** be passed as the `category` parameter of `GET /api/logs` | +| `size` / `modified` | Size in bytes / last modified time | +| `current` | Whether this is the one currently being written; `rotated` is its opposite (a rotated historical copy) | + +Only whitelisted files under the `logs/` directory are listed (rotated copies included); the proxy +pool's own `proxy_pool.log` is not included. + +### Read a Log File (GET /api/logs/file) + +| Parameter | Required | Default | Description | +|---|---|---|---| +| `name` | Yes | empty | File name; must be a name from the whitelist | +| `lines` | No | `500` | Number of trailing lines to read, clamped to 1-5000 | + +```json +{ "status": "success", "name": "main.log", "lines": ["..."], "truncated": true } +``` + +`truncated` of `true` means the file's line count is **no fewer than** the requested count (it is +also `true` when they are exactly equal), and only the trailing part was returned. A file name that +is not on the whitelist returns an error (**validated against the whitelist rather than by path +concatenation, ruling out directory traversal**). + +### Export Logs (GET /api/logs/export) + +| Parameter | Required | Description | +|---|---|---| +| `file` | No | When a file name is specified, **downloads that file as an attachment**; when omitted, exports the in-memory logs | +| `category` | No | Takes effect when `file` is omitted; same as `GET /api/logs` | +| `level` / `search` | No | As above | + +When `file` is omitted, a plain-text attachment named `proxycat_export.log` is returned. + +### Clear Logs (POST /api/logs/clear) + +```json +{ "category": "all" } +``` + +Clears the in-memory ring buffer of the given category and truncates the corresponding log file. +When `category` is `all`, everything is cleared. + +```json +{ "status": "success", "message": "Logs cleared" } +``` + +**This deletes log content on disk** and cannot be undone. + +## Access Records + +Records every proxy request individually. What sets it apart from Logs is that it is built for querying and statistics: stored in columns, filterable and exportable. + +### Query Access Records (GET /api/logs/records) + +| Parameter | Required | Default | Description | +|---|---|---|---| +| `since` | No | 1 hour ago | Start time; accepts `YYYY-MM-DD`, `YYYY-MM-DD HH:MM`, `YYYY-MM-DD HH:MM:SS`, and the `T` separator as well | +| `until` | No | No limit | End time; when given at minute or date granularity it is padded to the last second of that moment | +| `outcome` | No | Any | `success` / `failure` / `aborted`; any other value returns an error | +| `upstream` | No | Any | Filter by upstream proxy | +| `host` | No | Any | Filter by target host | +| `search` | No | Empty | Keyword filter | +| `limit` | No | `100` | Clamped to 1-500 | +| `offset` | No | `0` | Pagination offset | +| `order` | No | `desc` | Time sort direction | + +```json +{ + "status": "success", + "enabled": true, + "range": { "since": "2026-09-30 11:00:00", "until": "" }, + "outcome_counts": { "success": 100, "failure": 3, "aborted": 1 }, + "total": 104, + "export_limit": 50000, + "records": [ + { + "id": 12345, + "ts": "2026-09-30 12:00:00", + "kind": "http", + "method": "GET", + "client": "127.0.0.1/-", + "client_ip": "127.0.0.1", + "client_user": "-", + "host": "example.com", + "port": 443, + "target": "example.com:443", + "upstream": "http://1.2.3.4:8080", + "upstream_label": "http://1.2.3.4:8080", + "real_ip": "203.0.113.7", + "outcome": "success", + "status_code": 200, + "elapsed_ms": 123, + "reason": "" + } + ] +} +``` + +- `id` is the auto-increment primary key; `client` (`client_ip/client_user`) and `target` (`host:port`) are **derived fields** — they merely join the two adjacent columns into a form the UI can use directly, and come from the same source as `client_ip` / `host`. +- When `upstream` holds a special value, `upstream_label` supplies a readable label: direct (`direct`) and unknown upstream (`unknown`) each have their own value. +- On failure (`outcome=failure`) `status_code` is always empty: rows that failed only after the tunnel was established do not appear here carrying the 200 from connection setup (such rows were once stored in the database; reads normalize them uniformly). +- `real_ip` is **the egress IP this exit actually uses** (the one the target site sees); for a gateway-type exit its address is a different thing. It is a display field: for the pool source it comes from the verification performed when the pool accepts the proxy, for the api source it is produced by a background probe when `[Server] access_records_real_ip_probe` is on, and for the local source it is always an empty string. When probing fails or the setting is off it is `""` and the UI renders `--`; direct requests are also `""`. It **does not participate** in exit scheduling, lifetime or failure decisions, and historical records in an older database are likewise `""`. +- The response returns three persistence-status fields alongside `records`: `pending` (buffered rows not yet flushed), `dropped` (rows discarded because the buffer filled up), `oldest_ts` (timestamp of the oldest row in the database; an empty string means none). A non-zero `dropped` means flushing cannot keep up and records are being lost. + +### Export Access Records (GET /api/logs/records/export) + +The filter parameters are the same as for the query endpoint, plus one more: + +| Parameter | Required | Default | Description | +|---|---|---|---| +| `format` | No | `csv` | `csv` or `json`; any other value returns an error | + +- **CSV**: carries a UTF-8 BOM so Excel opens it without mojibake; **the header row is rendered in the current UI language**. +- **JSON**: UTF-8, with `exported_at`, the filter conditions, `count` and `truncated` metadata. + +Both formats are capped by `export_limit` (50000); the truncation flag exists only in JSON's metadata (`truncated`, `true` once the number of rows fetched reaches 50000) — CSV has just the body and no such flag. The response is an attachment download, with a filename like `access_records_20260930_120000.csv`. + +### List Filter Options (GET /api/logs/records/options) + +Returns the upstream proxy and target host values that appear in the current records, for the frontend to render dropdowns. + +```json +{ "status": "success", "upstreams": [{ "value": "http://1.2.3.4:8080", "label": "..." }] } +``` + +### Clear Access Records (POST /api/logs/records/clear) + +```json +{ "status": "success", "removed": 1234, "message": "Cleared 1234 access records" } +``` + +**Irreversible.** + +## Domain Statistics + +Accumulates success and failure counts per "target domain × upstream proxy". The difference from Access Records: this is an aggregate count while access records are per-request detail; clearing access records therefore leaves these totals intact, and vice versa. + +### Query Domain Statistics (GET /api/logs/domains) + +| Parameter | Required | Default | Description | +|---|---|---|---| +| `upstream` | No | Empty | **When given, drills down to the domain detail for that upstream; when omitted, aggregates by upstream** | +| `sort` | No | `last_seen` | Sort field | +| `order` | No | `desc` | Sort direction | +| `search` | No | Empty | Keyword filter | +| `limit` | No | `100` | Clamped to 1-1000 | +| `offset` | No | `0` | Pagination offset | + +**`upstream` omitted — aggregate by upstream:** + +```json +{ + "status": "success", + "enabled": true, + "summary": { "pairs": 42, "hosts": 12, "upstreams": 5, "success": 3400, "failure": 56 }, + "mode": "proxies", + "proxies": [ + { + "upstream": "http://1.2.3.4:8080", + "upstream_label": "http://1.2.3.4:8080", + "host_count": 7, + "success": 100, + "failure": 2, + "total": 102, + "rate": 98, + "first_seen": "2026-09-30 10:00:00", + "last_seen": "2026-09-30 12:00:00", + "last_error": "" + } + ], + "total": 1 +} +``` + +**`upstream` given — drill down to domains:** + +```json +{ + "status": "success", + "enabled": true, + "summary": { "pairs": 42, "hosts": 12, "upstreams": 5, "success": 3400, "failure": 56 }, + "mode": "domains", + "upstream": "http://1.2.3.4:8080", + "upstream_label": "http://1.2.3.4:8080", + "domains": [ + { + "host": "example.com", + "upstream": "http://1.2.3.4:8080", + "success": 50, + "failure": 1, + "first_seen": "2026-09-30 10:00:00", + "last_seen": "2026-09-30 12:00:00", + "last_status": 200, + "last_error": "" + } + ], + "total": 1 +} +``` + +- In `summary`, `pairs` is the number of "domain × upstream" combinations; `rate` is the success rate as a rounded percentage. +- **Use the returned `mode` field to tell the two shapes apart — don't guess.** +- `enabled` being `false` means the domain statistics feature is off (`[Server] domain_stats_enabled`), in which case there is no data to query. + +### Clear Domain Statistics (POST /api/logs/domains/clear) + +```json +{ "status": "success", "removed": 42, "message": "Cleared access statistics for 42 domains" } +``` + +## Users and API Credentials + +### Read / Write Outbound Proxy Accounts (GET/POST /api/users) + +**GET**: + +```json +{ "status": "success", "users": { "neko": "123456" } } +``` + +A failed read returns `status: "error"` with `users: null`, **not an empty object** — an empty object makes the UI show "no users at all", and the next time an administrator adds a user and saves, that empty table overwrites the original accounts, which amounts to turning outbound authentication off. **The response contains plaintext passwords.** + +**POST** replaces the whole `[Users]` section: + +```json +{ "users": { "neko": "123456" } } +``` + +**An empty object turns outbound authentication off** (when the account table is empty, `auth_required` is `false`). + +```json +{ "status": "success", "message": "Users saved successfully" } +``` + +### Manage Upstream API Credentials (GET/POST /api/api_credentials) + +Stores multiple credential sets for the upstream proxy-fetch endpoint and designates the one currently in effect. When you switch the active set with `set_active`, or when the set you delete is the active one, the **`[Server] api_proxy_url` / `proxy_username` / `proxy_password` keys are rewritten in step**, so with the proxy source set to `api` the new credentials take effect immediately; `save` does not sync those three keys — it only updates the credential collection and the active-set marker. For newly saved values to actually be used by proxy sourcing, call `set_active` on them once more (or let the automatic switch triggered by deleting the active set) write the three keys back into `[Server]`. + +**GET**: + +```json +{ + "status": "success", + "sets": [{ "name": "闪臣", "url": "https://...", "username": "", "password": "" }], + "active_credential": "闪臣" +} +``` + +**The response contains plaintext credentials.** + +**POST**: + +```json +{ "action": "save", "name": "闪臣", "url": "https://...", "username": "", "password": "" } +``` + +| action | Required fields | Description | +|---|---|---| +| `save` | `name`, `url`, `username`, `password` | Updates the entry by `name` if it exists, otherwise adds one; an empty `name` returns an error. If there is no active set yet, or the active set is this very entry, it is made the active set | +| `delete` | `name` | Deletes by `name`; if the entry deleted is the active set, the first remaining entry is made active **and its three keys are written back to `[Server]` in step** (with no entries left, the three keys are cleared) — without the switch, the running service would keep using the deleted endpoint | +| `set_active` | `name` | Switches the active set and writes its `url`/`username`/`password` into `[Server]`; if `name` is not in the credential collection it returns an error and never writes a "nothing selected" intermediate state | + +`save` and `delete` return the updated `sets` and `active_credential`; `set_active` returns `active_credential` and the active entry's `credential` but **does not return `sets`**. An unknown `action` and an empty `name` return an error (`{ "status": "error", "message": "..." }`), as does `set_active` when its `name` is not in the credential collection. Passing a `name` that does not exist to `save` adds a new entry; `delete` does not check whether the name exists — deleting a credential name that does not exist returns `success` all the same (`sets` returned unchanged). + +## Version Check and Ads + +> **The endpoints in this section require no authentication**; even when a token is configured, +> you do not need to send it. + +### Check Version (GET /api/version) + +Returns the result of the **most recent** version check. **This endpoint only reads a cache and +never makes an external request** — calling it never incurs a network wait. + +```json +{ + "status": "success", + "is_latest": true, + "current_version": "ProxyCat-V3.0.0", + "latest_version": "ProxyCat-V3.0.0" +} +``` + +| Field | Description | +|---|---| +| `is_latest` | Whether the remote version is no newer than the current version; **recomputed against the current `CURRENT_VERSION` on every read** | +| `current_version` | The version of this process | +| `latest_version` | The remote version fetched by the most recent check | + +**The check cadence is up to the process itself, not triggered by this endpoint:** + +- **Once at process startup**, then **once every 24 hours**. +- **A restart within 24 hours does not re-check**: the time of the last check is written to + `logs/version_check.json` and read back after a restart to be reused. Both entry points + (`app.py` and `ProxyCat.py`) follow this cadence. +- **The time is recorded whether the check succeeds or fails**, so at most one external request + is made every 24 hours; when the network is down, the "every panel open waits out one timeout" + situation no longer happens. +- The check runs on a background thread: it does not block startup, and it does not occupy a web + worker thread. + +**When there is neither a successful result nor a failure record** (the first check after startup +has not finished yet), it returns: + +```json +{ + "status": "error", + "message": "Version check has not completed yet" +} +``` + +**When the check fails** (no network, remote page structure changed) it returns `status` `error` +with a `message` explaining the cause; but if a check has succeeded before, it **keeps returning +the last successful result** — the panel is not a monitoring tool, and showing the last known +version state is more useful than showing an error. The failure itself is written to the logs. + +> After upgrading to a new version, a re-check happens immediately even if less than 24 hours +> have passed since the last one: the state file stores "the current version at the time of the +> check", and a mismatch with the version in the process counts as stale. + +### Get Ads (GET /api/ads) + +Reads every `*.json` in the `config/ads/` directory and returns the entries whose `enabled` is +not `false`. Files that fail to parse are skipped silently. + +```json +{ + "status": "success", + "ads": [ + { + "id": "example", + "title": "...", + "body": "...", + "image_url": "", + "link_url": "", + "link_text": "", + "display_time": 15 + } + ], + "total": 1, + "dismissed": false +} +``` + +`dismissed` is the **in-process dismissed state; it is lost on restart**. + +### Dismiss / Reopen Ads (POST /api/ads/dismiss, POST /api/ads/reopen) + +Both endpoints take no parameters and require no authentication; the response body is +`{ "status": "success", "dismissed": }`, where dismiss sets `true` and reopen sets +`false`: + +```json +{ "status": "success", "dismissed": true } +``` + +--- + +# 3. Pool API + +They all live under the `/api/pool` prefix, and **authentication is the same as the panel's** +(the same `before_request` hook). + +## Pool API Conventions + +### Success Responses Have No Uniform Envelope + +The adapter layer serializes the handler's return value **straight into the response body**, so: + +- `GET /api/pool/get` returns a **JSON array** (not `{"status": ..., "data": [...]}`). +- `GET /api/pool/count` returns `{"count": 12}`, **with no `status` field**. +- `GET /api/pool/random?format=text` and the export endpoints return **plain text or an + attachment**, not JSON. +- Only some endpoints carry their own `status` and `message` in the return value (such as the + lifecycle endpoints); plugin operations return `success` and `message`, with no `status` field. + +**To tell success apart, read the HTTP status code and the response content itself; do not assume +a `status` field is always present.** + +### Error Responses + +```json +{ + "status": "error", + "message": "Error description", + "error_code": "invalid_parameter" +} +``` + +| error_code | HTTP | Meaning | +|---|---|---| +| `invalid_parameter` | 400 | Invalid request parameters (including missing required fields and out-of-range values) | +| `api_error` | 4xx / 5xx | A business error, such as a proxy not existing or a spec mismatch. The HTTP status code comes with the error itself (e.g. plugin operation failure and backup restore failure are 500, geolocation resolution service not enabled is 503) — do not hard-code 4xx branches | +| `pool_running` | 409 | The pool is running and this operation is not allowed (**only backup restore** returns this) | +| `pool_unavailable` | 503 | The pool is not running, or is restarting | +| `pool_timeout` | 504 | The pool response timed out | +| `queue_full` | 503 | The write queue is full and the request was rejected (backpressure) | +| `internal_error` | 500 | Internal server error | + +**Two exceptions to be aware of:** + +- When `POST /api/pool/start|stop|restart` fails, it returns **500 with no `error_code`**, only + `status` / `message` / `is_running`. +- `message` follows the UI language and **must not be used for programmatic decisions**. + +### When the Pool Is Not Running + +While the pool has not started, apart from the status query, the lifecycle endpoints, backup +listing/restore and `/schema`, **every other endpoint returns `503 pool_unavailable`**. Callers +should first query `GET /api/pool/status` to confirm `is_running`. + +### Task Model + +Every long-running operation (batch validation, import, fetching, geolocation update/recompute) +**returns a `task_id` immediately**; the actual execution is in the background, and progress is +polled by `task_id`. + +- Task state is **in-process memory state**: all of it is lost when the process restarts. +- Completed/failed tasks are retained for **1 hour** (TTL) and are evicted after that; the status + table holds at most 200 entries. +- The `status` returned at creation (`queued` / `running`) **is only a snapshot of the creation + moment and does not represent the execution result**; entries in the task table are written + straight as `running` from creation, and no entry is ever in the `queued` state. +- The states that actually appear in the task table: `running` / `completed` / `failed` / + `cancelled` (`queued` appears only in some creation responses — filtering tasks by it will + never match an entry). + +### No Endpoint for Reporting Usage Feedback + +"Usage feedback" is what the outbound proxy reports back to the pool **inside the process** +during real forwarding (see the +[Proxy Pool Mechanics](ProxyCat-Manual/Features-EN.md#proxy-pool-mechanics) section in +Features); **there is no HTTP endpoint for reporting a usage outcome**, so do not go looking +for one. + +## Pool Status and Lifecycle + +### Get Pool Status (GET /api/pool/status) + +```json +{ + "status": "success", + "is_running": true, + "last_error": null, + "stats": { + "total_proxies": 120, + "valid_proxies": 86, + "invalid_proxies": 34, + "anonymity_fallback_proxies": 5, + "protocol_distribution": { "http": 60, "socks5": 26 }, + "region_distribution": { "中国": 40, "美国": 12 } + } +} +``` + +| Field | Description | +|---|---| +| `is_running` | Whether the pool is running | +| `last_error` | Text of the most recent error; `null` when there is none | +| `stats` | Global statistics; **`null` while the pool is not running**. A failure of the statistics themselves does not stop this endpoint from returning success — `stats` is simply `null` | +| `anonymity_fallback_proxies` | Number of proxies whose anonymity was judged as the fallback tier | +| `region_distribution` | Only the 10 regions with the most proxies | + +### Get Pool Config Field Schema (GET /api/pool/schema) + +Returns the field groups, types, descriptions and value ranges of the `[Pool]` section; **the panel's "Pool Settings" form is rendered from it**. +The wording follows the current UI language. + +```json +{ + "status": "success", + "groups": [ + { + "title": "Database", + "sub": "Where data lives and how it is backed up", + "id": "database", + "fields": [ + { "key": "database.path", "doc": "File holding all pool data (SQLite)", "type": "text", + "title": "Database file path", "hint": "Relative paths start from the proxypool directory; changes need a pool restart" }, + { "key": "database.pool_max_readers", "doc": "...", "type": "int", "low": 1, "high": 64, + "title": "Max read connections", "tier": "advanced" }, + { "key": "database.backup_interval_hours", "doc": "...", "type": "int", "low": 1, + "title": "Backup interval", "unit": "h", "tier": "advanced", "visible_when": "database.backup_enabled=true" } + ] + } + ] +} +``` + +| Field | Description | +|---|---| +| `type` | Field type, one of `bool` / `int` / `float` / `list` / `text` (string fields are always `text`; there is no `str`) | +| `doc` | Full description, word for word the comment in `config.ini` | +| `title` | Short title, used directly as the form field label | +| `hint` | One-line hint under the control; whether it appears depends on whether the field has one | +| `unit` | Unit of measure, rendered by the panel to the right of the input box. **The unit is not folded into `title`**, and the key is omitted when the language pack has no value for it | +| `tier` | When `"advanced"`, the field goes into the "Advanced" collapsible section | +| `visible_when` | Declarative show/hide rule `key=value1\|value2;key2!=value` (a semicolon means "and", a pipe means "or"). A field carrying this rule is shown inline and never collapsed. Hiding only affects display; the field is still collected and saved as usual | +| `sub` (group level) | Group subtitle, shown to the right of the card title | + +`low` / `high` appear only for options that have a value range. **This endpoint does not return current values** — for those, use +the `pool` field of `GET /api/config`. + +### Start the Pool (POST /api/pool/start) + +No parameters. + +```json +{ "status": "success", "message": "Pool started successfully", "is_running": true } +``` + +### Stop the Pool (POST /api/pool/stop) + +No parameters. **Waits for the write queue to drain**, so it can take several seconds. + +```json +{ "status": "success", "message": "Pool stopped successfully", "is_running": false } +``` + +### Restart the Pool (POST /api/pool/restart) + +No parameters. Stops and then starts; the stop and the start each wait up to 30 seconds. + +On failure (the same for all three lifecycle endpoints): + +```json +{ "status": "error", "message": "...", "is_running": false } +``` + +The HTTP status code is **500, and no `error_code` is included**. + +## Proxy Queries and Single-Proxy Operations + +### Query the Proxy List (GET /api/pool/get) + +**Returns a JSON array** whose elements are [proxy objects](#appendix-c-field-dictionary). + +| Parameter | Type | Default | Values and meaning | +|---|---|---|---| +| `protocol` | string | any | `http` / `https` / `socks5`; anything else returns 400 | +| `region` | string | any | Fuzzy region match (matches both the Chinese and the English column); **a leading `-` means exclude**, e.g. `-United States` excludes proxies whose region contains the United States | +| `min_delay` | int | any | Minimum delay (milliseconds); a negative value is an error | +| `max_delay` | int | any | Maximum delay (milliseconds); a negative value is an error, and so is reversing the order against `min_delay` | +| `status` | string | any | `valid` / `invalid`; anything else returns 400 | +| `source` | string | any | Source plugin name | +| `sort_by` | string | `delay_ms` | See the whitelist below | +| `sort_order` | string | `asc` | `asc` / `desc` | +| `limit` | int | any | Return only the first N rows; when set, `page` is forced to 1. Cap: 50000 | +| `page` | int | `1` | Page number, must be ≥ 1 | +| `page_size` | int | `50` | Rows per page, 1 to 50000 | +| `format` | string | `json` | `json` or `text`; `text` returns plain text with one proxy address per line | +| `ip` | string | any | Fuzzy match on IP | +| `is_favorite` | bool | any | Favorited only / unfavorited only | +| `anonymity_level` | string | any | `transparent` / `anonymous` / `elite` / `unverified` | +| `min_health_score` | float | any | Minimum health score; a negative value is an error | +| `supports_https` | bool | any | Only proxies that do / do not support HTTPS | + +`sort_by` whitelist: `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`. + +> **A misspelled `sort_by` raises no error**: a value outside the whitelist **falls back to +> `delay_ms` silently**, and a `sort_order` other than `asc` / `desc` falls back to `asc` +> silently. When the sort order looks wrong, check the spelling of the parameter names first. + +> **Unknown query parameters are silently ignored** (see [Parameter Parsing Rules](#parameter-parsing-rules-pool-api-only)). +> A misspelled parameter name raises no error; you simply get unfiltered results. + +### Get One Proxy (GET /api/pool/random) + +Returns one random proxy that matches the filters. The filter parameters are the same as for +`/get` (**there is no** `sort_by` / `sort_order` / `limit` / `page` / `page_size`), plus: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `format` | string | `json` | `json` returns the full [proxy object](#appendix-c-field-dictionary); `text` returns only one line, `protocol://ip:port` | + +```json +{ + "id": 42, + "proxy_url": "http://1.2.3.4:8080", + "protocol": "http", + "ip": "1.2.3.4", + "port": 8080, + "delay_ms": 320.5, + "health_score": 87.3, + "anonymity_level": "elite", + "region": "中国 广东 深圳" +} +``` + +(The example above is abridged; for the full field list see [Appendix C](#appendix-c-field-dictionary).) + +In its plain-text form, a `format=text` response body is exactly one line: + +``` +http://1.2.3.4:8080 +``` + +**Errors and edge cases:** + +- No proxy matches the filters → **404 `api_error`**. +- `format` is neither `json` nor `text` → 400 `api_error`. + +> **The pick is not strictly uniform**: the implementation counts the rows, picks a page at +> random (100 rows per page), then picks one row on that page — the granularity is the page. +> With a small pool the odds differ slightly from row to row, but that has no practical effect +> on use. + +> This endpoint's `format=text` is exactly the response shape [`pool_remote_url`](ProxyCat-Manual/Configuration-EN.md#source) expects — +> another ProxyCat instance's `/api/pool/random?format=text&token=...` can serve as an external pool address. + +### Count Proxies (GET /api/pool/count) + +The filter parameters are the same as for `/get` (minus sorting and pagination). + +```json +{ "count": 86 } +``` + +### Export Proxies (GET /api/pool/export) + +The filter parameters are the same as for `/get` (minus sorting and pagination), plus: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `format` | string | `txt` | `txt` / `json` / `csv`; anything else returns 400 | + +- **`txt`**: one proxy address per line, as an attachment named `proxies_YYYYMMDD_HHMMSS.txt`. +- **`json`**: an attachment with the fields `protocol`, `ip`, `port`, `username`, `password`, `url`, + `region`, `region_en`, `delay_ms`, `is_valid`, `real_ip`, `source_plugin`, `validated_at`. +- **`csv`**: an attachment with a UTF-8 BOM; **the number and order of header columns are a public + contract** (Protocol, IP, Port, Username, Password, Proxy URL, Region, Delay (ms), Status, Exit IP, + Source, Validated at), and the wording of the header and status columns follows the UI language. + The English region column is **deliberately left out of the export**. + +**A single export is capped at 50000 rows.** + +**Errors and edge cases:** no proxy matches the filters → **404 `api_error`**. + +### Import Proxies (POST /api/pool/import) + +Request body: + +```json +{ "proxies": ["http://1.2.3.4:8080", "socks5://user:pass@5.6.7.8:1080"] } +``` + +Import goes through **the same pipeline** as plugin fetching: a quick port screen first, then a full +validation, and **only proxies that pass validation are saved**, with their source always recorded as +`manual_import`. This endpoint is in the HEAVY tier. + +The response has three possible outcomes; **callers must read `message` and `proxy_count` together, +not `success` alone**: + +| Case | Response | +|---|---| +| Accepted | `{ "success": true, "message": "Validating N proxies in the background; only working ones are saved", "proxy_count": N, "task_id": "..." }` | +| All failed to parse | `{ "success": false, "message": "...", "proxy_count": 0 }` | +| All already exist | `{ "success": true, "message": "All proxies already exist, none imported", "proxy_count": 0 }` | + +- An empty list → **400 `invalid_parameter`**. +- "Already exists" is decided by looking each entry up in the database by `ip:port`; the deduplication cache is not consulted. +- Once accepted, poll progress with the returned `task_id`. + +### Toggle Favorite Status (POST /api/pool/proxies/{id}/favorite) + +No request body. + +```json +{ + "success": true, + "is_favorite": true, + "message": "Proxy added to favorites" +} +``` + +**This is a toggle, not a setter — calling it again flips back and forth between the two states**, +and there is no idempotent parameter. If the proxy does not exist → 404 `api_error`. Favorite status +represents a user choice; writes from fetching and validation never overwrite it. + +## Statistics and Host Information + +### Get Pool Statistics (GET /api/pool/stats) + +```json +{ + "total_proxies": 120, + "valid_proxies": 86, + "invalid_proxies": 34, + "anonymity_fallback_proxies": 5, + "protocol_distribution": { "http": 60, "socks5": 26 }, + "region_distribution": { "中国": 40, "美国": 12 } +} +``` + +**The response has no `status` field**; it is the statistics object itself (structurally identical to `stats` inside `/status`). + +### List Sources (GET /api/pool/sources) + +```json +{ "sources": ["geonode_plugin", "github_proxy_plugin", "manual_import"] } +``` + +Returns every source plugin name that has appeared in the database, including the pseudo-plugin name `manual_import` used for manual imports. + +### Get This Host's Public Egress IP (GET /api/pool/host-ip) + +Returns this host's public egress IP, the baseline for deciding whether a proxy is transparent. + +```json +{ + "host_public_ip": "203.0.113.7", + "host_public_ipv4": "203.0.113.7", + "host_public_ipv6": "240e:37a:2563:cb00:9424:56ad:5734:d8", + "source": "detected", + "check_anonymity": true +} +``` + +| Field | Description | +|---|---| +| `host_public_ip` | The single-value form of this host's egress IP, **IPv4 preferred**; IPv6 is given only when there is no IPv4. **`null` when it cannot be determined, which is a normal state rather than a fault** | +| `host_public_ipv4` | The egress IP of that family; `null` when it cannot be determined | +| `host_public_ipv6` | The egress IP of that family; `null` when it cannot be determined | +| `source` | Where the value comes from (`configured` set manually / `detected` looked up automatically); an empty string when it cannot be determined | +| `check_anonymity` | Whether anonymity detection is enabled | + +A dual-stack host has one egress IP per family, and **both families take part in the anonymity comparison**, which is why they are reported per family here. + +The baseline is taken first from `[Pool] validator.host_public_ip` (one or two comma-separated values, at most one IPv4 and one IPv6); when it is left empty the program performs the lookup itself — the lookup **pins the address family and queries each family once**, rather than taking the first endpoint that answers, which would otherwise let the family obtained drift with endpoint availability. At most once every 30 minutes. + +## Plugin Management + +### List Plugins (GET /api/pool/plugins) + +```json +[ + { + "name": "geonode_plugin", + "enabled": true, + "interval_minutes": 60, + "last_run": "2026-09-30T11:00:00", + "next_run": "2026-09-30T12:00:00", + "last_error": null, + "is_loaded": true, + "test_url": "", + "reval_enabled": true, + "reval_interval_minutes": 0, + "skip_validation": false + } +] +``` + +| Field | Description | +|---|---| +| `name` | The plugin name, **equal to the plugin file's file name without its extension** | +| `enabled` | Whether the plugin takes part in automatic fetch scheduling | +| `interval_minutes` | Fetch interval in minutes | +| `last_run` / `next_run` | An ISO 8601 string or `null` | +| `last_error` | The most recent error; **a built-in message key is rendered in the current language, while an exception text raised by the plugin itself is passed through as-is** | +| `is_loaded` | Whether the module loaded successfully. **The response only includes plugins that loaded successfully, so this is always `true`**; a plugin that failed to load does not appear in the list (the error is only recorded in the pool's log) | +| `test_url` | The liveness test URL used only by this plugin; an empty string means it inherits the global `[Server] test_url` | +| `reval_enabled` | Whether the plugin takes part in automatic revalidation | +| `reval_interval_minutes` | Revalidation interval; `0` means inherit the global value | +| `skip_validation` | Whether to skip validation before storing (**high risk**, see below) | + +**The response is a JSON array with no `status` field.** + +### Enable / Disable a Plugin (POST /api/pool/plugins/{name}/enable, POST /api/pool/plugins/{name}/disable) + +No request body. + +```json +{ "success": true, "message": "Plugin geonode_plugin enabled" } +``` + +On failure it returns 500 `api_error`. + +### Set the Fetch Interval (PUT /api/pool/plugins/{name}/interval) + +```json +{ "minutes": 30 } +``` + +`minutes` must be ≥ 1, otherwise 400 `invalid_parameter`. + +```json +{ "success": true, "message": "Plugin geonode_plugin interval set to 30 minutes" } +``` + +### Set the Plugin Test URL (PUT /api/pool/plugins/{name}/test-url) + +```json +{ "test_url": "https://example.com" } +``` + +**An empty or all-whitespace `test_url` means inheriting the global** `[Server] test_url`: + +```json +{ "success": true, "message": "Test URL of plugin geonode_plugin restored to inherit the global URL" } +``` + +### Set the Plugin Validation Policy (PUT /api/pool/plugins/{name}/validation) + +```json +{ "reval_enabled": true, "reval_interval_minutes": 0, "skip_validation": false } +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `reval_enabled` | bool | `true` | Whether the plugin takes part in automatic revalidation | +| `reval_interval_minutes` | int | `0` | Revalidation interval in minutes; **`0` means inherit the global value**. A string value is also accepted; a non-integer returns 400 | +| `skip_validation` | bool | `false` | Skip validation before storing | + +```json +{ "success": true, "message": "Validation policy of plugin geonode_plugin updated" } +``` + +> **`skip_validation` is a dangerous switch**: once it is on, proxies fetched by that plugin are **written to the database without +> validation**, and the pool's quality scoring and automatic cleanup both lose their meaning. It is only available for **real, loaded +> plugins**; for the pseudo-plugin name `manual_import` it returns 404 — otherwise turning it on once would make every manual import afterward skip validation. + +### Run a Plugin Immediately (POST /api/pool/plugins/{name}/run) + +No request body. **The call returns as soon as a background task is created**; the fetch itself is bounded by `[Pool] plugins.execution_timeout_seconds`. + +```json +{ + "task_id": "8f14e45f-ea6b-4c1e-9c1a-0f3b2d5e7a91", + "status": "running", + "message": "Fetch task created for plugin geonode_plugin" +} +``` + +`status` is only a snapshot from the moment of creation; poll `task_id` for progress and results. Plugin not loaded → 404 `api_error`. + +### Hot-Reload a Plugin (POST /api/pool/plugins/{name}/reload) + +No request body. After editing a plugin's source code you do not need to restart the process. + +**An expected failure returns 200 with `success: false`; only an unexpected exception produces a 500:** + +```json +{ "success": true, "message": "Plugin geonode_plugin reloaded successfully" } +``` + +```json +{ "success": false, "message": "Failed to reload plugin geonode_plugin (it may be running or the file does not exist)" } +``` + +A missing plugin file, a plugin that is currently running, or a load that returns empty all take the `success: false` path. + +## Batch Operations + +**Every endpoint in this section returns `task_id` immediately** (except the two delete +endpoints); poll progress under [Task Management](#task-management). + +### Revalidate Valid Proxies (POST /api/pool/validate/all-valid) + +No parameters. Runs one round of revalidation over all proxies currently with `is_valid = true`. + +```json +{ "task_id": "...", "status": "queued", "message": "Validation task created" } +``` + +### Revalidate Invalid Proxies (POST /api/pool/validate/all-invalid) + +No parameters. Runs one round of revalidation over all proxies currently with `is_valid = false`. + +> **This is the entry point for immediately revalidating proxies that usage feedback marked +> invalid**: when a proxy is marked invalid, `total_checks` is reset to zero along with it (the +> log records it as "added to the next revalidation candidate round"), and every round of auto +> revalidation adds proxies with `total_checks = 0` back into the candidates (not subject to +> `auto_revalidation.only_valid_proxies`, though still scheduled by each proxy's own +> revalidation interval), so these proxies are revalidated even without this endpoint. + +```json +{ "task_id": "...", "status": "queued", "message": "Validation task created" } +``` + +### Full Check and Region Backfill (POST /api/pool/update-geo/all) + +No parameters. Runs a complete assessment of every proxy (latency, egress IP, anonymity, +protocol support) and backfills regions along the way. Much heavier than routine revalidation. + +```json +{ "task_id": "...", "status": "queued", "message": "Full proxy check task created" } +``` + +### Delete All Invalid Proxies (DELETE /api/pool/delete/invalid) + +No parameters. **Deletion is irreversible.** + +```json +{ "success": true, "message": "Deleted 34 invalid proxies", "deleted_count": 34 } +``` + +With no invalid proxies it returns success all the same, with `deleted_count` 0. This endpoint +is in the HEAVY tier. + +### Batch Validate Specified Proxies (POST /api/pool/proxies/batch/validate) + +```json +{ "proxy_ids": [1, 2, 3], "mode": "liveness" } +``` + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `proxy_ids` | int[] | Yes | — | List of proxy ids; an empty list returns 400 | +| `mode` | string | No | `auto` | `auto` / `liveness` / `full`; any other value returns 400 | + +```json +{ "task_id": "...", "status": "running", "message": "Validating 3 proxies" } +``` + +- **Non-existent ids are silently ignored**; a 404 `api_error` is returned only when **none of + them exist**. +- Three modes: `liveness` only checks reachability; `full` runs a complete assessment; `auto` + picks one automatically from the information already held for each proxy. + +### Batch Delete Specified Proxies (DELETE /api/pool/proxies/batch/delete) + +**Note: this is a DELETE request, but the parameters go in the request body.** + +```json +{ "proxy_ids": [1, 2, 3] } +``` + +```json +{ "success": true, "message": "Deleted 3 proxies", "deleted_count": 3 } +``` + +An empty `proxy_ids` returns 400; non-existent ids are silently ignored, and `deleted_count` +reports the rows actually deleted. + +## Task Management + +### List Tasks (GET /api/pool/tasks) + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `status` | string | Unrestricted | Filter by status: `running` / `completed` / `failed` / `cancelled` (the task table has no `queued`, so passing it matches nothing) | + +```json +{ + "tasks": [ + { "task_id": "8f14e45f-...", "status": "running", "progress": 40, "total": 120, "message": "..." } + ], + "total": 1 +} +``` + +### Query Task Status (GET /api/pool/tasks/{task_id}) + +**The response is the task entry itself, with no outer `status` field** (the entry carries its +own `status` for the task state). + +```json +{ + "status": "running", + "message": "Validating proxies...", + "stage": "validate", + "progress": 40, + "total": 120, + "stage_done": 40, + "stage_total": 120, + "rate": 12.5, + "eta_seconds": 6, + "started_at": "2026-09-30T12:00:00", + "valid_count": 22, + "invalid_count": 10 +} +``` + +Fields vary by task type: only import / fetch tasks carry `stage` and the full set of counters; +batch validation and full check tasks additionally have `inconclusive_count`. **A task that does +not exist or has been evicted by its TTL → 404 `api_error`.** + +> Task entries are **in-memory state**: after a process restart, no historical task can be +> queried. + +### Cancel a Task (POST /api/pool/tasks/{task_id}/cancel) + +No request body. + +```json +{ "success": true, "message": "Task cancelled" } +``` + +**When the task has already finished it returns `success: false`, and this is not an error**: + +```json +{ "success": false, "message": "The task has already finished, nothing to cancel" } +``` + +A non-existent task id → 404 `api_error`. + +## Geolocation Maintenance + +Geolocation resolution uses an **offline database** (GeoLite2 and the Chunzhen library) and +issues no external lookup requests. All three endpoints in this section are maintenance actions. + +### Query Offline Geolocation Database Status (GET /api/pool/geo/status) + +```json +{ + "available": true, + "files": [ { "filename": "GeoLite2-City.mmdb", "exists": true, + "size": 65383808, "modified_at": "2026-10-02 12:00:00" } ], + "pending_recompute": 42 +} +``` + +`modified_at` is `null` when the file does not exist. + +| Field | Description | +|---|---| +| `available` | Whether the offline database is usable (**usable only when both data files are present**) | +| `files` | Status of each data file | +| `pending_recompute` | Number of records whose region is pending recomputation; when it cannot be determined, it returns 0 rather than an error | + +### Update the Offline Geolocation Database (POST /api/pool/geo/update) + +No parameters. **Downloads roughly 100 MB of data files**, run as a background task. + +```json +{ "task_id": "...", "status": "queued", "message": "Geo database update task created" } +``` + +- The two files total around **105 MB** (GeoLite2 about 65 MB, the Chunzhen library about + 40 MB). +- **Disk space is not checked**; if space runs short, the download fails partway through. +- A failure on one file does not block the other; failures are reported faithfully in the task + result's `message`. + +### Recompute Regions (POST /api/pool/geo/recompute) + +No parameters. Recomputes the regions of existing records from the offline database. + +```json +{ "task_id": "...", "status": "queued", "message": "Geo recompute task created" } +``` + +**Errors and edge cases:** + +- **Only records whose `country_code` is empty are recomputed** (that is, egress IPs never + resolved through the offline database). Records already resolved are not recomputed; there is + no dedicated immediate-refresh operation after updating the data files, but the full check + from `POST /api/pool/update-geo/all` hands each egress IP back to the resolver and can write + the new region back indirectly (not effective immediately, being subject to the in-process + query cache and the time it takes). +- When the geolocation resolution service is not enabled it returns **503 `api_error`** + directly and creates no task. + +## Database and Backups + +### List Backups (GET /api/pool/backups) + +```json +{ + "backups": [ + { "filename": "proxies_backup_20260930_030000.db", "size": 1048576, "created_at": "2026-09-30 03:00:00" } + ] +} +``` + +Backups are created by a scheduled loop controlled by `[Pool] database.backup_enabled`; this endpoint only lists existing backups. +**When the backup feature is not enabled, it returns 404 `api_error`.** The backup listing and restore endpoints in this group are pure file operations and +**also work while the pool is stopped**. + +### Restore from a Backup (POST /api/pool/backups/restore) + +```json +{ "filename": "proxies_backup_20260930_030000.db" } +``` + +```json +{ "success": true, "message": "Backup restored successfully; please restart the proxy pool to load the restored data" } +``` + +**Semantic contract (all three points matter):** + +1. **Returns 409 `pool_running` while the pool is running** — overwriting an open database file would corrupt it, + so the pool must be stopped first. This is the **only** endpoint that returns `pool_running`. +2. Restore **only overwrites the database file; it does not check the backup for version compatibility with the current database**. +3. After a successful restore, **the pool must be restarted** before the new data is loaded. The endpoint itself does not restart the pool; the panel's + "Database Maintenance" page restarts it automatically once the restore completes (it stops the pool first, then starts it back in its previous state), + while clients calling the API directly must call `POST /api/pool/start` themselves. + +Missing `filename` → 400 `invalid_parameter`. + +### Database Statistics (GET /api/pool/database/stats) + +```json +{ "stats": { "total_proxies": 120, "valid_proxies": 96, "invalid_proxies": 24, + "db_size_mb": 1.05 } } +``` + +Requires the pool to be running. Returns 404 `api_error` when the maintenance feature is not enabled. + +### Optimize the Database (POST /api/pool/database/optimize) + +No parameters. Runs `ANALYZE` + `REINDEX` + `VACUUM`; **it is a HEAVY-tier operation and VACUUM holds an exclusive +database lock for a long time**, so other database operations are blocked while it runs. + +```json +{ "success": true, "message": "Database optimization complete" } +``` + +## Pool Error Codes + +See [Pool API Conventions → Error Responses](#error-responses). For the summary table, see +[Appendix A](#appendix-a-error-code-reference). + +--- + +# 4. End-to-End Examples + +## Python + +```python +import requests + +HOST = "http://localhost:5001" +TOKEN = "YOUR_TOKEN" +PARAMS = {"token": TOKEN} + +# Panel: view runtime status +status = requests.get(f"{HOST}/api/status", params=PARAMS).json() +print(status["current_proxy"], status["proxy_source_mode"]) + +# Panel: manually switch the upstream proxy once +switched = requests.get(f"{HOST}/api/switch_proxy", params=PARAMS).json() +if switched["status"] != "success": + # A cooldown and a genuine failure both come back as error; tell them apart by the field + print("In cooldown" if switched.get("cooldown") else switched.get("message")) + +# Panel: query failed requests from the past hour +records = requests.get( + f"{HOST}/api/logs/records", + params={**PARAMS, "outcome": "failure", "limit": 50}, +).json() +for row in records["records"]: + print(row["ts"], row["host"], row["upstream_label"], row["reason"]) + +# Pool: get an available proxy (plain text) +proxy = requests.get( + f"{HOST}/api/pool/random", + params={**PARAMS, "status": "valid", "format": "text"}, +).text.strip() +print(proxy) # for example http://1.2.3.4:8080 + +# Pool: create a batch validation task and poll it +task = requests.post( + f"{HOST}/api/pool/proxies/batch/validate", + params=PARAMS, + json={"proxy_ids": [1, 2, 3], "mode": "liveness"}, +).json() +progress = requests.get( + f"{HOST}/api/pool/tasks/{task['task_id']}", params=PARAMS +).json() +print(progress["status"], progress.get("progress"), progress.get("total")) +``` + +## cURL + +```bash +# Panel: runtime status +curl "http://localhost:5001/api/status?token=YOUR_TOKEN" + +# Panel: save configuration (change the rotation mode) +curl -X POST "http://localhost:5001/api/config?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"server": {"mode": "loadbalance"}}' + +# Panel: restart the outbound proxy service +curl -X POST "http://localhost:5001/api/service?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"action": "restart"}' + +# Panel: switch the UI language +curl -X POST "http://localhost:5001/api/language?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"language": "en"}' + +# Panel: export the past hour of access records as CSV +curl -OJ "http://localhost:5001/api/logs/records/export?token=YOUR_TOKEN&format=csv" + +# Pool: filtered query +curl "http://localhost:5001/api/pool/get?protocol=http&status=valid&limit=10&token=YOUR_TOKEN" + +# Pool: get an available proxy (plain text) +curl "http://localhost:5001/api/pool/random?status=valid&format=text&token=YOUR_TOKEN" + +# Pool: run a plugin manually +curl -X POST "http://localhost:5001/api/pool/plugins/geonode_plugin/run?token=YOUR_TOKEN" + +# Pool: batch import +curl -X POST "http://localhost:5001/api/pool/import?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"proxies": ["http://1.2.3.4:8080"]}' +``` + +--- + +# 5. Notes + +1. **Authentication only compares a single token in the query string**; there is no CSRF protection and caller permissions are not distinguished: anyone who can reach the panel can + change the configuration (including downstream credentials). Do not expose the panel directly to the public internet. +2. **Two sets of error conventions coexist**: on the panel side most business failures are `200 + status: "error"`, while on the pool side they are real + 4xx/5xx + `error_code`. Callers must check both. +3. **`message` varies with the UI language**; use it for display only, never for program logic. +4. **Runtime state lives in process memory** (current proxy, exit roster, task table, ads dismissal state) and resets when the process restarts; + access records and domain statistics live in SQLite and survive a restart. +5. **Changing `web_port` requires restarting the whole process; changing `port` requires restarting the outbound service** (when you save through `POST /api/config` + the panel restarts the outbound service automatically; manual edits and the command-line entry point require you to restart it yourself). Every other configuration item takes effect + immediately once saved, with a few exceptions: the access-record buffer `access_records_buffer_size`, for example, is rebuilt at the new capacity only the next time the proxy service + starts. Changes to `[Server]` made by hand-editing `config.ini` are also hot-reloaded automatically + (both entry points poll the configuration file), but port rebinding does not happen automatically; the `[Pool]` section is hot-reloaded by the pool itself. +6. **Most** batch operations return a `task_id` (the two delete endpoints return `success` / `deleted_count` directly); + poll progress with `GET /api/pool/tasks/{task_id}`. Tasks routinely run for tens of minutes and can be aborted at any time with + `POST /api/pool/tasks/{task_id}/cancel`. Note that the download in the offline geolocation database update task runs on + a thread that cannot be cancelled — cancelling only writes out the status, and the download still runs to completion. The task table is in-memory with a 1-hour TTL and + cannot be queried after the process restarts. +7. **The Pool API's query parameters are a signature-based whitelist**: a misspelled parameter name is silently ignored, and the endpoint returns unfiltered results instead of an error. +8. When you change an endpoint or field under `/api/pool/*`, **this document must be updated in step** — the adaptation layer is a hand-written Flask blueprint + and cannot generate an OpenAPI description automatically the way FastAPI does. + +--- + +# Appendix A: Error Code Reference + +## Panel API Error Conventions + +| Case | HTTP | Response Body | +|---|---|---| +| Success | 200 | `{"status": "success", ...}` | +| Success (some read-only endpoints) | 200 | Returns the data directly, **with no `status` field** | +| Authentication failure | 401 | `{"status": "error", "message": "..."}` | +| Configuration validation failed (`POST /api/config` only) | 400 | `{"status": "error", "message": "...", "details": {"key": "...", "reason": "..."}}` | +| Other business failures | **200** | `{"status": "error", "message": "..."}` | + +## Pool API Error Conventions + +| error_code | HTTP | Meaning | +|---|---|---| +| `invalid_parameter` | 400 | Invalid request parameters | +| `api_error` | 4xx / 5xx | Business error (proxy not found, no matching results, spec mismatch, plugin operation failure, restore failure, etc.); the status code comes from the `ApiError` itself | +| `pool_running` | 409 | The pool is running, so this operation is not allowed (`POST /api/pool/backups/restore` only) | +| `pool_unavailable` | 503 | The pool is not running or is restarting | +| `pool_timeout` | 504 | The pool timed out while responding | +| `queue_full` | 503 | The write queue is full | +| `internal_error` | 500 | Internal server error | +| *(none)* | 500 | Pool start/stop failure: **no `error_code`** when `POST /api/pool/start\|stop\|restart` fails | + +--- + +# Appendix B: Endpoint Index + +## Page Routes (Non-`api`) + +| Method | Path | Auth | Description | +|---|---|---|---| +| GET | `/` | No | Redirects to `/web` with the token | +| GET | `/web` | Yes | Panel single page | +| GET | `/static/` | No | Frontend static assets | + +## Panel API (33) + +| Method | Path | Description | +|---|---|---| +| GET | `/api/status` | Runtime status | +| GET | `/api/config` | Read `[Server]` / `[Pool]` configuration | +| POST | `/api/config` | Save configuration | +| POST | `/api/language` | Switch the UI language | +| GET | `/api/proxies` | Read the local proxy list | +| POST | `/api/proxies` | Write the local proxy list | +| GET | `/api/check_proxies` | Check availability of the proxies in the source list (for the local source, all entries in `ip.txt`) | +| GET | `/api/ip_lists` | Read the IP whitelist / blacklist | +| POST | `/api/ip_lists` | Write the IP whitelist / blacklist | +| GET | `/api/bypass_whitelist` | Read the bypass list | +| POST | `/api/bypass_whitelist` | Write the bypass list | +| POST | `/api/service` | Start / stop / restart the outbound proxy service | +| GET | `/api/switch_proxy` | Refresh the exit pool: top it up when under capacity, replace the exit closest to expiry when full | +| GET | `/api/logs` | Query in-memory logs | +| GET | `/api/logs/stats` | Log statistics | +| GET | `/api/logs/files` | List log files | +| GET | `/api/logs/file` | Read a log file | +| GET | `/api/logs/export` | Export logs | +| POST | `/api/logs/clear` | Clear logs | +| GET | `/api/logs/records` | Query access records | +| GET | `/api/logs/records/export` | Export access records | +| GET | `/api/logs/records/options` | Filter options for access records | +| POST | `/api/logs/records/clear` | Clear access records | +| GET | `/api/logs/domains` | Query domain statistics | +| POST | `/api/logs/domains/clear` | Clear domain statistics | +| GET | `/api/users` | Read outbound proxy accounts | +| POST | `/api/users` | Write outbound proxy accounts | +| GET | `/api/api_credentials` | Read upstream API credentials | +| POST | `/api/api_credentials` | Manage upstream API credentials | +| GET | `/api/version` | Version check result (read-only cache, **no authentication**) | +| GET | `/api/ads` | Get ads (**no authentication**) | +| POST | `/api/ads/dismiss` | Dismiss ads (**no authentication**) | +| POST | `/api/ads/reopen` | Reopen ads (**no authentication**) | + +## Pool API (38) + +| Method | Path | Description | +|---|---|---| +| GET | `/api/pool/status` | Pool status and global statistics | +| GET | `/api/pool/schema` | `[Pool]` field schema (same source as the panel form) | +| POST | `/api/pool/start` | Start the pool | +| POST | `/api/pool/stop` | Stop the pool (waits for the write queue to drain) | +| POST | `/api/pool/restart` | Restart the pool | +| GET | `/api/pool/get` | Filtered proxy list (`json` / `text`) | +| GET | `/api/pool/random` | Get a random proxy (`json` / `text`) | +| GET | `/api/pool/count` | Count proxies matching the filters | +| GET | `/api/pool/export` | Export proxies (`txt` / `json` / `csv`) | +| POST | `/api/pool/import` | Batch import (validate before ingesting) | +| POST | `/api/pool/proxies/{id}/favorite` | Toggle favorite status | +| GET | `/api/pool/stats` | Pool statistics | +| GET | `/api/pool/sources` | List of source plugin names | +| GET | `/api/pool/host-ip` | This host's public egress IP | +| GET | `/api/pool/plugins` | Plugin list and status | +| POST | `/api/pool/plugins/{name}/enable` | Enable a plugin | +| POST | `/api/pool/plugins/{name}/disable` | Disable a plugin | +| PUT | `/api/pool/plugins/{name}/interval` | Set the fetch interval | +| PUT | `/api/pool/plugins/{name}/test-url` | Set the plugin's dedicated test URL | +| PUT | `/api/pool/plugins/{name}/validation` | Set the plugin validation policy | +| POST | `/api/pool/plugins/{name}/run` | Run a plugin immediately | +| POST | `/api/pool/plugins/{name}/reload` | Hot-reload a plugin | +| POST | `/api/pool/validate/all-valid` | Revalidate all valid proxies | +| POST | `/api/pool/validate/all-invalid` | Revalidate all invalid proxies | +| POST | `/api/pool/update-geo/all` | Full check and region backfill | +| DELETE | `/api/pool/delete/invalid` | Delete all invalid proxies | +| POST | `/api/pool/proxies/batch/validate` | Batch validate specified proxies | +| DELETE | `/api/pool/proxies/batch/delete` | Batch delete specified proxies (parameters in the request body) | +| GET | `/api/pool/tasks` | List tasks | +| GET | `/api/pool/tasks/{task_id}` | Query task status | +| POST | `/api/pool/tasks/{task_id}/cancel` | Cancel a task | +| GET | `/api/pool/geo/status` | Offline geolocation database status | +| POST | `/api/pool/geo/update` | Update the offline geolocation database (about 100 MB) | +| POST | `/api/pool/geo/recompute` | Recompute regions for existing records | +| GET | `/api/pool/backups` | List backups | +| POST | `/api/pool/backups/restore` | Restore from a backup (the pool must be stopped first) | +| GET | `/api/pool/database/stats` | Database statistics | +| POST | `/api/pool/database/optimize` | Optimize the database (ANALYZE / REINDEX / VACUUM) | + +--- + +# Appendix C: Field Dictionary + +## Proxy Object + +The object returned by `GET /api/pool/get` and `GET /api/pool/random` (`format=json`). + +| Field | Type | Description | +|---|---|---| +| `id` | int | Primary key | +| `proxy_url` | string | Full proxy address; when the username and password **are both present**, it takes the form `protocol://user:password@ip:port` | +| `protocol` | string | `http` / `https` / `socks5` | +| `ip` | string | Entry IP | +| `port` | int | Port | +| `username` / `password` | string \| null | Proxy authentication credentials | +| `real_ip` | string \| null | Egress IP (the IP the peer sees when requests go through the proxy) | +| `exit_ip_differs` | bool | Whether the entry IP differs from the egress IP. **Supplied in both the query and random responses** (the two share one serialization); the export paths deliberately leave it out | +| `region` | string | Full Chinese region name, e.g. 「中国 广东 深圳」; 「未知」 ("unknown") when the lookup finds nothing | +| `country` / `province` / `city` | string | Chinese region levels | +| `region_en` / `country_en` / `province_en` / `city_en` | string \| null | English region levels | +| `delay_ms` | float \| null | Most recently measured delay (milliseconds) | +| `validated_at` | string \| null | Most recent validation time (ISO 8601) | +| `source_plugin` | string | Source plugin name; `manual_import` for manual imports | +| `is_favorite` | bool | Whether the proxy is favorited (a user choice that fetching and validation do not overwrite) | +| `is_valid` | bool | Whether the proxy is currently usable; the list uses it to badge invalid rows as 「失效」 ("invalid"). Exports use their own status column | +| `health_score` | float | Health score, **0-100**, 1 decimal place | +| `anonymity_level` | string | `transparent` / `anonymous` / `elite` / `unverified` | +| `anonymity_fallback` | bool | Whether the anonymity level was judged to be the anonymity fallback | +| `supports_https` / `supports_http` | bool | Whether the corresponding protocol is supported | +| `quality_assessed_at` | string \| null | Most recent quality assessment time (ISO 8601) | +| `success_rate` | float | Validation success rate, 0-1, 3 decimal places | +| `avg_delay_ms` | float \| null | Historical average delay (milliseconds) | +| `total_checks` | int | Cumulative validation count; missing values fall back to 0 | +| `probe_success_rate` | float | Probe success rate, 0-1, 3 decimal places | +| `probe_total_count` | int | Cumulative probe count; missing values fall back to 0 | + +> **Semantics of empty region values**: the Chinese and English sets are given as separate columns, and `null` means **not looked up yet** (not 「未知」). +> When an English column is missing, the front end falls back to the Chinese column; identical values in the two columns mean that level has no corresponding translation. Do not write +> 「未知」 into these columns — 「未知」 is the value the Chinese columns take when they have been queried but nothing was found. + +## Task Object + +The response of `GET /api/pool/tasks/{task_id}`, and also an element of the `GET /api/pool/tasks` list. + +| Field | Type | Description | +|---|---|---| +| `task_id` | string | Task id; **it appears only on the elements of the `GET /api/pool/tasks` list** (injected per item by the list) and is absent from the single-task response | +| `status` | string | `running` / `completed` / `failed` / `cancelled` (`queued` appears only in some creation responses, never in the task table) | +| `message` | string | Human-readable description of the current stage, following the UI language | +| `progress` / `total` | int | Completed / total | +| `started_at` / `completed_at` | string | Start and end times (ISO 8601); `completed_at` is absent until the task finishes | +| `stage` | string | `prescreen` / `validate` / `ingest` / `done` (`done` is written on completion), **present only on import and fetch tasks** | +| `stage_done` / `stage_total` | int | Progress within the current stage; same availability as above | +| `in_flight` | int | Number of items being processed concurrently in the current stage; same availability as above | +| `rate` | float | Processing rate (items/second) | +| `eta_seconds` | int \| null | Estimated seconds remaining | +| `valid_count` / `invalid_count` / `inconclusive_count` | int | Counts by validation result category | +| `prescreened_out` | int | Number of items eliminated by the port prescreen | +| `incomplete_count` / `failed_count` | int | Incomplete / failed counts | +| `results` | object | Map from data file name to result text (`已更新` / `失败: ...`, in the Chinese UI), **present only in the completed state of an offline geolocation database update task** | + +**The set of fields varies with task type and endpoint**; the table above is the union across +all of them; relying only on the four fields `status`, `progress`, +`total`, and `message` is the safest. + +## Backup Object + +An element of the `GET /api/pool/backups` list. + +| Field | Type | Description | +|---|---|---| +| `filename` | string | Backup file name, used as the parameter of `POST /api/pool/backups/restore` | +| `size` | int | File size (bytes) | +| `created_at` | string | Creation time, always formatted as `YYYY-MM-DD HH:MM:SS` | diff --git a/API.md b/API.md new file mode 100644 index 0000000..ba50519 --- /dev/null +++ b/API.md @@ -0,0 +1,2009 @@ +# ProxyCat 接口文档 + +本文是 ProxyCat 对外 HTTP 接口的**唯一权威文档**,覆盖两套接口: + +- **面板接口**(`/api/*`)—— 面板自身的接口,由 `app.py` 提供。 +- **代理池接口**(`/api/pool/*`)—— 由 `modules/proxypool_api.py` 注册的 Flask 蓝图。 + +两套接口**共用同一个端口、同一套鉴权**,但**错误响应的约定不同**(面板多为 +`HTTP 200 + status: "error"`,代理池用真实状态码 + `error_code`)。两套约定都在本文中 +写明,调用前请先读 [一、通用约定](#一通用约定)。 + +> 历史版本把代理池接口的字段细节放在 `modules/proxypool/API.md`,该文件现已并入本文, +> 只剩一个指向本页的指针。 + +## 目录 + +- [分组导航](#分组导航) +- [一、通用约定](#一通用约定) +- [二、面板接口](#二面板接口) +- [三、代理池接口](#三代理池接口) +- [四、端到端调用示例](#四端到端调用示例) +- [五、注意事项](#五注意事项) +- [附录 A:错误码总表](#附录-a错误码总表) +- [附录 B:端点总索引](#附录-b端点总索引) +- [附录 C:字段字典](#附录-c字段字典) + +## 分组导航 + +| 组 | 端点数 | 说明 | +|---|---|---| +| [1. 通用约定](#一通用约定) | — | 地址、鉴权、两套错误约定、参数解析规则、通用上限 | +| [2. 面板接口](#二面板接口) | 33 | 面板自身的 `/api/*`,含状态、配置、日志、访问记录 | +| [3. 代理池接口](#三代理池接口) | 38 | 代理池的 `/api/pool/*`,含抓取、验证、任务与数据库维护 | + +按用途快速定位: + +| 我想…… | 去这里 | +|---|---| +| 看服务当前在用什么代理 | [获取运行状态](#获取运行状态get-apistatus) | +| 改配置 | [保存配置](#保存配置post-apiconfig) | +| 看某个请求为什么失败 | [查询访问记录](#查询访问记录get-apilogsrecords) | +| 看某个域名走哪个上游成功率如何 | [查询域名统计](#查询域名统计get-apilogsdomains) | +| 拿一个可用代理给别的程序用 | [取一个代理](#取一个代理get-apipoolrandom) | +| 手动触发一次抓取 | [立即执行插件](#立即执行插件post-apipoolpluginsnamerun) | +| 看一个跑得久的任务到哪了 | [查询任务状态](#查询任务状态get-apipooltaskstask_id) | + +--- + +# 一、通用约定 + +## 服务地址与端口 + +- 默认地址:`http://localhost:5001`,端口取自 `config/config.ini` 的 `[Server] web_port`。 +- 面板与代理池**没有各自独立的进程或端口**,都运行在 ProxyCat 进程内,随进程一起启停。 +- 代理监听端口(默认 `1080`)是另一个端口,只接受 HTTP 与 SOCKS5 代理请求, + **不提供任何 HTTP 接口**。 + +## 鉴权 + +**除下文明确标注「免鉴权」的端点外,所有接口都需要带上 token:** + +``` +GET http://localhost:5001/api/status?token=<你的token> +``` + +token 在 `config/config.ini` 的 `[Server] token`。**校验只读查询串参数 `token`**, +不读请求头,也不读 Cookie。 + +**token 留空时一律放行**,即不启用鉴权。此时任何能连上面板端口的人都可以读写配置 +(包括上游凭据)、查看访问记录、启停服务。仅在本机使用时可这样配置;要暴露到网络上 +必须设置 token。 + +> **仓库自带的 `config/config.ini` 里 token 非空**,克隆下来直接用会得到 401。 +> 见 [README 的快速部署](README.md#快速部署)。 + +**免鉴权的 API 端点只有 4 个**(`/` 与 `/static/` 也不校验 token,见[附录 B](#附录-b端点总索引)): + +| 方法 | 路径 | +|---|---| +| GET | `/api/version` | +| GET | `/api/ads` | +| POST | `/api/ads/dismiss` | +| POST | `/api/ads/reopen` | + +鉴权失败统一返回 **HTTP 401**: + +```json +{ + "status": "error", + "message": "无效的访问令牌" +} +``` + +## 两套错误约定 + +**这是本文最容易踩的地方:两套接口的错误表达方式不同,必须分别对待。** + +| 情形 | 面板 `/api/*` | 代理池 `/api/pool/*` | +|---|---|---| +| 成功 | `200` + `status: "success"`;少数只读端点**直接返回数据、没有 `status` 字段** | `200` + 数据;错误时才带 `status` | +| 业务失败 | **`200`** + `status: "error"` + `message` | **真实状态码**(4xx/5xx)+ `status: "error"` + `message` + `error_code` | +| 参数错误 | `400`,仅 `POST /api/config` 会这样返回 | `400` + `error_code: "invalid_parameter"` | +| 未鉴权 | `401` + `status: "error"` | `401` + `status: "error"`(同一个钩子) | +| 池未运行 / 池太慢 | 不适用 | `503` `pool_unavailable` / `504` `pool_timeout` | + +由此得出两条硬规则: + +1. **永远同时判断 HTTP 状态码与 `status` 字段。** 面板侧只看状态码会漏掉业务失败, + 只看 `status` 又会漏掉鉴权与池侧错误。 +2. **程序化分支只能依赖 `error_code`(池侧)或 HTTP 状态码 + 端点自有标志(面板侧), + 绝不能用 `message`。** `message` 的文案跟随当前界面语言(`[Server] language`), + 同一个错误在不同语言下返回不同文本。 + +## 参数解析规则(仅代理池接口) + +代理池适配层按**处理函数的签名**解析参数,规则如下: + +| 规则 | 说明 | +|---|---| +| 查询参数是**白名单** | 只有出现在处理函数签名里的查询参数才会被解析,**未知参数被静默忽略**。写错参数名不会报错,只会得到未筛选的全量结果 | +| 类型强制 | 依据类型注解,只识别 `bool` / `int` / `float`,其余按字符串原样传递 | +| 空串 | 非字符串类型的参数传空串(如 `?status=`)等同于**不传**该参数 | +| 请求体 | 必须是 JSON 对象;缺失必填字段返回 `400 invalid_parameter`,并在 `message` 里列出字段名 | +| 路径参数 | 如 `/api/pool/proxies/{id}/favorite` 中的 `{id}`,由路由本身解析 | + +## 通用上限与截断 + +| 位置 | 上限 / 默认 | 说明 | +|---|---|---| +| `GET /api/logs` 的 `limit` | 钳制到 1-2000,默认 200 | 超出被静默钳制,不报错 | +| `GET /api/logs/file` 的 `lines` | 钳制到 1-5000,默认 500 | 同上 | +| `GET /api/logs/records` 的 `limit` | 钳制到 1-500,默认 100 | 同上;`since` 默认「1 小时前」 | +| 访问记录导出 | 单次最多 50000 条 | 被截断的标记只有 JSON 格式的元信息里有(`truncated`,条数达到上限即为 `true`),CSV 没有该标记 | +| 代理池 `page_size` / `limit` / 导出 | 统一受 50000 约束 | `limit` 存在时 `page` 被强制为 1,等效「取前 limit 条」 | +| `GET /api/version` | 只读缓存,**不发起请求** | 检查由进程启动时与每 24 小时的后台任务完成 | + +## 阻塞与并发 + +面板由 **waitress 以 16 个线程**提供服务。每次调用代理池,**当前 Web 线程会一直被占用 +到池返回结果**,最长可达 60 秒(HEAVY 档)。因此 16 个并发的大批量操作就会把面板 +拖到无响应。调用重接口请控制并发,并给客户端留足够长的超时。 + +代理池的三档内部超时(会体现在错误码上): + +| 档 | 时限 | 用于 | +|---|---|---| +| QUERY | 10 秒 | 查询、统计、状态 | +| MUTATION | 20 秒 | 启停、单条修改 | +| HEAVY | 60 秒 | 导入、导出、数据库优化、手动执行插件、批量验证/删除 | + +--- + +# 二、面板接口 + +## 页面与静态资源 + +### 打开面板(GET /web) + +**鉴权:需要 token。** + +`GET /web` 返回面板单页。token 校验与其他接口一致,所以**设置了 token 时, +直接访问 `http://127.0.0.1:5001/` 会在跳转后得到 401**。正确地址是: + +``` +http://127.0.0.1:5001/?token=<你的token> +``` + +`GET /` 本身不校验,只把 `?token=` 原样带上去跳转到 `/web`: + +| 请求 | 响应 | +|---|---| +| `GET /?token=abc` | `302` → `/web?token=abc` | +| `GET /`(无 token) | `302` → `/web` | + +`GET /static/` 提供 `web/static/` 下的前端资源。带 `?v=<版本>` 查询参数的请求 +返回 `Cache-Control: public, max-age=31536000, immutable`(页面里的每个静态引用都带该 +参数,改版后参数变化、浏览器自然取新文件);不带参数的请求返回 `Cache-Control: no-cache`, +每次回源校验。 + +## 运行状态 + +### 获取运行状态(GET /api/status) + +无请求参数。返回出口代理服务的实时快照。 + +```json +{ + "current_proxy": "http://1.2.3.4:8080", + "mode": "cycle", + "port": 1080, + "interval": 300, + "time_left": 123.4, + "switch_countdown_mode": "time", + "total_proxies": 10, + "active_proxies": [ + { "url": "http://1.2.3.4:8080", "inflight": 3, "active": 2, "capacity": 0, "failures": 0, "suspect": false, "failure_ratio": 0.0, "last_used": 12.5 } + ], + "proxy_source_mode": "local", + "exit_count": 5, + "elastic_active": true, + "auth_required": false, + "display_level": 1, + "service_status": "running", + "language": "cn", + "request_interval": 0, + "pool_running": true, + "source_error": null +} +``` + +| 字段 | 说明 | +|---|---| +| `current_proxy` | **最近一次分配出去的**上游出口(已脱敏),不是「当前那个代理」—— 多出口下请求是按负载摊到整组出口上的;本地来源没有可用出口时是该语言的「无」文案,非本地来源此时为空串。整组状态见 `active_proxies` | +| `mode` | 运行模式。**同一个键在两种来源下是两件事**:本地来源问「挑谁」——`cycle`(按清单顺序,忽略负载)/ `loadbalance`(谁压力小先用谁);API 与维护池问「什么时候换」——`continuous`(持续更换)/ `request`(触发更换)。按来源归一后的值,换来源后不属于新那组的值会退回该组默认值 | +| `port` | **实际绑定的**代理端口(取自监听套接字),服务未运行时回落到配置值 | +| `interval` | 时间轮换间隔(秒) | +| `time_left` | **最近到期的那个出口还剩多久**,单位随 `switch_countdown_mode` 变化;名册为空时为 `0`。改出口由请求驱动还是后台主动做,取决于 `mode`:`continuous`(持续更换)下后台每拍都在换,空闲时这个数照样走;`request`(触发更换)与本地来源下空闲时到期的出口会被移除,池子可能为空 | +| `switch_countdown_mode` | 倒计时口径:`time`(秒,按出口各自的寿命;`interval` 与 `request_interval` 都填时按这个报 —— 秒数能画进度条,剩余请求数由每个出口的 `requests_left` 单独给出)/ `request_count`(只填了 `request_interval`,报还剩多少次请求)/ `per_request`(两项都没填,只在缺人或失效时补货)。**不会再返回 `none`**:寿命对所有来源、四种运行模式都生效 | +| `total_proxies` | 当前在用的出口条数。目标是 `exit_count`(`[Server] exit_count`),某个出口被判失效、或还没补上时这个数会小于目标;高峰自动扩容期间会大于它(同时 `elastic_active` 为真) | +| `active_proxies` | 各上游出口的实时状态:`url`(已脱敏)、`active` **正在用这个出口的连接数**(请求在途 + 最近有中继数据的隧道,面板与 CLI 的「在用」取它)、`inflight` 还占着名额的连接数(含空闲隧道,恒 ≥ `active`)、`capacity` 单出口活跃连接额度(`0` 表示不限制,空闲隧道不计入)、`failures` 连续失败次数、`suspect` 近期失败是否居多(居多的会被优先让路,但仍然可用)、`failure_ratio` 近期失败占比、`expires_in` 剩余寿命秒数、`requests_left` 剩余可服务的请求数(两个都是 `null` 表示没有启用该口径)、`last_used` / `added_at` / `expires_at` / `requests_served`(`time.monotonic()` 与计数,仅供同进程内比较) | +| `proxy_source_mode` | 代理来源:`local` / `api` / `pool` | +| `exit_count` | 出口池的目标容量(`[Server] exit_count`),与 `total_proxies` 成对读。**换的时机由运行模式决定**:持续更换(`continuous`)模式下空闲时也保持这么多;触发更换(`request`)与本地来源由请求驱动,空闲时到期的出口会被移除、池子可能空掉,下一个请求到达时才重新取货填满 | +| `elastic_active` | 出口数是否高于目标:突发扩容/高峰自动扩容中,或扩容已停止但多出来的出口还没到寿命(它们用满自己的寿命才逐个撤下) | +| `auth_required` | 出口代理是否要求用户名密码认证 | +| `display_level` | 控制台详细程度(`[Server] display_level`) | +| `service_status` | `running` / `stopped` | +| `language` | 当前界面语言 | +| `request_interval` | 按请求数切换的阈值(分配次数,不是「服务成功」的次数)。它与 `interval` 同时生效,谁先到谁换 | +| `pool_running` | 代理池是否在运行 | +| `source_error` | 最近一次从来源取出口失败的原因与时间(`{"message": ..., "at": ...}`);没有失败时为 `null` | + +## 配置管理 + +### 读取配置(GET /api/config) + +返回 `[Server]` 与 `[Pool]` 两段的全部键值。 + +```json +{ + "status": "success", + "server": { "port": "1080", "web_port": "5001", "mode": "cycle" }, + "pool": { "validator.timeout_seconds": "10" } +} +``` + +- **`token` 键已被剔除**,不会出现在响应里 —— 面板不需要回显它,避免它出现在页面源码、 + 浏览器缓存或前端日志中。 +- `[Users]` 与 `[api_credentials]` 不在这里返回,各有独立端点。 +- 布尔项已被归一化成 `"true"` / `"false"`:`config.ini` 里写 `yes` / `on` / `1` 在运行期 + 都算真,而界面只认 `"true"`,不归一化会让复选框显示成未勾选 —— 用户随手保存一次就把 + 开关关掉了。 + +### 保存配置(POST /api/config) + +请求体中的两段都可省略,但至少要有一段非空: + +```json +{ + "server": { "mode": "loadbalance", "interval": "600" }, + "pool": { "validator.timeout_seconds": "15" } +} +``` + +键名经白名单校验,未知键与非法取值会被拒绝,返回 **HTTP 400**。 + +> `token` 也是合法的 `server` 键:**面板不提供它的编辑入口,但本接口接受它**, +> 因此改 token 可以走 `POST /api/config`(立即生效),也可以直接改 `config.ini` +> 后重启进程。 + +```json +{ + "status": "success", + "port_changed": false, + "web_port_changed": false, + "service_status": "running", + "applied": { "proxy": true, "pool": true } +} +``` + +| 字段 | 说明 | +|---|---| +| `port_changed` | 代理监听端口是否变了。配置热重载本身不重新绑定端口;**面板会据此自动重启出口服务**(`POST /api/service` 的 `restart`)以换到新端口,用命令行入口时需重启进程 | +| `web_port_changed` | 面板端口是否变了。**必须重启整个进程**才生效,当前面板仍运行在旧端口 | +| `applied.proxy` | 新配置是否成功推送给出口代理服务 | +| `applied.pool` | 新配置是否成功推送给代理池 | + +`applied` 里的某一项为 `false` 表示**配置已落盘、但推送失败**,应答本身仍是 `success`。 +此时应以 `applied` 为准判断是否真的生效。 + +校验失败时(HTTP 400)除 `message` 外还带 `details`,指明是哪个键、以及按界面语言渲染 +好的原因,面板据此把提示落到具体输入框上: + +```json +{ + "status": "error", + "message": "配置项 port 非法(当前值: 70000):不能大于 65535", + "details": { "key": "port", "reason": "不能大于 65535" } +} +``` + +`details` 在 `key` 与 `reason_key` 齐备时才会出现(`[Server]` 与 `[Pool]` 的校验异常 +都带这两个属性),其余异常只有 `message`。`message` 始终保留,老客户端不受影响。 + +保存配置时,若当前代理来源为 `pool`、未配置外部池地址(`pool_remote_url`)且池未运行, +会顺带自动拉起池服务。 + +## 语言 + +### 切换界面语言(POST /api/language) + +语言是**服务端设置**,不是浏览器设置:它写回 `config.ini` 的 `[Server] language`, +并切换整个进程的文案(含面板、控制台横幅、接口 `message`、导出文件的表头与状态列)。 + +```json +{ "language": "en" } +``` + +| 字段 | 类型 | 必填 | 取值 | +|---|---|---|---| +| `language` | string | 否 | `cn` / `en`,缺省按 `cn` 处理 | + +```json +{ "status": "success", "language": "en" } +``` + +取值不在 `cn` / `en` 之内时**仍返回 HTTP 200**,靠 `status` 判断: + +```json +{ "status": "error", "message": "不支持的语言" } +``` + +> 该端点走的是面板侧的「业务失败返回 200」约定,不要只看状态码。 + +## 本地代理与名单 + +### 读写本地代理列表(GET/POST /api/proxies) + +**GET** 返回 `config/` 下代理列表文件的内容。 + +```json +{ "proxies": ["http://1.2.3.4:8080", "socks5://5.6.7.8:1080"] } +``` + +读失败时返回错误,**不是**空列表——空列表会让界面显示成「一个代理都没有」, +用户随手一保存就把代理文件清空了: + +```json +{ "status": "error", "message": "加载代理文件失败: ..." } +``` + +**POST** 整份覆盖写入,并立即热载入出口服务(不重新检测可用性): + +```json +{ "proxies": ["http://1.2.3.4:8080"] } +``` + +`proxies` **必须是数组**(传字符串会被逐字符拆行、传对象只会写进键名,两种都会 +把文件覆盖成垃圾),否则: + +```json +{ "status": "error", "message": "代理列表必须是数组" } +``` + +文件被其他进程占用时(Windows 上常见)可能失败: + +```json +{ "status": "error", "message": "..." } +``` + +### 检测代理可用性(GET /api/check_proxies) + +| 参数 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `test_url` | 否 | `https://www.baidu.com` | 存活检测的目标地址 | + +对**源清单**逐个检测,并发数取自 `[Server] check_concurrency`: + +- `local` 来源:`proxy_file` 里的全部条目 —— 故意不查活名册,因为出口因失败被停用后 + 就从名册里消失了,而那恰恰是最需要复查的那些。 +- `api` / `pool` 来源:没有可读的本地清单,检测当前在用的出口。 + +```json +{ + "status": "success", + "valid_proxies": ["http://1.2.3.4:8080"], + "checked": 3, + "total": 1, + "message": "代理检查完成,有效代理:1个" +} +``` + +| 字段 | 说明 | +|---|---| +| `valid_proxies` | 检测通过的地址 | +| `checked` | 本次实际检测了几个(源清单的条数) | +| `total` | 检测通过的数量 | + +检测只报告结论,不改变活名册 —— 想把新验证过的出口投入使用,等下一次批次刷新即可。 + +检测耗时随代理数量增长,请求可能等待较久(内部超时 30 秒)。 + +### 读写 IP 白名单 / 黑名单(GET/POST /api/ip_lists) + +**GET**: + +```json +{ "whitelist": ["127.0.0.1"], "blacklist": ["1.2.3.4"] } +``` + +**POST**: + +```json +{ "type": "whitelist", "list": ["127.0.0.1"] } +``` + +| 字段 | 说明 | +|---|---| +| `type` | `whitelist` 或 `blacklist`,其他取值返回错误 | +| `list` | 完整名单,整份覆盖写入 | + +写盘后立即热载入出口服务,无需重启。两份名单校验的是**客户端来源 IP**(连到代理端口 +的那一端),在每个新连接建立时先判定,未通过直接返回 403 并记录「未授权的IP尝试访问」, +连接不会被转发。同时命中两份名单时的判定优先级由 `[Server] ip_auth_priority` 决定 +(`whitelist` 放行 / `blacklist` 拦截)。 + +### 读写绕过名单(GET/POST /api/bypass_whitelist) + +**GET**: + +```json +{ "list": ["192.168.0.0/16"] } +``` + +**POST**: + +```json +{ "list": ["192.168.0.0/16"] } +``` + +名单内的**目标地址**不做任何上游转发,直接由本机连接(不参与出口调度)。它与上面 +按客户端来源 IP 判定的黑白名单是两回事:把某个目标加进本名单,不会跳过对客户端 +IP 的黑白名单校验。 + +## 服务控制 + +### 启停出口代理服务(POST /api/service) + +```json +{ "action": "start" } +``` + +| action | 说明 | +|---|---| +| `start` | 服务未运行时启动;已在运行则直接返回成功 | +| `stop` | 停止服务 | +| `restart` | 先停后起 | + +```json +{ "status": "success", "message": "服务启动成功", "service_status": "running" } +``` + +`start` / `stop` 的响应会带 `service_status`(取值 `running` / `stopped`);`restart` 成功时不带该字段。 + +**这个接口是串行且带超时的**:启动要等旧线程退出(最多 10 秒)再等服务就绪(最多 5 秒), +停止最多等 10 秒。超时即返回 `status` 为 `error`,**不会静默改用其他端口**。因此调用方 +应留出足够长的请求超时。 + +端口被占用时返回错误,且不会启动服务: + +```json +{ + "status": "error", + "message": "端口 1080 已被其他程序占用,请更换其他端口后重试", + "service_status": "stopped" +} +``` + +### 手动切换上游代理(GET /api/switch_proxy) + +丢开现有候补、立刻调一次来源:**池子不满就补满;启用了出口寿命(`interval` 或 +`request_interval` 非 0)时,满员则换掉最近到期的那个出口**(只填了 +`request_interval` 时各出口没有时间寿命,实际换掉的是名册里的第一个)。 +不是「换到下一个 IP」(所有出口本来就同时在用),也不是整批替换。响应有四种形态: + +**切换成功** + +```json +{ + "status": "success", + "current_proxy": "http://1.2.3.4:8080", + "active_proxies": [ + { "url": "http://1.2.3.4:8080", "inflight": 0, "active": 0, "capacity": 0, "failures": 0, "suspect": false, "failure_ratio": 0.0, "last_used": 0.0 } + ], + "total_proxies": 1, + "message": "出口批次已刷新" +} +``` + +| 字段 | 说明 | +|---|---| +| `current_proxy` | 当前出口(最近一次分配出去的出口,已脱敏);它被换掉时回退到名册里的第一个 | +| `active_proxies` | 当前名册的快照,字段含义同 `/api/status` | +| `total_proxies` | 当前名册里的出口数量 | + +「成功」按刷新后**名册是否非空**判定 —— 已经是满员且这次只轮换了一个出口,同样算成功。 + +**处于冷却期**(距上次切换过近,`[Server] switch_cooldown` 内不再切换) + +```json +{ + "status": "error", + "cooldown": true, + "cooldown_remaining": 12, + "message": "..." +} +``` + +**已有切换在进行中** + +```json +{ "status": "error", "switching": true, "message": "..." } +``` + +**切换失败** + +```json +{ "status": "error", "current_proxy": "...", "message": "..." } +``` + +调用方应据 `cooldown` / `switching` 字段区分「暂时不切」与「真的失败」,两者都是 +`status: error`。 + +## 日志 + +日志按用途分为四个类别:`access`(代理访问)、`proxy`(代理生命周期)、`main`(主程序)、 +`pool`(代理池)。其中 `access` / `proxy` / `main` 三个类别有对应的日志文件,`pool` 只进 +内存环形缓冲与 `error.log`,其自身文件在 `modules/proxypool/logs/` 下,不通过本组接口浏览。 + +### 查询内存日志(GET /api/logs) + +| 参数 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `category` | 否 | `all` | `all` / `access` / `proxy` / `main` / `pool`,其他取值返回错误 | +| `start` | 否 | `0` | **从最新一条往前跳过的条数** | +| `limit` | 否 | `200` | 返回条数,被限制在 1-2000 | +| `level` | 否 | `ALL` | 按级别过滤 | +| `search` | 否 | 空 | 关键词过滤 | + +```json +{ + "logs": [ + { + "seq": 8821, + "time": "2026-09-30 12:00:00", + "level": "INFO", + "category": "main", + "message": "..." + } + ], + "total": 1234, + "status": "success" +} +``` + +- `seq` 是**跨类别唯一的全局递增序号**,多个类别合并返回时按它还原真实先后。 + 条目按时间正序排列。 +- `total` 是**过滤后的总数**,不是本次返回的条数。 +- `start` 的语义与常规分页相反:它是从最新一条往回数的偏移量,配 `limit` 用于 + 「翻页看更早的日志」。 + +### 日志统计(GET /api/logs/stats) + +```json +{ + "status": "success", + "stats": { + "total": 170, + "levels": { "INFO": 150, "WARNING": 20 }, + "categories": { "main": 100, "access": 50, "proxy": 20, "pool": 0 }, + "capacity": { "main": 20000, "access": 5000, "proxy": 20000, "pool": 20000 }, + "first_time": "2026-09-30 10:00:00", + "last_time": "2026-09-30 12:00:00" + } +} +``` + +`total` 是四个环加起来的条数,`capacity` 是各环容量(`access` 环更小)。 +`first_time` / `last_time` 在缓冲为空时不出现。 + +### 列出日志文件(GET /api/logs/files) + +```json +{ + "status": "success", + "files": [ + { "name": "main.log", "category": "main", "size": 20480, + "modified": "2026-10-02 15:30:00", "current": true, "rotated": false } + ] +} +``` + +| 字段 | 说明 | +|---|---| +| `name` | 文件名(面板拿它调 `/api/logs/file`) | +| `category` | `main` / `proxy` / `access` / **`error`**。`error` 是 ERROR 及以上的汇总文件,**不能**当作 `GET /api/logs` 的 `category` 参数传 | +| `size` / `modified` | 字节数 / 最后修改时间 | +| `current` | 是不是当前在写的那个;`rotated` 是它的反面(轮转出来的历史份) | + +只列出 `logs/` 目录下的白名单文件(含轮转份),不含代理池自己的 `proxy_pool.log`。 + +### 读取日志文件(GET /api/logs/file) + +| 参数 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `name` | 是 | 空 | 文件名,取白名单内的名字 | +| `lines` | 否 | `500` | 读取末尾行数,被限制在 1-5000 | + +```json +{ "status": "success", "name": "main.log", "lines": ["..."], "truncated": true } +``` + +`truncated` 为 `true` 表示文件的行数**不少于**请求的行数(恰好相等时也为 `true`), +本次只返回了末尾部分。文件名不在白名单内返回错误(**按白名单校验而非路径拼接, +杜绝目录穿越**)。 + +### 导出日志(GET /api/logs/export) + +| 参数 | 必填 | 说明 | +|---|---|---| +| `file` | 否 | 指定文件名时**下载该文件的附件**;省略则导出内存日志 | +| `category` | 否 | 省略 `file` 时生效,同 `GET /api/logs` | +| `level` / `search` | 否 | 同上 | + +省略 `file` 时返回纯文本附件,文件名为 `proxycat_export.log`。 + +### 清空日志(POST /api/logs/clear) + +```json +{ "category": "all" } +``` + +清空对应类别的内存环形缓冲,并截断对应的日志文件。`category` 为 `all` 时清空全部。 + +```json +{ "status": "success", "message": "日志已清除" } +``` + +**会删掉磁盘上的日志内容**,不可恢复。 + +## 访问记录 + +逐条记录每一次代理请求。与「日志」的区别是它面向查询与统计:按列存储、可过滤、可导出。 + +### 查询访问记录(GET /api/logs/records) + +| 参数 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `since` | 否 | 1 小时前 | 起始时间,接受 `YYYY-MM-DD`、`YYYY-MM-DD HH:MM`、`YYYY-MM-DD HH:MM:SS`,也接受 `T` 分隔 | +| `until` | 否 | 不限 | 结束时间,粒度到分钟或日期时自动补到该时刻的最后一秒 | +| `outcome` | 否 | 不限 | `success` / `failure` / `aborted`,其他取值返回错误 | +| `upstream` | 否 | 不限 | 按上游代理过滤 | +| `host` | 否 | 不限 | 按目标主机过滤 | +| `search` | 否 | 空 | 关键词过滤 | +| `limit` | 否 | `100` | 被限制在 1-500 | +| `offset` | 否 | `0` | 分页偏移 | +| `order` | 否 | `desc` | 时间排序方向 | + +```json +{ + "status": "success", + "enabled": true, + "range": { "since": "2026-09-30 11:00:00", "until": "" }, + "outcome_counts": { "success": 100, "failure": 3, "aborted": 1 }, + "total": 104, + "export_limit": 50000, + "records": [ + { + "id": 12345, + "ts": "2026-09-30 12:00:00", + "kind": "http", + "method": "GET", + "client": "127.0.0.1/-", + "client_ip": "127.0.0.1", + "client_user": "-", + "host": "example.com", + "port": 443, + "target": "example.com:443", + "upstream": "http://1.2.3.4:8080", + "upstream_label": "http://1.2.3.4:8080", + "real_ip": "203.0.113.7", + "outcome": "success", + "status_code": 200, + "elapsed_ms": 123, + "reason": "" + } + ] +} +``` + +- `id` 是自增主键;`client`(`client_ip/client_user`)与 `target`(`host:port`)是**派生 + 字段**,只是把相邻两列拼成界面直接可用的形态,与 `client_ip` / `host` 同源。 +- `upstream` 为特殊值时用 `upstream_label` 给出可读文案:直连(`direct`)与未知上游 + (`unknown`)各有一种取值。 +- 失败(`outcome=failure`)时 `status_code` 一律为空:隧道先建好、之后才失败的行不会 + 带着建连那一下的 200 出现在这里(历史库里存过这种行,读取时统一收敛)。 +- `real_ip` 是**这个出口实际用的出口 IP**(目标站看到的那个),网关型出口的地址与它 + 不是一回事。它是展示字段:代理池来源由池收录时验证得出,API 来源在 + `[Server] access_records_real_ip_probe` 打开时由后台探测得出,本地来源恒为空串。 + 探不到或没开启时是 `""`,界面显示成 `--`;直连的请求也是 `""`。 + 它**不参与**出口调度、寿命与故障判定,老库里的历史记录同样是 `""`。 +- 响应里另有三个落库状态字段与 `records` 平级返回:`pending`(尚未落盘的缓冲条数)、 + `dropped`(因缓冲写满而丢弃的条数)、`oldest_ts`(库里最早一条记录的时间,空串表示没有)。 + `dropped` 非零说明落盘跟不上,记录有丢失。 + +### 导出访问记录(GET /api/logs/records/export) + +过滤参数与查询接口相同,额外一个: + +| 参数 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `format` | 否 | `csv` | `csv` 或 `json`,其他取值返回错误 | + +- **CSV**:带 UTF-8 BOM,Excel 直接打开不乱码;**表头按当前界面语言渲染**。 +- **JSON**:UTF-8,含 `exported_at`、过滤条件、`count` 与 `truncated` 元信息。 + +两种格式都受 `export_limit`(50000)限制;被截断的标记只有 JSON 的元信息里有 +(`truncated`,取到的条数达到 50000 即为 `true`),CSV 只有正文、没有该标记。响应为 +附件下载,文件名形如 `access_records_20260930_120000.csv`。 + +### 列出过滤候选项(GET /api/logs/records/options) + +返回当前记录里出现过的上游代理与目标主机取值,供前端渲染下拉框。 + +```json +{ "status": "success", "upstreams": [{ "value": "http://1.2.3.4:8080", "label": "..." }] } +``` + +### 清空访问记录(POST /api/logs/records/clear) + +```json +{ "status": "success", "removed": 1234, "message": "已清除 1234 条访问记录" } +``` + +**不可恢复。** + +## 域名统计 + +按「目标域名 × 上游代理」累计请求的成败次数。与访问记录的区别:这里是聚合计数, +访问记录是逐条明细;因此访问记录可以清空而这里的累计值仍在,反之亦然。 + +### 查询域名统计(GET /api/logs/domains) + +| 参数 | 必填 | 默认 | 说明 | +|---|---|---|---| +| `upstream` | 否 | 空 | **指定时下钻到该上游的域名明细,省略时按上游汇总** | +| `sort` | 否 | `last_seen` | 排序字段 | +| `order` | 否 | `desc` | 排序方向 | +| `search` | 否 | 空 | 关键词过滤 | +| `limit` | 否 | `100` | 被限制在 1-1000 | +| `offset` | 否 | `0` | 分页偏移 | + +**省略 `upstream`——按上游汇总:** + +```json +{ + "status": "success", + "enabled": true, + "summary": { "pairs": 42, "hosts": 12, "upstreams": 5, "success": 3400, "failure": 56 }, + "mode": "proxies", + "proxies": [ + { + "upstream": "http://1.2.3.4:8080", + "upstream_label": "http://1.2.3.4:8080", + "host_count": 7, + "success": 100, + "failure": 2, + "total": 102, + "rate": 98, + "first_seen": "2026-09-30 10:00:00", + "last_seen": "2026-09-30 12:00:00", + "last_error": "" + } + ], + "total": 1 +} +``` + +**指定 `upstream`——下钻到域名:** + +```json +{ + "status": "success", + "enabled": true, + "summary": { "pairs": 42, "hosts": 12, "upstreams": 5, "success": 3400, "failure": 56 }, + "mode": "domains", + "upstream": "http://1.2.3.4:8080", + "upstream_label": "http://1.2.3.4:8080", + "domains": [ + { + "host": "example.com", + "upstream": "http://1.2.3.4:8080", + "success": 50, + "failure": 1, + "first_seen": "2026-09-30 10:00:00", + "last_seen": "2026-09-30 12:00:00", + "last_status": 200, + "last_error": "" + } + ], + "total": 1 +} +``` + +- `summary` 的 `pairs` 是「域名 × 上游」的组合数,`rate` 是成功率百分比(取整)。 +- **用返回的 `mode` 字段区分两种形态,不要靠猜。** +- `enabled` 为 `false` 表示域名统计功能未开启(`[Server] domain_stats_enabled`), + 此时没有数据可查。 + +### 清空域名统计(POST /api/logs/domains/clear) + +```json +{ "status": "success", "removed": 42, "message": "已清除 42 个域名的访问统计" } +``` + +## 用户与 API 凭据 + +### 读写出口代理账号(GET/POST /api/users) + +**GET**: + +```json +{ "status": "success", "users": { "neko": "123456" } } +``` + +读失败时返回 `status: "error"` 与 `users: null`,**不是空对象**——空对象会让界面 +显示成「一个用户都没有」,管理员加一个用户再保存就用空表覆盖原账号,等于把出口 +认证关掉。**响应里是明文密码**。 + +**POST** 整份覆盖 `[Users]` 段: + +```json +{ "users": { "neko": "123456" } } +``` + +**传空对象即可关闭出口认证**(账号表为空时 `auth_required` 为 `false`)。 + +```json +{ "status": "success", "message": "用户保存成功" } +``` + +### 管理上游 API 凭据(GET/POST /api/api_credentials) + +存多套「取代理接口」的凭据,并指定当前生效的一套。用 `set_active` 切换生效项、 +或删除的正是生效项时,会**同步改写 `[Server] api_proxy_url` / `proxy_username` / +`proxy_password`**,因此代理来源为 `api` 时会立即用上新凭据;`save` 不同步这三键 —— +它只更新凭据集合与生效项标记,要让新保存的值真正被代理来源使用,需要对它再调一次 +`set_active`(或由删除生效项触发的自动切换)把三键写回 `[Server]`。 + +**GET**: + +```json +{ + "status": "success", + "sets": [{ "name": "闪臣", "url": "https://...", "username": "", "password": "" }], + "active_credential": "闪臣" +} +``` + +**响应含明文凭据。** + +**POST**: + +```json +{ "action": "save", "name": "闪臣", "url": "https://...", "username": "", "password": "" } +``` + +| action | 需要字段 | 说明 | +|---|---|---| +| `save` | `name`、`url`、`username`、`password` | 按 `name` 存在则更新,否则新增;`name` 为空返回错误。若当前没有生效项,或生效项正是这一条,则把它设为生效项 | +| `delete` | `name` | 按 `name` 删除;若删掉的正是生效项,则把第一条剩余项设为生效项、**并把它的三个键同步写回 `[Server]`**(没有剩余则清空这三键)—— 不换的话运行中的服务会继续用已删除的接口 | +| `set_active` | `name` | 切换生效项,并把它的 `url`/`username`/`password` 写进 `[Server]`;`name` 不在凭据集合里时返回错误,不会写一个「未选用」的中间态 | + +`save` 与 `delete` 返回更新后的 `sets` 与 `active_credential`;`set_active` 返回 +`active_credential` 与生效项的 `credential`,**不返回 `sets`**。未知 `action` 与空 `name` +返回错误(`{ "status": "error", "message": "..." }`),`set_active` 的 `name` 不在凭据集合 +里时也返回错误。`save` 传一个不存在的 `name` 是新增;`delete` 不校验名字是否存在, +删除一个不存在的凭据名照常返回 `success`(`sets` 原样返回)。 + +## 版本检查与广告 + +> **本节端点无鉴权**,配置了 token 也无需携带。 + +### 检查版本(GET /api/version) + +返回**最近一次**版本检查的结果。**本端点只读缓存,不会发起任何外部请求** —— +请求它永远不会产生网络等待。 + +```json +{ + "status": "success", + "is_latest": true, + "current_version": "ProxyCat-V3.0.0", + "latest_version": "ProxyCat-V3.0.0" +} +``` + +| 字段 | 说明 | +|---|---| +| `is_latest` | 远端版本是否不高于当前版本;**每次读取时按当前的 `CURRENT_VERSION` 重算** | +| `current_version` | 本进程的版本 | +| `latest_version` | 最近一次检查取到的远端版本 | + +**检查的节奏由进程自己掌握,不由本端点触发:** + +- **进程启动时查一次**,之后**每 24 小时一次**。 +- **24 小时内重启不会重复检查**:上次检查的时间写在 `logs/version_check.json` 里, + 重启后读回来继续用。两个入口(`app.py` 与 `ProxyCat.py`)都是这个节奏。 +- **无论成败都记录时间**,因此对外最多 24 小时发起一次请求;断网时不会再出现 + 「每次打开面板都要等一次超时」的情况。 +- 检查在后台线程里做,不阻塞启动,也不占用 Web 工作线程。 + +**既没有成功结果、也没有失败记录时**(启动后第一次检查还没跑完)返回: + +```json +{ + "status": "error", + "message": "尚未完成版本检查" +} +``` + +**检查失败时**(断网、远端页面结构变化)返回 `status` 为 `error`,`message` 说明原因; +但若此前成功过,则**继续返回上一次成功的结果** —— 面板不是监控工具,展示最后已知的 +版本状态比展示错误更有用。失败本身记在日志里。 + +> 升级到新版本后,即使距上次检查不足 24 小时也会立即重查:状态文件里存着 +> 「当时检查的当前版本」,与进程里的版本不一致即视为过期。 + +### 获取广告(GET /api/ads) + +读取 `config/ads/` 目录下所有 `*.json`,返回 `enabled` 不为 `false` 的条目。 +解析失败的文件静默跳过。 + +```json +{ + "status": "success", + "ads": [ + { + "id": "example", + "title": "...", + "body": "...", + "image_url": "", + "link_url": "", + "link_text": "", + "display_time": 15 + } + ], + "total": 1, + "dismissed": false +} +``` + +`dismissed` 是**进程内的关闭状态,重启即丢失**。 + +### 关闭 / 重新开启广告(POST /api/ads/dismiss、POST /api/ads/reopen) + +两个端点都无参数、无鉴权,响应体是 `{ "status": "success", "dismissed": <布尔值> }`, +关闭置 `true`、重新开启置 `false`: + +```json +{ "status": "success", "dismissed": true } +``` + +--- + +# 三、代理池接口 + +全部挂在 `/api/pool` 前缀下,**鉴权与面板一致**(同一个 `before_request` 钩子)。 + +## 池侧约定 + +### 成功响应没有统一外壳 + +适配层把处理函数的返回值**直接序列化成响应体**,因此: + +- `GET /api/pool/get` 返回一个 **JSON 数组**(不是 `{"status": ..., "data": [...]}`)。 +- `GET /api/pool/count` 返回 `{"count": 12}`,**没有 `status` 字段**。 +- `GET /api/pool/random?format=text` 与导出接口返回**纯文本或附件**,不是 JSON。 +- 只有部分端点会在返回值里自带 `status` 与 `message`(如生命周期端点);插件操作返回的是 + `success` 与 `message`,没有 `status` 字段。 + +**判断成功请看 HTTP 状态码与响应内容本身,不要假设一定有 `status` 字段。** + +### 错误响应 + +```json +{ + "status": "error", + "message": "错误描述", + "error_code": "invalid_parameter" +} +``` + +| error_code | HTTP | 含义 | +|---|---|---| +| `invalid_parameter` | 400 | 请求参数非法(含缺失必填字段、取值越界) | +| `api_error` | 4xx / 5xx | 业务错误,如代理不存在、规格不匹配。HTTP 状态码随错误自带(如插件操作失败、备份恢复失败为 500,归属地解析服务未启用为 503),不要按 4xx 写死分支 | +| `pool_running` | 409 | 池运行中,不允许该操作(**仅备份恢复**会返回) | +| `pool_unavailable` | 503 | 代理池未运行或正在重启 | +| `pool_timeout` | 504 | 代理池响应超时 | +| `queue_full` | 503 | 写入队列已满,请求被拒(背压) | +| `internal_error` | 500 | 服务端内部错误 | + +**两个例外要知道:** + +- `POST /api/pool/start|stop|restart` 失败时返回 **500 且不带 `error_code`**, + 只有 `status` / `message` / `is_running`。 +- `message` 跟随界面语言,**不可用于程序判断**。 + +### 池未运行时 + +池未启动时,除状态查询、生命周期端点、备份列表/恢复与 `/schema` 外,**其余端点一律返回 +`503 pool_unavailable`**。调用方应先查 `GET /api/pool/status` 确认 `is_running`。 + +### 任务模型 + +所有耗时操作(批量验证、导入、抓取、归属地更新/重算)都**立即返回 `task_id`**, +真正的执行在后台,进度按 `task_id` 轮询。 + +- 任务状态是**进程内内存态**:进程重启即全部丢失。 +- 已完成/失败的任务保留 **1 小时**(TTL),超过即被淘汰;状态表上限 200 条。 +- 创建时返回的 `status`(`queued` / `running`)**只是创建瞬间的快照,不代表执行结果**; + 任务表里的条目从创建起就直接写 `running`,不存在 `queued` 状态的条目。 +- 任务表里实际出现的状态:`running` / `completed` / `failed` / `cancelled` + (`queued` 只在部分创建响应里出现,用它过滤任务永远筛不到条目)。 + +### 没有上报使用反馈的接口 + +「使用期反馈」是出口代理在真实转发过程中**在进程内部**回报给池的(见 +[功能特性里的「代理池机制」](ProxyCat-Manual/Features.md#代理池机制)),**没有任何 HTTP 端点可以上报使用结果**, +不必去找。 + +## 池状态与生命周期 + +### 获取池状态(GET /api/pool/status) + +```json +{ + "status": "success", + "is_running": true, + "last_error": null, + "stats": { + "total_proxies": 120, + "valid_proxies": 86, + "invalid_proxies": 34, + "anonymity_fallback_proxies": 5, + "protocol_distribution": { "http": 60, "socks5": 26 }, + "region_distribution": { "中国": 40, "美国": 12 } + } +} +``` + +| 字段 | 说明 | +|---|---| +| `is_running` | 池是否在运行 | +| `last_error` | 最近一次错误文案,无错误时为 `null` | +| `stats` | 全局统计;**池未运行时为 `null`**。统计本身失败也不影响本端点返回成功,只是 `stats` 为 `null` | +| `anonymity_fallback_proxies` | 匿名度被判成兜底档的条数 | +| `region_distribution` | 只取数量最多的前 10 个地区 | + +### 获取池配置字段结构(GET /api/pool/schema) + +返回 `[Pool]` 段的字段分组、类型、说明与取值区间,**面板的「池设置」表单就是按它渲染的**。 +文案跟随当前界面语言。 + +```json +{ + "status": "success", + "groups": [ + { + "title": "数据库", + "sub": "数据存在哪、怎么备份", + "id": "database", + "fields": [ + { "key": "database.path", "doc": "代理池所有数据存放的文件(SQLite 格式)", "type": "text", + "title": "数据库文件路径", "hint": "改动需重启池服务;相对路径从 proxypool 目录起算" }, + { "key": "database.pool_max_readers", "doc": "...", "type": "int", "low": 1, "high": 64, + "title": "读连接数上限", "tier": "advanced" }, + { "key": "database.backup_interval_hours", "doc": "...", "type": "int", "low": 1, + "title": "备份间隔", "unit": "小时", "tier": "advanced", "visible_when": "database.backup_enabled=true" } + ] + } + ] +} +``` + +| 字段 | 说明 | +|---|---| +| `type` | 字段类型,取值 `bool` / `int` / `float` / `list` / `text`(字符串字段一律是 `text`,没有 `str`) | +| `doc` | 完整说明,与 `config.ini` 里的注释逐字一致 | +| `title` | 短标题,直接当表单的字段标签用 | +| `hint` | 控件下方的一行提示;是否出现取决于字段是否写了提示 | +| `unit` | 计量单位,由面板渲染在输入框右侧。**单位不写进 `title`**,语言包无对应值时该键不输出 | +| `tier` | 为 `"advanced"` 时归入「高级选项」折叠区 | +| `visible_when` | 声明式显隐规则 `键=值1\|值2;键2!=值`(分号是「与」、竖线是「或」)。带此规则的字段内联显示、不折叠。隐藏只影响显示,字段照常收集与保存 | +| `sub`(分组级) | 分组副标题,显示在卡片标题右侧 | + +`low` / `high` 只在配置项有取值区间时出现。**本端点不返回当前值** —— 当前值走 +`GET /api/config` 的 `pool` 字段。 + +### 启动池(POST /api/pool/start) + +无参数。 + +```json +{ "status": "success", "message": "维护池启动成功", "is_running": true } +``` + +### 停止池(POST /api/pool/stop) + +无参数。**会等写队列排空**,可能耗时数秒。 + +```json +{ "status": "success", "message": "维护池停止成功", "is_running": false } +``` + +### 重启池(POST /api/pool/restart) + +无参数。先停后起,停止与启动各最多等 30 秒。 + +失败时(三个生命周期端点相同): + +```json +{ "status": "error", "message": "...", "is_running": false } +``` + +HTTP 状态码为 **500,且不带 `error_code`**。 + +## 代理查询与单条操作 + +### 查询代理列表(GET /api/pool/get) + +**返回一个 JSON 数组**,元素为[代理对象](#附录-c字段字典)。 + +| 参数 | 类型 | 默认 | 取值与语义 | +|---|---|---|---| +| `protocol` | string | 不限 | `http` / `https` / `socks5`,其他取值返回 400 | +| `region` | string | 不限 | 归属地模糊匹配(中、英两列都匹配);**以 `-` 开头表示排除**,如 `-美国` 表示排除归属地含「美国」的代理 | +| `min_delay` | int | 不限 | 最小延迟(毫秒),负数报错 | +| `max_delay` | int | 不限 | 最大延迟(毫秒),负数报错;与 `min_delay` 顺序颠倒报错 | +| `status` | string | 不限 | `valid` / `invalid`,其他取值返回 400 | +| `source` | string | 不限 | 来源插件名 | +| `sort_by` | string | `delay_ms` | 见下方白名单 | +| `sort_order` | string | `asc` | `asc` / `desc` | +| `limit` | int | 不限 | 只取前 N 条;设置后 `page` 被强制为 1。上限 50000 | +| `page` | int | `1` | 页码,必须 ≥ 1 | +| `page_size` | int | `50` | 每页条数,1 到 50000 | +| `format` | string | `json` | `json` 或 `text`;`text` 返回每行一个代理地址的纯文本 | +| `ip` | string | 不限 | 按 IP 模糊匹配 | +| `is_favorite` | bool | 不限 | 只看收藏 / 只看未收藏 | +| `anonymity_level` | string | 不限 | `transparent` / `anonymous` / `elite` / `unverified` | +| `min_health_score` | float | 不限 | 最低健康分,负数报错 | +| `supports_https` | bool | 不限 | 只看支持 / 不支持 HTTPS 的代理 | + +`sort_by` 白名单:`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`。 + +> **写错 `sort_by` 不会报错**:不在白名单内会**静默回落到 `delay_ms`**;`sort_order` +> 不是 `asc` / `desc` 时静默回落到 `asc`。排序结果不对时先检查参数名拼写。 + +> **未知查询参数被静默忽略**(见 [参数解析规则](#参数解析规则仅代理池接口))。 +> 参数名写错时不会报错,只会返回未筛选的结果。 + +### 取一个代理(GET /api/pool/random) + +随机返回一个符合条件的代理,筛选参数与 `/get` 相同(**没有** `sort_by` / `sort_order` / +`limit` / `page` / `page_size`),另有: + +| 参数 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `format` | string | `json` | `json` 返回完整[代理对象](#附录-c字段字典);`text` 只返回 `协议://ip:端口` 一行 | + +```json +{ + "id": 42, + "proxy_url": "http://1.2.3.4:8080", + "protocol": "http", + "ip": "1.2.3.4", + "port": 8080, + "delay_ms": 320.5, + "health_score": 87.3, + "anonymity_level": "elite", + "region": "中国 广东 深圳" +} +``` + +(上例为节选,完整字段见[附录 C](#附录-c字段字典)。) + +纯文本形态的 `format=text` 响应体就是一行: + +``` +http://1.2.3.4:8080 +``` + +**错误与边界:** + +- 没有符合条件的代理 → **404 `api_error`**。 +- `format` 不是 `json` / `text` → 400 `api_error`。 + +> **随机不是严格等概率**:实现是「先计数、随机挑一页(每页 100 条)、再随机挑一条」, +> 以页为粒度。池子很小时各条被取到的概率略有差异,但对取用没有实际影响。 + +> 这个端点的 `format=text` 正是 [`pool_remote_url`](ProxyCat-Manual/Configuration.md#来源设置) 期望的响应形态 —— +> 可以用另一个 ProxyCat 实例的 `/api/pool/random?format=text&token=...` 作为外部池地址。 + +### 统计代理数量(GET /api/pool/count) + +筛选参数与 `/get` 相同(去掉排序与分页)。 + +```json +{ "count": 86 } +``` + +### 导出代理(GET /api/pool/export) + +筛选参数与 `/get` 相同(去掉排序与分页),另有: + +| 参数 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `format` | string | `txt` | `txt` / `json` / `csv`,其他取值返回 400 | + +- **`txt`**:每行一个代理地址,附件名 `proxies_YYYYMMDD_HHMMSS.txt`。 +- **`json`**:附件,字段为 `protocol`、`ip`、`port`、`username`、`password`、`url`、 + `region`、`region_en`、`delay_ms`、`is_valid`、`real_ip`、`source_plugin`、`validated_at`。 +- **`csv`**:附件,带 UTF-8 BOM;**表头列数与顺序是对外契约**(协议、IP、端口、用户名、 + 密码、代理地址、归属地、延迟、状态、真实 IP、来源、验证时间),表头与状态列文案跟随 + 界面语言。英文归属地列**刻意不在导出里**。 + +**一次最多导出 50000 条。** + +**错误与边界:** 没有符合条件的代理 → **404 `api_error`**。 + +### 导入代理(POST /api/pool/import) + +请求体: + +```json +{ "proxies": ["http://1.2.3.4:8080", "socks5://user:pass@5.6.7.8:1080"] } +``` + +导入与插件抓取走**同一条流水线**:先端口初筛、再做完整验证,**只有验证通过的才入库**, +来源固定记为 `manual_import`。本端点为 HEAVY 档。 + +响应有三种结果,**调用方必须同时看 `message` 与 `proxy_count`,不能只看 `success`**: + +| 情形 | 响应 | +|---|---| +| 正常受理 | `{ "success": true, "message": "正在后台验证 N 个代理,通过后才入库", "proxy_count": N, "task_id": "..." }` | +| 全部解析失败 | `{ "success": false, "message": "...", "proxy_count": 0 }` | +| 全部已存在 | `{ "success": true, "message": "所有代理已存在,未导入新代理", "proxy_count": 0 }` | + +- 空列表 → **400 `invalid_parameter`**。 +- 「已存在」的判据是逐条按 `ip:port` 查库,不走去重缓存。 +- 受理后进度按返回的 `task_id` 轮询。 + +### 切换收藏状态(POST /api/pool/proxies/{id}/favorite) + +无请求体。 + +```json +{ + "success": true, + "is_favorite": true, + "message": "代理已收藏" +} +``` + +**这是「切换」而不是「设置」,重复调用会在两个状态之间来回翻转**,没有幂等参数。 +代理不存在 → 404 `api_error`。收藏状态代表用户选择,抓取与验证写入不会覆盖它。 + +## 统计与主机信息 + +### 获取池统计(GET /api/pool/stats) + +```json +{ + "total_proxies": 120, + "valid_proxies": 86, + "invalid_proxies": 34, + "anonymity_fallback_proxies": 5, + "protocol_distribution": { "http": 60, "socks5": 26 }, + "region_distribution": { "中国": 40, "美国": 12 } +} +``` + +**响应没有 `status` 字段**,直接就是统计对象(与 `/status` 里的 `stats` 同构)。 + +### 获取来源列表(GET /api/pool/sources) + +```json +{ "sources": ["geonode_plugin", "github_proxy_plugin", "manual_import"] } +``` + +返回库里出现过的全部来源插件名,包含手动导入用的伪插件名 `manual_import`。 + +### 获取本机出口 IP(GET /api/pool/host-ip) + +返回本机公网出口 IP,它是判定「透明代理」的对比基准。 + +```json +{ + "host_public_ip": "203.0.113.7", + "host_public_ipv4": "203.0.113.7", + "host_public_ipv6": "240e:37a:2563:cb00:9424:56ad:5734:d8", + "source": "detected", + "check_anonymity": true +} +``` + +| 字段 | 说明 | +|---|---| +| `host_public_ip` | 本机出口 IP 的单值形态,**优先 IPv4**,只有 IPv6 时给 IPv6;**拿不到时为 `null`,这是正常状态而非故障** | +| `host_public_ipv4` | 该族的出口 IP,拿不到时为 `null` | +| `host_public_ipv6` | 该族的出口 IP,拿不到时为 `null` | +| `source` | 该值的来源(`configured` 手动指定 / `detected` 自动查询),拿不到时为空串 | +| `check_anonymity` | 匿名度检测是否开启 | + +双栈主机两族各有一个出口 IP,**两族都会参与匿名度比对**,所以这里分族给出。 + +基准值优先取 `[Pool] validator.host_public_ip`(可填逗号分隔的一到两个,IPv4 与 IPv6 各至多一个);留空时由程序自行查询 —— 查询会**钉死地址族各问一次**,不是取第一个应答的端点,否则拿到的族别会随端点可用性漂移。每 30 分钟最多一次。 + +## 插件管理 + +### 列出插件(GET /api/pool/plugins) + +```json +[ + { + "name": "geonode_plugin", + "enabled": true, + "interval_minutes": 60, + "last_run": "2026-09-30T11:00:00", + "next_run": "2026-09-30T12:00:00", + "last_error": null, + "is_loaded": true, + "test_url": "", + "reval_enabled": true, + "reval_interval_minutes": 0, + "skip_validation": false + } +] +``` + +| 字段 | 说明 | +|---|---| +| `name` | 插件名,**等于插件文件的文件名(不含扩展名)** | +| `enabled` | 是否参与自动抓取调度 | +| `interval_minutes` | 抓取周期(分钟) | +| `last_run` / `next_run` | ISO 8601 字符串或 `null` | +| `last_error` | 最近一次错误;**若是内置文案键则按当前语言渲染,若是插件自己抛的异常文本则原样给出** | +| `is_loaded` | 模块是否已成功加载。**响应只包含加载成功的插件,因此恒为 `true`**;加载失败的插件不会出现在列表里(错误只记在池的日志中) | +| `test_url` | 该插件专用的存活检测地址,空串表示继承全局 `[Server] test_url` | +| `reval_enabled` | 是否参与自动重验证 | +| `reval_interval_minutes` | 重验证周期,`0` 表示继承全局 | +| `skip_validation` | 是否跳过入库验证(**高危**,见下) | + +**响应是 JSON 数组,没有 `status` 字段。** + +### 启用 / 禁用插件(POST /api/pool/plugins/{name}/enable、POST /api/pool/plugins/{name}/disable) + +无请求体。 + +```json +{ "success": true, "message": "插件 geonode_plugin 已启用" } +``` + +失败时返回 500 `api_error`。 + +### 设置抓取周期(PUT /api/pool/plugins/{name}/interval) + +```json +{ "minutes": 30 } +``` + +`minutes` 必须 ≥ 1,否则 400 `invalid_parameter`。 + +```json +{ "success": true, "message": "插件 geonode_plugin 运行周期已设置为 30 分钟" } +``` + +### 设置插件检测地址(PUT /api/pool/plugins/{name}/test-url) + +```json +{ "test_url": "https://example.com" } +``` + +**`test_url` 为空串或全空白表示继承全局** `[Server] test_url`: + +```json +{ "success": true, "message": "插件 geonode_plugin 的测试地址已恢复为继承全局地址" } +``` + +### 设置插件验证策略(PUT /api/pool/plugins/{name}/validation) + +```json +{ "reval_enabled": true, "reval_interval_minutes": 0, "skip_validation": false } +``` + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `reval_enabled` | bool | `true` | 是否参与自动重验证 | +| `reval_interval_minutes` | int | `0` | 重验证周期(分钟);**`0` 表示继承全局**。传字符串也能接受,非整数返回 400 | +| `skip_validation` | bool | `false` | 跳过入库验证 | + +```json +{ "success": true, "message": "插件 geonode_plugin 的验证策略已更新" } +``` + +> **`skip_validation` 是危险开关**:打开后该插件抓到的代理**不经验证直接入库**, +> 池的质量评分与自动清理都会失去意义。它只对**已加载的真实插件**开放,对伪插件名 +> `manual_import` 会返回 404 —— 否则一次性打开就会让此后所有手动导入都不再验证。 + +### 立即执行插件(POST /api/pool/plugins/{name}/run) + +无请求体。**只创建后台任务便返回**,被抓取本身受 `[Pool] plugins.execution_timeout_seconds` +约束。 + +```json +{ + "task_id": "8f14e45f-ea6b-4c1e-9c1a-0f3b2d5e7a91", + "status": "running", + "message": "插件 geonode_plugin 抓取任务已创建" +} +``` + +`status` 只是创建瞬间的快照,进度与结果按 `task_id` 轮询。插件未加载 → 404 `api_error`。 + +### 热重载插件(POST /api/pool/plugins/{name}/reload) + +无请求体。改完插件源码后无需重启进程。 + +**可预期的失败返回 200 + `success: false`,只有未预期异常才报 500:** + +```json +{ "success": true, "message": "插件 geonode_plugin 热重载成功" } +``` + +```json +{ "success": false, "message": "插件 geonode_plugin 热重载失败(可能正在执行中或文件不存在)" } +``` + +插件文件不存在、正在执行、或加载返回空,都会走 `success: false`。 + +## 批量操作 + +**本节所有端点都立即返回 `task_id`**(除两个删除端点),进度按 +[任务管理](#任务管理)轮询。 + +### 重新验证有效代理(POST /api/pool/validate/all-valid) + +无参数。对当前所有 `is_valid = true` 的代理做一轮重验证。 + +```json +{ "task_id": "...", "status": "queued", "message": "验证任务已创建" } +``` + +### 重新验证失效代理(POST /api/pool/validate/all-invalid) + +无参数。对当前所有 `is_valid = false` 的代理做一轮重验证。 + +> **这是把「被使用反馈标记失效」的代理立刻重验的入口**:被标记失效时 `total_checks` +> 会一并清零(日志记为「列入下一轮重验证候选」),而自动重验证每轮都会把 +> `total_checks = 0` 的代理补充进候选(不受 `auto_revalidation.only_valid_proxies` +> 限制,但仍按各自的重验证间隔排期),所以这些代理不依赖本端点也会被重新验证。 + +```json +{ "task_id": "...", "status": "queued", "message": "验证任务已创建" } +``` + +### 全面检测并补归属地(POST /api/pool/update-geo/all) + +无参数。对全部代理做完整评估(延迟、出口 IP、匿名度、协议支持),顺带补齐归属地。 +比日常重验证重得多。 + +```json +{ "task_id": "...", "status": "queued", "message": "代理全面检测任务已创建" } +``` + +### 删除全部失效代理(DELETE /api/pool/delete/invalid) + +无参数。**删完不可恢复。** + +```json +{ "success": true, "message": "成功删除 34 个失效代理", "deleted_count": 34 } +``` + +没有失效代理时同样返回成功,`deleted_count` 为 0。本端点为 HEAVY 档。 + +### 批量验证指定代理(POST /api/pool/proxies/batch/validate) + +```json +{ "proxy_ids": [1, 2, 3], "mode": "liveness" } +``` + +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `proxy_ids` | int[] | 是 | — | 代理 id 列表;为空返回 400 | +| `mode` | string | 否 | `auto` | `auto` / `liveness` / `full`,其他取值返回 400 | + +```json +{ "task_id": "...", "status": "running", "message": "正在验证 3 个代理" } +``` + +- **不存在的 id 会被静默忽略**,只有**一个都不存在**时才返回 404 `api_error`。 +- 三种模式:`liveness` 只判通不通;`full` 做完整评估;`auto` 按代理已有信息自动选。 + +### 批量删除指定代理(DELETE /api/pool/proxies/batch/delete) + +**注意:这是 DELETE 请求,但参数在请求体里。** + +```json +{ "proxy_ids": [1, 2, 3] } +``` + +```json +{ "success": true, "message": "成功删除 3 个代理", "deleted_count": 3 } +``` + +`proxy_ids` 为空返回 400;不存在的 id 被静默忽略,`deleted_count` 取实际删除行数。 + +## 任务管理 + +### 列出任务(GET /api/pool/tasks) + +| 参数 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `status` | string | 不限 | 按状态过滤:`running` / `completed` / `failed` / `cancelled`(任务表里没有 `queued`,传它筛不到条目) | + +```json +{ + "tasks": [ + { "task_id": "8f14e45f-...", "status": "running", "progress": 40, "total": 120, "message": "..." } + ], + "total": 1 +} +``` + +### 查询任务状态(GET /api/pool/tasks/{task_id}) + +**响应就是任务条目本身,没有 `status` 外层字段**(条目内自带 `status` 表示任务状态)。 + +```json +{ + "status": "running", + "message": "正在验证代理...", + "stage": "validate", + "progress": 40, + "total": 120, + "stage_done": 40, + "stage_total": 120, + "rate": 12.5, + "eta_seconds": 6, + "started_at": "2026-09-30T12:00:00", + "valid_count": 22, + "invalid_count": 10 +} +``` + +字段随任务类型不同:只有导入/抓取类任务带 `stage` 与全套计数;批量验证与全面检测类 +任务另有 `inconclusive_count`。**任务不存在或已被 TTL 淘汰 → 404 `api_error`。** + +> 任务条目是**内存态**:进程重启后查不到任何历史任务。 + +### 取消任务(POST /api/pool/tasks/{task_id}/cancel) + +无请求体。 + +```json +{ "success": true, "message": "任务已取消" } +``` + +**任务已经结束时返回 `success: false`,这不是错误**: + +```json +{ "success": false, "message": "任务已经结束,无需取消" } +``` + +任务 id 不存在 → 404 `api_error`。 + +## 归属地维护 + +归属地解析用**离线库**(GeoLite2 与纯真库),不发外部查询请求。本节三个端点都是维护动作。 + +### 查询离线库状态(GET /api/pool/geo/status) + +```json +{ + "available": true, + "files": [ { "filename": "GeoLite2-City.mmdb", "exists": true, + "size": 65383808, "modified_at": "2026-10-02 12:00:00" } ], + "pending_recompute": 42 +} +``` + +`modified_at` 在文件不存在时为 `null`。 + +| 字段 | 说明 | +|---|---| +| `available` | 离线库是否可用(**两个数据文件都在才算可用**) | +| `files` | 各数据文件的状态 | +| `pending_recompute` | 待重算归属地的记录数;查不到时按 0 返回,不报错 | + +### 更新离线库(POST /api/pool/geo/update) + +无参数。**会下载约 100 MB 的数据文件**,做成后台任务。 + +```json +{ "task_id": "...", "status": "queued", "message": "离线归属地库更新任务已创建" } +``` + +- 两个文件合计约 **105 MB**(GeoLite2 约 65 MB、纯真库约 40 MB)。 +- **不校验磁盘空间**,空间不足会在下载中途失败。 +- 一个文件失败不阻断另一个,失败项在任务结果的 `message` 里如实回报。 + +### 重算归属地(POST /api/pool/geo/recompute) + +无参数。按离线库重算存量记录的归属地。 + +```json +{ "task_id": "...", "status": "queued", "message": "归属地重算任务已创建" } +``` + +**错误与边界:** + +- **只重算 `country_code` 为空的记录**(即从未用离线库解析过的出口 IP)。已经解析过的 + 记录不会被重算;更新数据文件后没有专门的立即刷新操作,但 `POST /api/pool/update-geo/all` + 的全面检测会把各出口 IP 重新交给解析器、可间接写回新归属地(受进程内查询缓存与耗时 + 限制,并非即时生效)。 +- 归属地解析服务未启用时直接返回 **503 `api_error`**,不创建任务。 + +## 数据库与备份 + +### 列出备份(GET /api/pool/backups) + +```json +{ + "backups": [ + { "filename": "proxies_backup_20260930_030000.db", "size": 1048576, "created_at": "2026-09-30 03:00:00" } + ] +} +``` + +备份由 `[Pool] database.backup_enabled` 控制的定时循环创建,本端点只列出已有备份。 +**备份功能未启用时返回 404 `api_error`。** 本组备份列表与恢复是纯文件操作, +**池停止时同样可用**。 + +### 从备份恢复(POST /api/pool/backups/restore) + +```json +{ "filename": "proxies_backup_20260930_030000.db" } +``` + +```json +{ "success": true, "message": "备份恢复成功,请重启代理池以加载恢复后的数据" } +``` + +**语义约定(三条都很重要):** + +1. **池运行中返回 409 `pool_running`** —— 覆盖正在打开的数据库文件会造成损坏, + 必须先停池。这是**唯一**会返回 `pool_running` 的端点。 +2. 恢复**只覆盖库文件,不校验备份与当前库的版本兼容性**。 +3. 恢复成功后**必须重启池**才会加载新数据。端点本身不重启池;面板的 + 「数据库维护」页会在恢复完成后自动重启(恢复前先停池、恢复后按原状态启回), + 直接调接口的客户端要自己调 `POST /api/pool/start`。 + +`filename` 缺失 → 400 `invalid_parameter`。 + +### 数据库统计(GET /api/pool/database/stats) + +```json +{ "stats": { "total_proxies": 120, "valid_proxies": 96, "invalid_proxies": 24, + "db_size_mb": 1.05 } } +``` + +需要池在运行。维护功能未启用时返回 404 `api_error`。 + +### 优化数据库(POST /api/pool/database/optimize) + +无参数。执行 `ANALYZE` + `REINDEX` + `VACUUM`,**HEAVY 档,且 VACUUM 会长时间独占 +数据库锁**,期间其他数据库操作会被阻塞。 + +```json +{ "success": true, "message": "数据库优化完成" } +``` + +## 代理池错误码 + +见[池侧约定 → 错误响应](#错误响应)。汇总表见 +[附录 A](#附录-a错误码总表)。 + +--- + +# 四、端到端调用示例 + +## Python + +```python +import requests + +HOST = "http://localhost:5001" +TOKEN = "你的token" +PARAMS = {"token": TOKEN} + +# 面板:看运行状态 +status = requests.get(f"{HOST}/api/status", params=PARAMS).json() +print(status["current_proxy"], status["proxy_source_mode"]) + +# 面板:手动切换一次上游代理 +switched = requests.get(f"{HOST}/api/switch_proxy", params=PARAMS).json() +if switched["status"] != "success": + # 冷却中和真的失败都是 error,靠字段区分 + print("冷却中" if switched.get("cooldown") else switched.get("message")) + +# 面板:查询最近一小时的失败请求 +records = requests.get( + f"{HOST}/api/logs/records", + params={**PARAMS, "outcome": "failure", "limit": 50}, +).json() +for row in records["records"]: + print(row["ts"], row["host"], row["upstream_label"], row["reason"]) + +# 代理池:取一个可用代理(纯文本) +proxy = requests.get( + f"{HOST}/api/pool/random", + params={**PARAMS, "status": "valid", "format": "text"}, +).text.strip() +print(proxy) # 例如 http://1.2.3.4:8080 + +# 代理池:创建批量验证任务并轮询 +task = requests.post( + f"{HOST}/api/pool/proxies/batch/validate", + params=PARAMS, + json={"proxy_ids": [1, 2, 3], "mode": "liveness"}, +).json() +progress = requests.get( + f"{HOST}/api/pool/tasks/{task['task_id']}", params=PARAMS +).json() +print(progress["status"], progress.get("progress"), progress.get("total")) +``` + +## cURL + +```bash +# 面板:运行状态 +curl "http://localhost:5001/api/status?token=YOUR_TOKEN" + +# 面板:保存配置(改轮换模式) +curl -X POST "http://localhost:5001/api/config?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"server": {"mode": "loadbalance"}}' + +# 面板:重启出口代理服务 +curl -X POST "http://localhost:5001/api/service?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"action": "restart"}' + +# 面板:切换界面语言 +curl -X POST "http://localhost:5001/api/language?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"language": "en"}' + +# 面板:导出最近一小时的访问记录为 CSV +curl -OJ "http://localhost:5001/api/logs/records/export?token=YOUR_TOKEN&format=csv" + +# 代理池:筛选查询 +curl "http://localhost:5001/api/pool/get?protocol=http&status=valid&limit=10&token=YOUR_TOKEN" + +# 代理池:取一个可用代理(纯文本) +curl "http://localhost:5001/api/pool/random?status=valid&format=text&token=YOUR_TOKEN" + +# 代理池:手动运行插件 +curl -X POST "http://localhost:5001/api/pool/plugins/geonode_plugin/run?token=YOUR_TOKEN" + +# 代理池:批量导入 +curl -X POST "http://localhost:5001/api/pool/import?token=YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"proxies": ["http://1.2.3.4:8080"]}' +``` + +--- + +# 五、注意事项 + +1. **鉴权只在查询串里比对单一 token**,无 CSRF 防护,也不区分调用方权限:能访问面板即可 + 修改配置(含下游凭据)。不要把面板直接暴露到公网。 +2. **两套错误约定并存**:面板侧多数业务失败是 `200 + status: "error"`,代理池侧是真实 + 4xx/5xx + `error_code`。调用方两个都要判。 +3. **`message` 随界面语言变化**,只可用于展示,不可用于程序判断。 +4. **运行状态在进程内存中**(当前代理、出口名册、任务表、广告关闭状态),进程重启即重置; + 访问记录与域名统计落在 SQLite,重启后仍在。 +5. **`web_port` 改动需重启整个进程;`port` 改动需重启出口服务**(走 `POST /api/config` + 保存时面板会自动重启出口服务;手工编辑与命令行入口需自行重启),其余配置项保存后 + 即时生效,但个别项例外:如访问记录缓冲 `access_records_buffer_size` 要等代理服务下次 + 启动时才按新容量重建缓冲。手工编辑 `config.ini` 的 `[Server]` 改动也会被自动热重载 + (两个入口都在轮询配置文件),但端口换绑不会自动发生;`[Pool]` 段由池自行热加载。 +6. **多数**批量操作返回 `task_id`(两个删除端点直接返回 `success` / `deleted_count`), + 用 `GET /api/pool/tasks/{task_id}` 查进度;任务动辄跑几十分钟,可随时用 + `POST /api/pool/tasks/{task_id}/cancel` 中止。注意离线归属地库更新任务的下载跑在 + 不可取消的线程里,取消只会写出状态、下载仍会跑完。任务表是内存态且有 1 小时 TTL, + 进程重启后不可查。 +7. **代理池的查询参数是签名白名单**:参数名写错会被静默忽略,返回未筛选的结果而不报错。 +8. 修改 `/api/pool/*` 的端点或字段时,**必须同步更新本文档** —— 适配层是手写的 Flask 蓝图, + 不像 FastAPI 那样能自动生成 OpenAPI 描述。 + +--- + +# 附录 A:错误码总表 + +## 面板接口错误约定 + +| 情形 | HTTP | 响应体 | +|---|---|---| +| 成功 | 200 | `{"status": "success", ...}` | +| 成功(部分只读端点) | 200 | 直接返回数据,**无 `status` 字段** | +| 鉴权失败 | 401 | `{"status": "error", "message": "..."}` | +| 配置校验未通过(仅 `POST /api/config`) | 400 | `{"status": "error", "message": "...", "details": {"key": "...", "reason": "..."}}` | +| 其余业务失败 | **200** | `{"status": "error", "message": "..."}` | + +## 代理池接口错误约定 + +| error_code | HTTP | 含义 | +|---|---|---| +| `invalid_parameter` | 400 | 请求参数非法 | +| `api_error` | 4xx / 5xx | 业务错误(代理不存在、无匹配结果、规格不符、插件操作失败、恢复失败等);状态码随 `ApiError` 自带 | +| `pool_running` | 409 | 池运行中,不允许该操作(仅 `POST /api/pool/backups/restore`) | +| `pool_unavailable` | 503 | 代理池未运行或正在重启 | +| `pool_timeout` | 504 | 代理池响应超时 | +| `queue_full` | 503 | 写入队列已满 | +| `internal_error` | 500 | 服务端内部错误 | +| *(无)* | 500 | 池启停失败:`POST /api/pool/start\|stop\|restart` 失败时**不带 `error_code`** | + +--- + +# 附录 B:端点总索引 + +## 页面路由(非 `api`) + +| 方法 | 路径 | 鉴权 | 说明 | +|---|---|---|---| +| GET | `/` | 否 | 带 token 跳转到 `/web` | +| GET | `/web` | 是 | 面板单页 | +| GET | `/static/` | 否 | 前端静态资源 | + +## 面板接口(33) + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/status` | 运行状态 | +| GET | `/api/config` | 读取 `[Server]` / `[Pool]` 配置 | +| POST | `/api/config` | 保存配置 | +| POST | `/api/language` | 切换界面语言 | +| GET | `/api/proxies` | 读本地代理列表 | +| POST | `/api/proxies` | 写本地代理列表 | +| GET | `/api/check_proxies` | 检测源清单里的代理可用性(local 来源即 `ip.txt` 全部条目) | +| GET | `/api/ip_lists` | 读 IP 黑白名单 | +| POST | `/api/ip_lists` | 写 IP 黑白名单 | +| GET | `/api/bypass_whitelist` | 读绕过名单 | +| POST | `/api/bypass_whitelist` | 写绕过名单 | +| POST | `/api/service` | 启停 / 重启出口代理服务 | +| GET | `/api/switch_proxy` | 刷新出口池:不满则补满,满员则换掉最近到期的那个出口 | +| GET | `/api/logs` | 查询内存日志 | +| GET | `/api/logs/stats` | 日志统计 | +| GET | `/api/logs/files` | 列出日志文件 | +| GET | `/api/logs/file` | 读取日志文件 | +| GET | `/api/logs/export` | 导出日志 | +| POST | `/api/logs/clear` | 清空日志 | +| GET | `/api/logs/records` | 查询访问记录 | +| GET | `/api/logs/records/export` | 导出访问记录 | +| GET | `/api/logs/records/options` | 访问记录过滤候选项 | +| POST | `/api/logs/records/clear` | 清空访问记录 | +| GET | `/api/logs/domains` | 查询域名统计 | +| POST | `/api/logs/domains/clear` | 清空域名统计 | +| GET | `/api/users` | 读出口代理账号 | +| POST | `/api/users` | 写出口代理账号 | +| GET | `/api/api_credentials` | 读上游 API 凭据 | +| POST | `/api/api_credentials` | 管理上游 API 凭据 | +| GET | `/api/version` | 版本检查结果(只读缓存,**免鉴权**) | +| GET | `/api/ads` | 获取广告(**免鉴权**) | +| POST | `/api/ads/dismiss` | 关闭广告(**免鉴权**) | +| POST | `/api/ads/reopen` | 重新开启广告(**免鉴权**) | + +## 代理池接口(38) + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/pool/status` | 池状态与全局统计 | +| GET | `/api/pool/schema` | `[Pool]` 字段结构(面板表单同源) | +| POST | `/api/pool/start` | 启动池 | +| POST | `/api/pool/stop` | 停止池(等写队列排空) | +| POST | `/api/pool/restart` | 重启池 | +| GET | `/api/pool/get` | 筛选代理列表(`json` / `text`) | +| GET | `/api/pool/random` | 随机取一个代理(`json` / `text`) | +| GET | `/api/pool/count` | 统计符合条件的代理数 | +| GET | `/api/pool/export` | 导出代理(`txt` / `json` / `csv`) | +| POST | `/api/pool/import` | 批量导入(先验证再入库) | +| POST | `/api/pool/proxies/{id}/favorite` | 切换收藏状态 | +| GET | `/api/pool/stats` | 池统计 | +| GET | `/api/pool/sources` | 来源插件名列表 | +| GET | `/api/pool/host-ip` | 本机公网出口 IP | +| GET | `/api/pool/plugins` | 插件列表与状态 | +| POST | `/api/pool/plugins/{name}/enable` | 启用插件 | +| POST | `/api/pool/plugins/{name}/disable` | 禁用插件 | +| PUT | `/api/pool/plugins/{name}/interval` | 设置抓取周期 | +| PUT | `/api/pool/plugins/{name}/test-url` | 设置插件专用检测地址 | +| PUT | `/api/pool/plugins/{name}/validation` | 设置插件验证策略 | +| POST | `/api/pool/plugins/{name}/run` | 立即执行插件 | +| POST | `/api/pool/plugins/{name}/reload` | 热重载插件 | +| POST | `/api/pool/validate/all-valid` | 重新验证所有有效代理 | +| POST | `/api/pool/validate/all-invalid` | 重新验证所有失效代理 | +| POST | `/api/pool/update-geo/all` | 全面检测并补归属地 | +| DELETE | `/api/pool/delete/invalid` | 删除全部失效代理 | +| POST | `/api/pool/proxies/batch/validate` | 批量验证指定代理 | +| DELETE | `/api/pool/proxies/batch/delete` | 批量删除指定代理(参数在请求体) | +| GET | `/api/pool/tasks` | 列出任务 | +| GET | `/api/pool/tasks/{task_id}` | 查询任务状态 | +| POST | `/api/pool/tasks/{task_id}/cancel` | 取消任务 | +| GET | `/api/pool/geo/status` | 离线归属地库状态 | +| POST | `/api/pool/geo/update` | 更新离线归属地库(约 100 MB) | +| POST | `/api/pool/geo/recompute` | 重算存量记录的归属地 | +| GET | `/api/pool/backups` | 列出备份 | +| POST | `/api/pool/backups/restore` | 从备份恢复(需先停池) | +| GET | `/api/pool/database/stats` | 数据库统计 | +| POST | `/api/pool/database/optimize` | 优化数据库(ANALYZE / REINDEX / VACUUM) | + +--- + +# 附录 C:字段字典 + +## 代理对象 + +`GET /api/pool/get`、`GET /api/pool/random`(`format=json`)返回的对象。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 主键 | +| `proxy_url` | string | 完整代理地址;用户名与密码**都存在**时形如 `协议://用户:密码@ip:端口` | +| `protocol` | string | `http` / `https` / `socks5` | +| `ip` | string | 入口 IP | +| `port` | int | 端口 | +| `username` / `password` | string \| null | 代理认证凭据 | +| `real_ip` | string \| null | 出口 IP(经代理访问时对端看到的 IP) | +| `exit_ip_differs` | bool | 入口 IP 与出口 IP 是否不同。**查询与随机响应都提供**(两者共用同一序列化),导出路径刻意不带 | +| `region` | string | 中文归属地全名,如「中国 广东 深圳」;查不到为「未知」 | +| `country` / `province` / `city` | string | 中文归属地分级 | +| `region_en` / `country_en` / `province_en` / `city_en` | string \| null | 英文归属地分级 | +| `delay_ms` | float \| null | 最近一次测得的延迟(毫秒) | +| `validated_at` | string \| null | 最近一次验证时间(ISO 8601) | +| `source_plugin` | string | 来源插件名;手动导入为 `manual_import` | +| `is_favorite` | bool | 是否收藏(用户选择,抓取与验证不会覆盖) | +| `is_valid` | bool | 当前是否可用;列表用它给失效行打「失效」徽标。导出走自己的状态列 | +| `health_score` | float | 健康分,**0-100**,保留 1 位小数 | +| `anonymity_level` | string | `transparent` / `anonymous` / `elite` / `unverified` | +| `anonymity_fallback` | bool | 匿名度是否被判成兜底档 | +| `supports_https` / `supports_http` | bool | 是否支持对应协议 | +| `quality_assessed_at` | string \| null | 最近一次质量评估时间(ISO 8601) | +| `success_rate` | float | 验证成功率,0-1,保留 3 位小数 | +| `avg_delay_ms` | float \| null | 历史平均延迟(毫秒) | +| `total_checks` | int | 累计验证次数;缺值以 0 兜底 | +| `probe_success_rate` | float | 探测成功率,0-1,保留 3 位小数 | +| `probe_total_count` | int | 累计探测次数;缺值以 0 兜底 | + +> **归属地空值的语义**:中英两套按列给出,`null` 表示**还没查过**(不是「未知」)。 +> 英文列缺失时前端回退到中文列;两列相同表示该级没有对应译名。不要往这些列里写 +> 「未知」文案 —— 「未知」是中文列查询过但没查到的取值。 + +## 任务对象 + +`GET /api/pool/tasks/{task_id}` 的响应,也是 `GET /api/pool/tasks` 列表里的元素。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `task_id` | string | 任务 id;**只在 `GET /api/pool/tasks` 的列表元素里出现**(列表逐条注入),单查接口的响应里没有 | +| `status` | string | `running` / `completed` / `failed` / `cancelled`(`queued` 只出现在部分创建响应里,任务表里没有) | +| `message` | string | 当前阶段的可读描述,跟随界面语言 | +| `progress` / `total` | int | 已完成 / 总数 | +| `started_at` / `completed_at` | string | 起止时间(ISO 8601);未结束则无 `completed_at` | +| `stage` | string | `prescreen` / `validate` / `ingest` / `done`(完成时写 `done`),**仅导入与抓取类任务有** | +| `stage_done` / `stage_total` | int | 当前阶段的进度,同上 | +| `in_flight` | int | 当前阶段正在并发处理中的条数,同上 | +| `rate` | float | 处理速率(条/秒) | +| `eta_seconds` | int \| null | 预计剩余秒数 | +| `valid_count` / `invalid_count` / `inconclusive_count` | int | 验证结果分类计数 | +| `prescreened_out` | int | 端口初筛即淘汰的条数 | +| `incomplete_count` / `failed_count` | int | 未完成 / 失败条数 | +| `results` | object | 数据文件名到结果文案(`已更新` / `失败: ...`)的映射,**仅离线归属地库更新任务的完成态有** | + +**字段集合随任务类型与端点不同**,上表是各处的并集;只依赖 `status`、`progress`、 +`total`、`message` 这四个字段最稳妥。 + +## 备份对象 + +`GET /api/pool/backups` 列表里的元素。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `filename` | string | 备份文件名,作为 `POST /api/pool/backups/restore` 的参数 | +| `size` | int | 文件大小(字节) | +| `created_at` | string | 创建时间,格式固定为 `YYYY-MM-DD HH:MM:SS` | + diff --git a/Dockerfile b/Dockerfile index 4aecfcc..adb8f8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/ProxyCat-Manual/Configuration-EN.md b/ProxyCat-Manual/Configuration-EN.md new file mode 100644 index 0000000..b3eb22a --- /dev/null +++ b/ProxyCat-Manual/Configuration-EN.md @@ -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 Source group on the panel](../assets/screenshots/config-source-en.png) + +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 Listening ports group on the panel](../assets/screenshots/config-listen-en.png) + +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 Exit rotation group on the panel](../assets/screenshots/config-exits-en.png) + +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 Availability checks group on the panel](../assets/screenshots/config-check-en.png) + +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 Performance group on the panel](../assets/screenshots/config-perf-en.png) + +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 Logging and records group on the panel](../assets/screenshots/config-logs-en.png) + +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 Advanced group on the panel](../assets/screenshots/config-advanced-en.png) + +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 Pool Settings page](../assets/screenshots/pool-settings-en.png) + +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 Database Maintenance page](../assets/screenshots/pool-db-en.png) + +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. diff --git a/ProxyCat-Manual/Configuration.md b/ProxyCat-Manual/Configuration.md new file mode 100644 index 0000000..e1ab07a --- /dev/null +++ b/ProxyCat-Manual/Configuration.md @@ -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]` 段在面板「代理配置」页里按用途分成七组,下面按同一分组列出全部键,每项的生效方式见表格末列。 + +### 来源设置 + +只显示当前来源用得上的字段。 + +![面板「来源设置」组](../assets/screenshots/config-source.png) + +图中为面板「代理配置 → 来源设置」组:顶部按钮切换代理来源,表单只渲染当前来源用得上的字段。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `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` 表示暂时没有可用代理。留空表示使用进程内的代理池 | 即时 | + +### 监听端口 + +端口被占用时才需要改。 + +![面板「监听端口」组](../assets/screenshots/config-listen.png) + +图中为面板「代理配置 → 监听端口」组,另附一条说明:访问令牌不在这里修改。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `port` | `1080` | 代理监听端口,HTTP 与 SOCKS5 共用(1-65535) | 需重启代理服务 | +| `web_port` | `5001` | Web 面板端口(1-65535);改了必须重启整个应用,不是只重启代理服务 | 需重启整个应用 | +| `token` | 空 | Web 面板访问令牌,经 `?token=` 校验;留空表示面板不校验。面板**没有**这个入口 —— 改了会让当前地址立刻失效、把你锁在面板外,需要更换时编辑 `config.ini` 保存 | 改文件后热重载(约 1 秒),旧 token 随即失效 | + +> **`token` 留空的风险**:留空时任何人只要连得上面板端口就能读写配置、查看访问记录, +> 也能拿到上游凭据。面板默认只应在本机使用;要暴露到网络上请务必设置 `token`。 +> 另外,**仓库里自带的 `config.ini` 带了一个公开的默认 token**,对外使用前请换掉。 + +### 出口轮换 + +一个出口用多久、同时用几个。 + +![面板「出口轮换」组](../assets/screenshots/config-exits.png) + +图中为面板「代理配置 → 出口轮换」组:运行模式、两种寿命口径、在用出口数与无出口时的排队时长。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `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` | 即时 | + +### 可用性检测 + +什么样的代理能进池。 + +![面板「可用性检测」组](../assets/screenshots/config-check.png) + +图中为面板「代理配置 → 可用性检测」组:检测目标、启动检测与取用前校验、批量检测并发数。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `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` | 即时 | + +### 性能与并发 + +本机能同时扛多少请求。 + +![面板「性能与并发」组](../assets/screenshots/config-perf.png) + +图中为面板「代理配置 → 性能与并发」组:全局并发、单出口并发与自动扩容开关。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `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`(不限)时不扩,但已经张开的膨胀状态照常收回 | 即时 | + +### 日志与记录 + +记什么、留多久。 + +![面板「日志与记录」组](../assets/screenshots/config-logs.png) + +图中为面板「代理配置 → 日志与记录」组:日志级别与访问日志、访问明细、域名统计三套开关及各自的保留口径。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `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` | 访问统计的落库间隔(秒) | 即时 | + +### 高级选项 + +平时不用动,改错很难查。 + +![面板「高级选项」组](../assets/screenshots/config-advanced.png) + +图中为面板「代理配置 → 高级选项」组:各项冷却、缓存与内部机制相关的调优项都在这里。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `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` 驱动。 + +![面板「池设置」页](../assets/screenshots/pool-settings.png) + +图中为面板「代理池 → 池设置」页:下面八个分组的表单都在这一页里。 + +### 数据库 + +数据存在哪、怎么备份。 + +![面板「数据库维护」页](../assets/screenshots/pool-db.png) + +图中为面板「代理池 → 数据库维护」页:数据库统计与优化、备份列表与恢复、离线归属地库的更新与重算。 + +| 配置项 | 默认值 | 说明 | 生效方式 | +|---|---|---|---| +| `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` 推算,手改的值会被推导值覆盖,留在这里只为了兼容老配置。 diff --git a/ProxyCat-Manual/Features-EN.md b/ProxyCat-Manual/Features-EN.md new file mode 100644 index 0000000..a8d4096 --- /dev/null +++ b/ProxyCat-Manual/Features-EN.md @@ -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: + +![Exit rotation](../assets/screenshots/config-exits-en.png) + +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. + +![Performance and concurrency](../assets/screenshots/config-perf-en.png) + +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: + +![Performance test](../assets/性能测试图.png) + +The screenshot is a Yakit load test through `http://neko:123456@127.0.0.1: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 +``` diff --git a/ProxyCat-Manual/Features.md b/ProxyCat-Manual/Features.md new file mode 100644 index 0000000..b01fd4d --- /dev/null +++ b/ProxyCat-Manual/Features.md @@ -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` 决定放行还是拦截; + 另有绕过名单:命中的目标地址不经代理、直接连接。 +- **出口认证**:支持用户名 / 密码认证,账号表为空即关闭认证。 + +换来源用「代理配置」页顶部的来源切换按钮,改模式与寿命、调出口容量则在「出口轮换」组里完成,保存后即时生效: + +![出口轮换](../assets/screenshots/config-exits.png) + +上图是「维护池代理」来源下的「出口轮换」组:更换方式、出口寿命(按时间与按次数两条独立 +规则)、在用出口数与无可用出口时的排队时长。 + +## 内置代理池 + +- **插件化抓取**:抓取插件放进 `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` 是全局信号量,对三类请求一视同仁,按「隧道」而不是「请求」估: + 它才是「本机愿意同时挂多少条隧道」的硬上限,每出口额度之和远超它时,真正生效的是它。 + +![性能与并发](../assets/screenshots/config-perf.png) + +上图是面板「代理配置 → 性能与并发」组:全局并发上限与单出口并发上限; +「单出口并发上限」填 `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 —— 客户端流量转发本身就是让上游替你访问目标。想让某一条停下来的最小操作见「如何关闭」列。 + +## 性能与基准 + +转发通路的吞吐表现: + +![性能测试](../assets/性能测试图.png) + +这张图是用 Yakit 经 `http://neko:123456@127.0.0.1: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 +``` diff --git a/ProxyCat-Manual/Investigation Manual-EN.md b/ProxyCat-Manual/Investigation Manual-EN.md new file mode 100644 index 0000000..7f1a739 --- /dev/null +++ b/ProxyCat-Manual/Investigation Manual-EN.md @@ -0,0 +1,23 @@ +[Back to README](../README-EN.md) + +Q: Abnormal proxy addresses appear and the proxy cannot be used + +![Abnormal proxy address built from the API error line](./Investigation%20Manual.assets/1.png) + +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. diff --git a/ProxyCat-Manual/Investigation Manual.md b/ProxyCat-Manual/Investigation Manual.md index 7ddc17f..389ccf4 100644 --- a/ProxyCat-Manual/Investigation Manual.md +++ b/ProxyCat-Manual/Investigation Manual.md @@ -1,19 +1,21 @@ +[返回 README](../README.md) + Q:出现不正常代理地址,无法正常使用代理 ![1](./Investigation%20Manual.assets/1.png) -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 两种代理协议,只转发客户端按这两种协议接入的连接;端口扫描等直接发送原始数据包的任务不经过代理协议握手,无法经本工具转发。 diff --git a/ProxyCat-Manual/Operation Manual-EN.md b/ProxyCat-Manual/Operation Manual-EN.md new file mode 100644 index 0000000..f0b1e9c --- /dev/null +++ b/ProxyCat-Manual/Operation Manual-EN.md @@ -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 +``` + +![Cloning the project with Git](./Operation%20Manual.assets/Linux%20Download.png) + +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:123456@127.0.0.1:7890 +https://neko:123456@127.0.0.1:7890 +http://neko:123456@127.0.0.1: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) + +![Docker Desktop download guide](./Operation%20Manual.assets/Docker%20Desktop%20Download.png) + +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/) + +![Docker installation guide](./Operation%20Manual.assets/Docker%20Download.png) + +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 + +![Proxy Config tab: Source group with the Pool source selected](../assets/screenshots/config-source-en.png) + +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. + +![Proxy Config tab: Performance group](../assets/screenshots/config-perf-en.png) + +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 + +![Proxy Pool sub-view: Proxy Management](../assets/screenshots/pool-manage-en.png) + +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 + +![Proxy Pool sub-view: Pool Settings](../assets/screenshots/pool-settings-en.png) + +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 + +![Proxy Pool sub-view: Database Maintenance](../assets/screenshots/pool-db-en.png) + +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 + +![Access Control tab](../assets/screenshots/access-en.png) + +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 + +![Logs tab](../assets/screenshots/logs-en.png) + +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. diff --git a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-02-21_16-23-35.png b/ProxyCat-Manual/Operation Manual.assets/Clip_2025-02-21_16-23-35.png deleted file mode 100644 index c83112a..0000000 Binary files a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-02-21_16-23-35.png and /dev/null differ diff --git a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-54-38.png b/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-54-38.png deleted file mode 100644 index abadeb5..0000000 Binary files a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-54-38.png and /dev/null differ diff --git a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-55-05.png b/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-55-05.png deleted file mode 100644 index 3940606..0000000 Binary files a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-55-05.png and /dev/null differ diff --git a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-55-37.png b/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-55-37.png deleted file mode 100644 index f634257..0000000 Binary files a/ProxyCat-Manual/Operation Manual.assets/Clip_2025-03-03_10-55-37.png and /dev/null differ diff --git a/ProxyCat-Manual/Operation Manual.assets/Run.png b/ProxyCat-Manual/Operation Manual.assets/Run.png deleted file mode 100644 index ab36d52..0000000 Binary files a/ProxyCat-Manual/Operation Manual.assets/Run.png and /dev/null differ diff --git a/ProxyCat-Manual/Operation Manual.assets/Windows Download.png b/ProxyCat-Manual/Operation Manual.assets/Windows Download.png deleted file mode 100644 index badba24..0000000 Binary files a/ProxyCat-Manual/Operation Manual.assets/Windows Download.png and /dev/null differ diff --git a/ProxyCat-Manual/Operation Manual.md b/ProxyCat-Manual/Operation Manual.md index 3bb8beb..3593143 100644 --- a/ProxyCat-Manual/Operation Manual.md +++ b/ProxyCat-Manual/Operation Manual.md @@ -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 Download](./Operation%20Manual.assets/Windows%20Download.png) +**Windows 与 Mac**:浏览器访问位于 Github 的源码存储库并下载:[ProxyCat](https://github.com/honmashironeko/ProxyCat) **Linux**:通过 Git 方法拉取项目到本地 -``` +```bash git clone https://github.com/honmashironeko/ProxyCat.git ``` -![Linux Download](./Operation%20Manual.assets/Linux%20Download.png) +![通过 Git 拉取项目](./Operation%20Manual.assets/Linux%20Download.png) -安装 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:123456@127.0.0.1:7890 https://neko:123456@127.0.0.1:7890 http://neko:123456@127.0.0.1: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) -![Run](./Operation%20Manual.assets/Run.png) - -### Docker 手册 - -Windows 可以下载 Docker 官方工具:[Docker Desktop](docs.dockerd.com.cn) - -![Docker%20Desktop%20Download](./Operation%20Manual.assets/Docker%20Desktop%20Download.png) +![Docker Desktop 下载引导](./Operation%20Manual.assets/Docker%20Desktop%20Download.png) Linux 可以通过清华大学源提供的脚本一键安装:[清华大学安装脚本](https://mirrors.tuna.tsinghua.edu.cn/help/docker-ce/) -![Docker%20Download](./Operation%20Manual.assets/Docker%20Download.png) +![Docker 下载引导](./Operation%20Manual.assets/Docker%20Download.png) -安装完成后请测试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` 可以不动进程,在面板里重启一次代理服务即可按新端口生效。 -![Clip_2025-02-21_16-23-35](./Operation%20Manual.assets/Clip_2025-02-21_16-23-35.png) +## 面板使用 -![Clip_2025-03-03_10-54-38](./Operation%20Manual.assets/Clip_2025-03-03_10-54-38.png) +面板是**一个单页**,地址 `http://127.0.0.1:5001/web`(记得带 `?token=`),左侧四个页签: -![Clip_2025-03-03_10-55-05](./Operation%20Manual.assets/Clip_2025-03-03_10-55-05.png) +| 页签 | 在这里做什么 | 背后由什么支撑 | +|---|---|---| +| **代理配置** | 切换代理来源(本地 / API / 代理池)、改轮换模式与间隔、编辑本地代理列表、保存 API 凭据、调日志与统计开关;并发等调优项在「性能」与「高级选项」组里 | `[Server]` 段配置 | +| **代理池** | 见下方三个子视图 | `[Pool]` 段配置 + `/api/pool/*` | +| **访问控制** | 用户管理(出口认证账号)、IP 黑白名单与优先级、代理绕过白名单 | `[Users]` 段与 `config/` 下的名单文件 | +| **运行日志** | 按类别看实时日志、浏览历史日志文件、看访问统计与访问明细 | `logs/` 下的日志与两个统计库 | -![Clip_2025-03-03_10-55-37](./Operation%20Manual.assets/Clip_2025-03-03_10-55-37.png) +「代理池」页签下有三个子视图: -## 问题Q&A +| 子视图 | 在这里做什么 | +|---|---| +| **代理管理** | 看抓到的代理(协议 / 真实 IP / 归属地 / 延迟 / 健康分 / 匿名度)、按条件筛选、批量验证与删除、导入导出、手动触发插件抓取 | +| **池设置** | 调代理池自身的运行参数(验证并发与超时、抓取周期、自动重验证、自动清理、使用反馈、写队列) | +| **数据库维护** | 数据库统计与优化、备份列表与恢复、离线归属地库的更新与重算 | -Q:为什么运行后我的XXX工具代理还是没换? +下面逐页签展开。 -A:ProxyCat 并不是全局代理工具,需要XXX工具支持使用代理,将流量发送到 ProxyCat 的本地监听端口才会经过代理池。 +### 代理配置 +![代理配置页签:来源设置(维护池代理)](../assets/screenshots/config-source.png) +图中是「代理配置」页签:页头是服务控制(启动 / 停止 / 重启)、两个监听地址与代理状态;下面可在「本地代理 / 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` 里的全部条目),所以已经被停用的坏出口也查得到。 + +![代理配置页签:性能与并发](../assets/screenshots/config-perf.png) + +「性能与并发」组管本机能同时扛多少请求:全局并发上限是所有出口合计的上限,超出后在信号量上排队;单出口并发上限 `0` 表示不限,此时并发只受全局并发上限约束;设成非 0 后,总上游并发约等于 `出口数 × 本值`(仍受全局上限封顶)。截图里的提示「扩容只在单出口并发上限不为 0 时有意义」正是自动扩容的开关条件 —— 填 0 时不扩,但已经张开的膨胀状态照常收回。扩容与收回为什么看两个不同的数、放宽单出口额度为什么是最后手段,见[上游出口与并发](Features.md#上游出口与并发)。 + +每个配置项的含义、默认值与生效方式(即时 / 需重启)以[详细配置](Configuration.md)为准;面板表单本身带校验,保存即生效,推荐日常在这里修改。 + +### 代理池 + +#### 代理管理 + +![代理池子视图:代理管理](../assets/screenshots/pool-manage.png) + +截图自上而下是:统计卡(代理总数 / 有效 / 失效 / 可用率)、插件管理表(每个插件的状态、抓取周期、上次与下次运行、重验证策略,以及「抓取」「测试地址」「重载」按钮)、带筛选的代理列表(协议、代理地址、真实 IP、地区、延迟、健康分、匿名度,右上角有导入 / 导出 / 复制取用链接)。 + +在这里可以:按协议 / 有效性 / 匿名度 / 来源筛选(还有高级筛选),对选中的代理批量验证与删除,导入与导出代理,手动触发插件抓取。列表里还提示了匿名度是实测还是推算 —— 拿不到回显证据的代理只能给推算值。 + +#### 池设置 + +![代理池子视图:池设置](../assets/screenshots/pool-settings.png) + +池设置按用途分为八组:数据库 / 代理验证 / 抓取插件 / 自动重新验证 / 自动清理 / 使用反馈 / 写入队列 / 日志,每个组内的调优项折叠在「高级选项」里。截图选中的是「数据库」组,管自动备份的开关、间隔(小时)与保留天数 —— 自动备份关掉后不再生成新备份,保留天数决定能恢复到多久以前。每个字段的说明以[详细配置](Configuration.md)的 `[Pool]` 段落为准。 + +#### 数据库维护 + +![代理池子视图:数据库维护](../assets/screenshots/pool-db.png) + +截图自上而下是:数据库统计(代理总数 / 有效 / 失效 / 数据库体积)与「优化数据库」按钮;历史备份列表(时间、大小,最新一份带「最新」标记),每份备份都能一键「恢复」;底部的归属地库 —— 显示离线库可用状态、数据文件与修改时间,以及「更新归属地库」与「重算归属地」两个动作。 + +- 「优化数据库」会重建数据库索引并压缩文件,期间写入会短暂等待; +- 「恢复」会**用该备份覆盖当前数据库**:代理池会先停止,恢复完成后自动重启; +- 归属地库离线可用时,中国城市级解析用纯真库(`qqwry.ipdb`),其余用 GeoLite2(`GeoLite2-City.mmdb`);「更新归属地库」需要联网下载(约 104MB,需数分钟),「重算归属地」则对存量记录重新解析,有待重算记录时才可用。 + +### 访问控制 + +![访问控制页签](../assets/screenshots/access.png) + +截图分三块:左上「用户管理」列出出口认证账号(截图中是仓库自带的 `k` 与 `neko` 两个账号,可改密码与增删用户);右上是 IP 访问控制的**白名单**与**黑名单**,底部下拉选择同时命中时的优先级,点「保存名单」落盘;下方是**代理绕过白名单**,命中的目标地址直连、不经过上游代理,支持通配符。 + +- 用户管理编辑的是 `[Users]` 段(「账号 = 密码」一行一个):**账号表为空时代理不要求身份鉴别**。程序内置默认没有 `[Users]` 段、不要求认证;仓库自带的 `config.ini` 里出厂账号是 `k = 123` 与 `neko = 123456`,所以不带认证的请求会得到 407。请通过面板的用户管理修改账号 —— 手工编辑 `config.ini` 的 `[Users]` 段同样会随配置热重载生效(约 1 秒内)。 +- 白名单 / 黑名单文件固定从 `config/` 目录读取;同时命中时按 `ip_auth_priority`(`whitelist` 放行 / `blacklist` 拦截)决定。 +- 三份名单的文件名与匹配规则见[详细配置](Configuration.md)。 + +### 运行日志 + +![运行日志页签](../assets/screenshots/logs.png) + +截图顶部是两排过滤:类别(全部 / 主程序 / 代理 / 访问 / 池)与级别(全部 / 重要 / 信息 / 警告 / 错误 / 严重错误);右侧可切换实时日志与历史日志文件,以及自动刷新、跟随、导出、清除日志。日志列表中的每行带时间、级别与类别。 + +- 「访问」类别下有按代理汇总的访问统计与逐条访问明细,见[日志与访问记录](#日志与访问记录); +- **日志级别与访问日志是联动的**:成功访问行是 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。 diff --git a/ProxyCat-Manual/logs-EN.md b/ProxyCat-Manual/logs-EN.md new file mode 100644 index 0000000..0fe1626 --- /dev/null +++ b/ProxyCat-Manual/logs-EN.md @@ -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 `` 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. diff --git a/ProxyCat-Manual/logs.md b/ProxyCat-Manual/logs.md index 63a5bf0..8c888ee 100644 --- a/ProxyCat-Manual/logs.md +++ b/ProxyCat-Manual/logs.md @@ -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 项不得窄于内容的保护) +- 修复运行日志页签渲染在卡片容器之外的问题(「访问控制」页签末尾多了一个 ``,提前关闭了 `.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 diff --git a/ProxyCat.py b/ProxyCat.py index c086846..73e32a6 100644 --- a/ProxyCat.py +++ b/ProxyCat.py @@ -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() diff --git a/README-EN.md b/README-EN.md index a806dde..43a703d 100644 --- a/README-EN.md +++ b/README-EN.md @@ -1,57 +1,139 @@ ![ProxyCat](https://socialify.git.ci/honmashironeko/ProxyCat/image?description=1&descriptionEditable=A%20lightweight%20and%20excellent%20proxy%20pool%20middleware%20that%20implements%20automatic%20proxy%20rotation&font=Bitter&forks=1&issues=1&language=1&logo=https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F139044047%3Fv%3D4&name=1&owner=1&pattern=Circuit%20Board&pulls=1&stargazers=1&theme=Dark)

- English - · 简体中文 + · + English

## 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. ![Project Principle](./assets/项目原理图.png) -## 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 | +|---|---| +| ![Proxy Config · Source](./assets/screenshots/config-source-en.png) | ![Proxy Pool · Proxy Management](./assets/screenshots/pool-manage-en.png) | +| 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 | +|---|---| +| ![Access Control](./assets/screenshots/access-en.png) | ![Logs](./assets/screenshots/logs-en.png) | +| 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 | +|---|---| +| ![Dark Theme · Exit Rotation](./assets/screenshots/config-exits-dark.png) | ![Dark Theme · Proxy Management](./assets/screenshots/pool-manage-dark.png) | +| 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:123456@127.0.0.1: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! | (´∀`)♡ | ---- ![Sponsor](./assets/赞助.png) ## Proxy Recommendations diff --git a/README.md b/README.md index e7a509b..5d557cc 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,180 @@ -![ProxyCat](https://socialify.git.ci/honmashironeko/ProxyCat/image?custom_description=%E4%B8%80%E6%AC%BE%E6%94%AF%E6%8C%81%E5%A4%9A%E5%8D%8F%E8%AE%AE%E7%9A%84%E9%9A%A7%E9%81%93%E4%BB%A3%E7%90%86%E6%B1%A0%E4%B8%AD%E9%97%B4%E4%BB%B6%E6%9C%8D%E5%8A%A1%EF%BC%8C%E5%AE%9E%E7%8E%B0%E4%BD%8E%E6%88%90%E6%9C%AC%E4%BB%A3%E7%90%86%E7%9A%84%E8%87%AA%E5%8A%A8%E8%BD%AE%E6%8D%A2&description=1&font=Bitter&forks=1&issues=1&language=1&logo=https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F139044047%3Fv%3D4%26size%3D1080&name=1&owner=1&pattern=Circuit+Board&pulls=1&stargazers=1&theme=Dark) +![ProxyCat](https://socialify.git.ci/honmashironeko/ProxyCat/image?description=1&descriptionEditable=A%20lightweight%20and%20excellent%20proxy%20pool%20middleware%20that%20implements%20automatic%20proxy%20rotation&font=Bitter&forks=1&issues=1&language=1&logo=https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F139044047%3Fv%3D4&name=1&owner=1&pattern=Circuit%20Board&pulls=1&stargazers=1&theme=Dark)

- English - · 简体中文 + · + English

## 目录 -- [开发缘由](#开发缘由) -- [功能特点](#功能特点) -- [安装与使用](#安装与使用) -- [免责申明](#免责申明) -- [更新日志](#更新日志) +- [项目背景](#项目背景) +- [核心特性](#核心特性) +- [外观展示](#外观展示) +- [快速部署](#快速部署) +- [文档导航](#文档导航) +- [免责声明](#免责声明) +- [开源协议](#开源协议) - [开发计划](#开发计划) -- [特别鸣谢](#特别鸣谢) -- [赞助开源](#赞助开源) +- [特别感谢](#特别感谢) +- [赞助](#赞助) - [代理推荐](#代理推荐) -## 开发缘由 +## 项目背景 -在渗透过程中,经常需要隐藏或更换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 供其他工具使用, +打造一个部署一次即可永久使用的代理池服务器。 ![项目原理图](./assets/项目原理图.png) -## 功能特点 +## 核心特性 -- **两种协议监听**:支持 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) +面板是一个单页,左侧四个页签:代理配置、代理池、访问控制、运行日志。 -## 报错排查 +| 代理配置 · 来源设置 | 代理池 · 代理管理 | +|---|---| +| ![代理配置 · 来源设置](./assets/screenshots/config-source.png) | ![代理池 · 代理管理](./assets/screenshots/pool-manage.png) | +| 在来源设置里选择代理来源(本地代理 / API代理 / 维护池代理),并填写当前来源需要的参数;图中为维护池代理的远程代理池地址(留空即用内置池)。 | 抓到的代理一览:协议、真实 IP、归属地、延迟、健康分与匿名度。 | -[ProxyCat排查手册](../main/ProxyCat-Manual/Investigation%20Manual.md) +| 访问控制 | 运行日志 | +|---|---| +| ![访问控制](./assets/screenshots/access.png) | ![运行日志](./assets/screenshots/logs.png) | +| 出口认证账号、IP 黑白名单与代理绕过名单都在这里。 | 实时日志、历史文件回看,以及访问统计与逐条明细。 | -## 免责申明 +| 深色主题 · 出口轮换 | 英文界面 · 代理管理 | +|---|---| +| ![深色主题 · 出口轮换](./assets/screenshots/config-exits-dark.png) | ![英文界面 · 代理管理](./assets/screenshots/pool-manage-en.png) | +| 深色主题,布局与浅色完全相同。 | 英文界面,与中文界面共用同一份数据与服务端设置。 | -- 如果您下载、安装、使用、修改本工具及相关代码,即表明您信任本工具。 -- 在使用本工具时造成对您自己或他人任何形式的损失和伤害,我们不承担任何责任。 -- 如您在使用本工具的过程中存在任何非法行为,您需自行承担相应后果,我们将不承担任何法律及连带责任。 -- 请您务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款,并选择接受或不接受。 -- 除非您已阅读并接受本协议所有条款,否则您无权下载、安装或使用本工具。 -- 您的下载、安装、使用等行为即视为您已阅读并同意上述协议的约束。 +面板提供深色 / 浅色两档主题,未手动切换过时跟随系统的深色偏好;界面支持中文 / 英文切换, +语言是服务端设置,切换后整站文案与导出文件的表头一起变。 -## 更新日志 +## 快速部署 -[更新日志记录](../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:123456@127.0.0.1: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) | 每一份赞助都是作者的动力! | (´∀`)♡ | ---- ![赞助](./assets/赞助.png) ## 代理推荐 -- [第一家便宜大碗代理购买,用邀请码注册得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) - -![Star History Chart](https://api.star-history.com/svg?repos=honmashironeko/ProxyCat&type=Date) +- [第一家平价代理服务商 - 使用邀请码可获 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) diff --git a/app.py b/app.py index c004aa0..3721bf8 100644 --- a/app.py +++ b/app.py @@ -1,94 +1,150 @@ -from flask import Flask, render_template, jsonify, request, redirect, url_for, send_from_directory +""" +模块名称:app +功能描述:ProxyCat 面板的 Web 入口与进程骨架:用 Flask 承载面板页面与 /api/* 接口,并在本进程内创建、启停与热更新出口代理服务和代理池。 +职责边界:负责:路由注册与 token 鉴权、config.ini 的读取与持久化、出口服务与代理池的进程内启停及配置推送、退出资源收尾。 + 不负责:代理转发与代理池内部业务(见 modules.proxyserver、modules.proxypool_service); + 日志装配与配置取值校验见 modules.logging_setup、modules.config_validation、modules.pool_config_ini。 +关键依赖:modules.modules、modules.logging_setup、modules.proxyserver、modules.proxypool_service、 + modules.proxypool_api、modules.pool_config_ini、modules.config_validation、 + modules.domain_stats、modules.version_check、modules.access_records、 + modules.access_log,Flask 与 waitress。 +已知限制: + 1. 配置迁移只在 __name__ 为 __main__ 时执行且必须早于模块级装配;配置在导入期即被读取,web_port 等启动参数改动需重启进程。 + 2. 鉴权只比对查询串中的单一 token,无 CSRF 防护;/api/version 与三个广告端点有意免鉴权,不要给它们补 require_token。 + 3. 鉴权失败为 401;400 仅出自 POST /api/config 的校验失败,其余端点的参数校验失败均为 200 且 status=error,调用方须同时检查两者。 + 4. 代理列表、IP 黑白名单与 bypass 名单全量写盘:名单由面板按秒轮询,代理列表仅 POST 后重载。 + 5. Windows 上文件被占用时 os.replace 抛 PermissionError,相关 POST 偶发失败。 + 6. 导入即注册 atexit 与信号处理器(SIGINT 恒有、SIGTERM 仅非 Windows、SIGBREAK 仅 Windows);3 秒硬超时(os._exit(1))仅在信号路径。 + 7. watch_server_config 线程按秒同步手改的 config.ini;面板保存后的重复推送由 _note_config_written 记的 mtime 挡住,避免重置切换节奏。 + 8. 比较配置变更时两侧须各自归一(strip/lower);跨来源直接比较会把未变更误判为已变更,无谓清空出口名册重建。 +""" + +from flask import Flask, render_template, jsonify, request, redirect, send_from_directory, Response import sys import os +import csv +import io import logging -from datetime import datetime import json +import threading +from datetime import datetime, timedelta from configparser import ConfigParser from itertools import cycle import werkzeug.serving from functools import wraps +import signal +import atexit -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(BASE_DIR) -from ProxyCat import run_server -from modules.modules import load_config, check_proxies, get_message, load_ip_list -from modules.proxyserver import AsyncProxyServer +from modules.modules import load_config, check_proxies, get_message, load_ip_list, load_bypass_whitelist, CURRENT_VERSION, port_in_use, strip_deprecated_server_keys, migrate_pool_proxy_count, migrate_getip_url, update_ini_keys, write_ini_section, read_config_text, write_text_atomic, ini_parser, enable_safe_console_output +from modules.logging_setup import ( + ALL_CATEGORIES, apply_log_level, clear_log_ring, current_log_filenames, + flush_log_queue, iter_log_files, log_file_path, log_ring_stats, query_log_ring, + read_log_file, setup_logging, shutdown_logging, truncate_log_file, +) +from modules import access_records +from modules.access_log import sanitize_proxy +from modules.access_records import TIME_FORMAT as _RECORD_TIME_FORMAT, RecordFilter +from modules.domain_stats import UPSTREAM_DIRECT, UPSTREAM_UNKNOWN +from modules.proxyserver import AsyncProxyServer, run_server +from modules import pool_config_ini +from modules.config_validation import display_server_values, validate_server_updates +from modules.modules import normalize_rotation_mode, list_files_snapshot, reload_ip_lists_if_changed +from modules.proxypool_service import ProxyPoolService +from modules.proxypool_api import create_pool_blueprint +from modules.version_check import VersionChecker import asyncio -import threading import time -app = Flask(__name__, - template_folder='web/templates', - static_folder='web/static') +CONFIG_INI_PATH = os.path.join(BASE_DIR, 'config', 'config.ini') +LEGACY_POOL_YAML_PATH = os.path.join(BASE_DIR, 'modules', 'proxypool', 'config.yaml') + +app = Flask(__name__, + template_folder=os.path.join(BASE_DIR, 'web', 'templates'), + static_folder=os.path.join(BASE_DIR, 'web', 'static')) werkzeug.serving.WSGIRequestHandler.log = lambda self, type, message, *args: None logging.getLogger('werkzeug').setLevel(logging.ERROR) +logging.getLogger('waitress.task').setLevel(logging.ERROR) -config = load_config('config/config.ini') -server = AsyncProxyServer(config) +_config_sections: dict = {} +_config_sections_mtime: float = 0.0 + + +def config_sections() -> dict: + global _config_sections, _config_sections_mtime + try: + mtime = os.path.getmtime(CONFIG_INI_PATH) + except OSError: + return _config_sections + + if mtime != _config_sections_mtime: + _config_sections = pool_config_ini.read_ini_sections(CONFIG_INI_PATH) + _config_sections_mtime = mtime + return _config_sections + + +if __name__ == '__main__': + pool_config_ini.ensure_pool_section(CONFIG_INI_PATH, LEGACY_POOL_YAML_PATH) + migrate_pool_proxy_count(CONFIG_INI_PATH) + migrate_getip_url(CONFIG_INI_PATH) + strip_deprecated_server_keys(CONFIG_INI_PATH) + +_sections = config_sections() + +config = load_config(CONFIG_INI_PATH) +pool_service = ProxyPoolService( + config=pool_config_ini.build_pool_config( + _sections.get('Server', {}), + _sections.get(pool_config_ini.POOL_SECTION, {}), + strict=False, + ), + config_ini_path=CONFIG_INI_PATH, + language_provider=lambda: server.language, +) +server = AsyncProxyServer(config, pool_service=pool_service) def get_config_path(filename): - return os.path.join('config', filename) + return os.path.join(BASE_DIR, 'config', filename) -log_file = 'logs/proxycat.log' -os.makedirs('logs', exist_ok=True) -log_messages = [] -max_log_messages = 10000 +_config_lock = threading.Lock() -class CustomFormatter(logging.Formatter): - def formatTime(self, record, datefmt=None): - return datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S') +_ad_dismissed = False -def setup_logging(): - file_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s') - file_handler = logging.FileHandler(log_file, encoding='utf-8') - file_handler.setFormatter(file_formatter) +def verify_token(): + config_token = server.config.get('token', '') + if not config_token: + return None + if request.args.get('token') == config_token: + return None + return jsonify({ + 'status': 'error', + 'message': get_message('invalid_token', server.language) + }), 401 - console_handler = logging.StreamHandler() - console_formatter = CustomFormatter('%(asctime)s - %(levelname)s - %(message)s') - console_handler.setFormatter(console_formatter) - - memory_handler = MemoryHandler() - memory_handler.setFormatter(CustomFormatter('%(message)s')) - - root_logger = logging.getLogger() - root_logger.setLevel(logging.INFO) - for handler in root_logger.handlers[:]: - root_logger.removeHandler(handler) - root_logger.addHandler(file_handler) - root_logger.addHandler(console_handler) - root_logger.addHandler(memory_handler) - -class MemoryHandler(logging.Handler): - def emit(self, record): - global log_messages - log_messages.append({ - 'time': datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S'), - 'level': record.levelname, - 'message': self.format(record) - }) - if len(log_messages) > max_log_messages: - log_messages = log_messages[-max_log_messages:] def require_token(f): @wraps(f) def decorated_function(*args, **kwargs): - token = request.args.get('token') - config_token = server.config.get('token', '') - - if not config_token: - return f(*args, **kwargs) - - if not token or token != config_token: - return jsonify({ - 'status': 'error', - 'message': get_message('invalid_token', server.language) - }), 401 - + failure = verify_token() + if failure is not None: + return failure return f(*args, **kwargs) return decorated_function + +_pool_blueprint = create_pool_blueprint(pool_service) + + +@_pool_blueprint.before_request +def _authorize_pool_request(): + return verify_token() + + +app.register_blueprint(_pool_blueprint) + @app.route('/') def root(): token = request.args.get('token') @@ -96,115 +152,299 @@ def root(): return redirect(f'/web?token={token}') return redirect('/web') +_WEB_STATIC_DIR = os.path.join(BASE_DIR, 'web', 'static') + + +def asset_version(): + latest = 0.0 + for root, _dirs, names in os.walk(_WEB_STATIC_DIR): + for name in names: + try: + latest = max(latest, os.path.getmtime(os.path.join(root, name))) + except OSError: + continue + return str(int(latest)) if latest else CURRENT_VERSION + + @app.route('/web') @require_token def web(): - return render_template('index.html') + return render_template('index.html', app_version=CURRENT_VERSION, asset_version=asset_version()) @app.route('/api/status') @require_token def get_status(): - with open('config/config.ini', 'r', encoding='utf-8') as f: - config_content = f.read() - - config = ConfigParser() - config.read('config/config.ini', encoding='utf-8') - - server_config = dict(config.items('Server')) if config.has_section('Server') else {} - - current_proxy = server.current_proxy - if not current_proxy and not server.use_getip: + server_config = config_sections().get('Server', {}) + + current_proxy = sanitize_proxy(server.current_proxy) + if not current_proxy and server.proxy_source_mode == 'local': if hasattr(server, 'proxies') and server.proxies: - current_proxy = server.proxies[0] + current_proxy = sanitize_proxy(server.proxies[0]) else: current_proxy = get_message('no_proxy', server.language) - - + time_left = server.time_until_next_switch() - if server.mode == 'loadbalance': - - if time_left == float('inf'): - time_left = -1 - + if time_left == float('inf'): + time_left = -1 + return jsonify({ 'current_proxy': current_proxy, 'mode': server.mode, - 'port': int(server_config.get('port', '1080')), + 'port': _bound_server_port() or int(server_config.get('port', '1080')), 'interval': server.interval, 'time_left': time_left, + 'switch_countdown_mode': server.switch_countdown_mode(), 'total_proxies': len(server.proxies) if hasattr(server, 'proxies') else 0, - 'use_getip': server.use_getip, - 'getip_url': getattr(server, 'getip_url', '') if getattr(server, 'use_getip', False) else '', + 'active_proxies': server.active_workers() if hasattr(server, 'active_workers') else [], + 'proxy_source_mode': server.proxy_source_mode, + 'exit_count': server.exit_count if hasattr(server, 'exit_count') else 0, + 'elastic_active': bool(getattr(server, 'elastic_active', False)), 'auth_required': server.auth_required, 'display_level': int(server_config.get('display_level', '1')), 'service_status': 'running' if server.running else 'stopped', - 'config': server_config + 'language': server.language, + 'request_interval': server.request_interval, + 'pool_running': pool_service.is_running, + 'source_error': server.last_source_error(), }) + +@app.route('/api/config', methods=['GET']) +@require_token +def get_config(): + sections = pool_config_ini.read_ini_sections(CONFIG_INI_PATH) + server_section = dict(sections.get('Server', {})) + server_section.pop('token', None) + + return jsonify({ + 'status': 'success', + 'server': display_server_values(server_section), + 'pool': dict(sections.get(pool_config_ini.POOL_SECTION, {})), + }) + +def _config_error(message, status=400, details=None): + payload = {'status': 'error', 'message': message} + if details is not None: + payload['details'] = details + return jsonify(payload), status + + +def _error_details(error): + key = getattr(error, 'key', None) + reason_key = getattr(error, 'reason_key', None) + if key is None or reason_key is None: + return None + return { + 'key': key, + 'reason': get_message(reason_key, server.language, + *getattr(error, 'reason_args', ())), + } + + +def _localized_error_text(exc) -> str: + localized = getattr(exc, 'localized', None) + if callable(localized): + return localized(server.language) + return str(exc) + + @app.route('/api/config', methods=['POST']) +@require_token def save_config(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return _config_error(get_message('config_body_must_be_json', server.language)) + + server_updates = payload.get('server') or {} + pool_updates = payload.get('pool') or {} + if not isinstance(server_updates, dict) or not isinstance(pool_updates, dict): + return _config_error( + get_message('config_sections_must_be_json', server.language)) + try: - new_config = request.get_json() - current_config = load_config('config/config.ini') - port_changed = str(new_config.get('port', '')) != str(current_config.get('port', '')) - mode_changed = new_config.get('mode', '') != current_config.get('mode', '') - use_getip_changed = (new_config.get('use_getip', 'False').lower() == 'true') != (current_config.get('use_getip', 'False').lower() == 'true') - - config_parser = ConfigParser() - config_parser.read('config/config.ini', encoding='utf-8') - - if not config_parser.has_section('Server'): - config_parser.add_section('Server') - - for key, value in new_config.items(): - if key != 'users': - config_parser.set('Server', key, str(value)) - - with open('config/config.ini', 'w', encoding='utf-8') as f: - config_parser.write(f) - - - old_mode = server.mode - old_use_getip = server.use_getip - - - server.config = load_config('config/config.ini') - server._init_config_values(server.config) - - - if mode_changed or use_getip_changed: - server._handle_mode_change() - - - if new_config.get('mode') == 'loadbalance': - server.last_switch_time = time.time() - server.last_switch_attempt = 0 - - return jsonify({ - 'status': 'success', - 'port_changed': port_changed, - 'service_status': 'running' if server.running else 'stopped' - }) - + normalized_server = validate_server_updates(server_updates) + normalized_pool = pool_config_ini.normalize_pool_updates(pool_updates) + except ValueError as e: + logging.warning(f"配置校验未通过: {e}") + return _config_error(_localized_error_text(e), details=_error_details(e)) + + if not normalized_server and not normalized_pool: + return _config_error(get_message('config_nothing_to_save', server.language)) + + try: + current_config = load_config(CONFIG_INI_PATH) + port_changed = str(normalized_server.get('port', current_config.get('port'))) != str(current_config.get('port')) + web_port_changed = ( + str(normalized_server.get('web_port', current_config.get('web_port', '5001'))) + != str(current_config.get('web_port', '5001')) + ) + new_source_mode = str(normalized_server.get( + 'proxy_source_mode', current_config.get('proxy_source_mode', 'local'))).strip().lower() + old_source_mode = str(current_config.get('proxy_source_mode', 'local')).strip().lower() + mode_changed = ( + normalize_rotation_mode(normalized_server.get('mode', current_config.get('mode')), new_source_mode) + != normalize_rotation_mode(current_config.get('mode'), old_source_mode) + ) + source_mode_changed = new_source_mode != old_source_mode + + updated_config = _persist_config(normalized_server, normalized_pool) except Exception as e: - logging.error(f"Error saving config: {e}") - return jsonify({ - 'status': 'error', - 'message': str(e) - }) + logging.error(f"保存配置失败: {e}") + return _config_error(str(e), 500) + + if web_port_changed: + logging.warning( + "web_port 已保存为 %s:面板端口需重启整个应用后生效,当前面板仍运行在旧端口", + updated_config.get('web_port'), + ) + + if 'log_level' in normalized_server: + applied_level = apply_log_level(normalized_server['log_level']) + logging.getLogger(__name__).info( + "日志级别已切换为 %s", logging.getLevelName(applied_level) + ) + + applied = { + 'proxy': _apply_proxy_config(updated_config, mode_changed, source_mode_changed), + 'pool': _apply_pool_config(updated_config, source_mode_changed), + } + + return jsonify({ + 'status': 'success', + 'port_changed': port_changed, + 'web_port_changed': web_port_changed, + 'service_status': 'running' if server.running else 'stopped', + 'applied': applied, + }) + + +def _persist_config(normalized_server, normalized_pool): + with _config_lock: + update_ini_keys(CONFIG_INI_PATH, 'Server', normalized_server) + + if normalized_pool: + pool_config_ini.sync_pool_section_text(CONFIG_INI_PATH, normalized_pool) + + sections = pool_config_ini.read_ini_sections(CONFIG_INI_PATH) + updated_config = dict(sections.get('Server', {})) + if 'Users' in sections: + updated_config['Users'] = dict(sections['Users']) + + _note_config_written() + reload_config_sections() + return updated_config + + +def reload_config_sections() -> None: + global _config_sections_mtime + _config_sections_mtime = 0.0 + config_sections() + + +_config_watch_mtime: float = 0.0 + + +def _note_config_written() -> None: + global _config_watch_mtime + try: + _config_watch_mtime = os.path.getmtime(CONFIG_INI_PATH) + except OSError: + _config_watch_mtime = 0.0 + + +def _hot_reload_once() -> bool: + global _config_watch_mtime + try: + mtime = os.path.getmtime(CONFIG_INI_PATH) + except OSError: + return False + if mtime == _config_watch_mtime: + return False + _config_watch_mtime = mtime + + try: + new_config = load_config(CONFIG_INI_PATH) + except Exception as e: + logging.warning(f"配置热重载:读取失败,沿用当前配置: {e}") + return False + + try: + new_source_mode = new_config.get('proxy_source_mode', 'local').lower() + server.apply_config_sync( + new_config, + mode_changed=( + server.mode + != normalize_rotation_mode(new_config.get('mode', 'cycle'), new_source_mode)), + source_mode_changed=(server.proxy_source_mode != new_source_mode), + reset_switch_timers=True, + timeout=10, + ) + logging.info(get_message('config_file_changed', server.language)) + except Exception as e: + logging.error(f"配置热重载:应用失败,沿用当前配置: {e}") + return False + return True + + +def watch_server_config(interval: float = 1.0) -> None: + _note_config_written() + list_snapshot = list_files_snapshot(server) + while True: + time.sleep(interval) + _hot_reload_once() + try: + list_snapshot = reload_ip_lists_if_changed(server, list_snapshot) + except Exception as e: + logging.warning(f"访问名单热重载失败(沿用当前名单): {e}") + + +def _apply_proxy_config(updated_config, mode_changed, source_mode_changed) -> bool: + try: + server.apply_config_sync( + updated_config, mode_changed, source_mode_changed, + reset_switch_timers=bool( + mode_changed and updated_config.get('mode') == 'loadbalance'), + timeout=10, + ) + return True + except Exception as e: + logging.error(f"代理服务应用新配置失败: {e}") + return False + + +def _apply_pool_config(updated_config=None, source_mode_changed: bool = False) -> bool: + try: + if pool_service.is_running: + pool_service.reload_config_from_file() + elif updated_config is not None and pool_config_ini.should_autostart_pool(updated_config): + if pool_service.start(): + reason_key = 'pool_autostart_source_switched' if source_mode_changed else 'pool_autostart_required' + logging.info(get_message(reason_key, server.language)) + else: + logging.error(get_message('pool_start_failed', server.language, pool_service.last_error)) + pool_service.note_config_saved() + return False + pool_service.note_config_saved() + return True + except Exception as e: + logging.error(f"代理池应用新配置失败: {e}") + return False + @app.route('/api/proxies', methods=['GET', 'POST']) +@require_token def handle_proxies(): if request.method == 'POST': try: proxies = request.json.get('proxies', []) + if not isinstance(proxies, list): + return jsonify({ + 'status': 'error', + 'message': get_message('proxies_must_be_list', server.language) + }) proxy_file = get_config_path(os.path.basename(server.proxy_file)) - with open(proxy_file, 'w', encoding='utf-8') as f: - f.write('\n'.join(proxies)) - server.proxies = server._load_file_proxies() - if server.proxies: - server.proxy_cycle = cycle(server.proxies) - server.current_proxy = next(server.proxy_cycle) + write_text_atomic(proxy_file, '\n'.join(str(line) for line in proxies)) + server.reload_local_proxies(run_check=False) return jsonify({ 'status': 'success', 'message': get_message('proxy_save_success', server.language) @@ -217,21 +457,29 @@ def handle_proxies(): else: try: proxy_file = get_config_path(os.path.basename(server.proxy_file)) - with open(proxy_file, 'r', encoding='utf-8') as f: - proxies = f.read().splitlines() + proxies = read_config_text(proxy_file).splitlines() return jsonify({'proxies': proxies}) - except Exception: - return jsonify({'proxies': []}) + except Exception as e: + logging.error(f"读取本地代理列表失败: {e}") + return jsonify({ + 'status': 'error', + 'message': get_message('load_proxy_file_error', server.language, str(e)) + }) @app.route('/api/check_proxies') +@require_token def check_proxies_api(): try: test_url = request.args.get('test_url', 'https://www.baidu.com') - valid_proxies = asyncio.run(check_proxies(server.proxies, test_url)) + candidates = server.source_proxies() + valid_proxies = _run_async_safe( + check_proxies, candidates, test_url, server.check_concurrency + ) total_valid = len(valid_proxies) return jsonify({ 'status': 'success', 'valid_proxies': valid_proxies, + 'checked': len(candidates), 'total': total_valid, 'message': get_message('proxy_check_result', server.language, total_valid) }) @@ -242,22 +490,27 @@ def check_proxies_api(): }) @app.route('/api/ip_lists', methods=['GET', 'POST']) +@require_token def handle_ip_lists(): if request.method == 'POST': try: - list_type = request.json.get('type') + list_type = request.json.get('type', '') + if list_type not in ('whitelist', 'blacklist'): + return jsonify({ + 'status': 'error', + 'message': get_message('ip_list_type_invalid', server.language), + }) ip_list = request.json.get('list', []) base_filename = os.path.basename(server.whitelist_file if list_type == 'whitelist' else server.blacklist_file) filename = get_config_path(base_filename) - - with open(filename, 'w', encoding='utf-8') as f: - f.write('\n'.join(ip_list)) - + + write_text_atomic(filename, '\n'.join(ip_list)) + if list_type == 'whitelist': server.whitelist = load_ip_list(filename) else: server.blacklist = load_ip_list(filename) - + return jsonify({ 'status': 'success', 'message': get_message('ip_list_save_success', server.language) @@ -271,76 +524,487 @@ def handle_ip_lists(): whitelist_file = get_config_path(os.path.basename(server.whitelist_file)) blacklist_file = get_config_path(os.path.basename(server.blacklist_file)) return jsonify({ - 'whitelist': list(load_ip_list(whitelist_file)), - 'blacklist': list(load_ip_list(blacklist_file)) + 'whitelist': list(load_ip_list(whitelist_file)), + 'blacklist': list(load_ip_list(blacklist_file)) }) +@app.route('/api/bypass_whitelist', methods=['GET', 'POST']) +@require_token +def handle_bypass_whitelist(): + if request.method == 'POST': + try: + bypass_list = request.json.get('list', []) + filename = os.path.join(BASE_DIR, 'config', os.path.basename(server.bypass_whitelist_file)) + write_text_atomic(filename, '\n'.join(bypass_list)) + server.bypass_whitelist = load_bypass_whitelist(filename) + return jsonify({ + 'status': 'success', + 'message': get_message('ip_list_save_success', server.language) + }) + except Exception as e: + return jsonify({ + 'status': 'error', + 'message': str(e) + }) + else: + filename = os.path.join(BASE_DIR, 'config', os.path.basename(server.bypass_whitelist_file)) + return jsonify({'list': list(load_bypass_whitelist(filename))}) + +def _requested_category(): + category = request.args.get('category', 'all') + if category == 'all' or category in ALL_CATEGORIES: + return category + return None + + @app.route('/api/logs') +@require_token def get_logs(): try: - start = int(request.args.get('start', 0)) - limit = int(request.args.get('limit', 100)) + category = _requested_category() + if category is None: + return jsonify({ + 'status': 'error', + 'message': get_message('log_category_unknown', server.language), + }) + + start = max(0, int(request.args.get('start', 0))) + limit = max(1, min(int(request.args.get('limit', 200)), 2000)) level = request.args.get('level', 'ALL') - search = request.args.get('search', '').lower() - - filtered_logs = log_messages - - if level != 'ALL': - filtered_logs = [log for log in log_messages if log['level'] == level] - - if search: - filtered_logs = [ - log for log in filtered_logs - if search in log['message'].lower() or - search in log['level'].lower() or - search in log['time'].lower() - ] - - return jsonify({ - 'logs': filtered_logs[start:start+limit], - 'total': len(filtered_logs), - 'status': 'success' - }) + search = request.args.get('search', '').strip() + + filtered = query_log_ring(category, level, search) + + end = len(filtered) - start + begin = max(0, end - limit) + page = filtered[begin:end] if end > 0 else [] + + return jsonify({'logs': page, 'total': len(filtered), 'status': 'success'}) except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/stats') +@require_token +def get_logs_stats(): + try: + return jsonify({'status': 'success', 'stats': log_ring_stats()}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/files') +@require_token +def get_log_files(): + try: + return jsonify({'status': 'success', 'files': iter_log_files(BASE_DIR)}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/file') +@require_token +def get_log_file(): + try: + filename = request.args.get('name', '') + lines = max(1, min(int(request.args.get('lines', 500)), 5000)) + entries, truncated = read_log_file(filename, BASE_DIR, lines) return jsonify({ - 'status': 'error', - 'message': str(e) + 'status': 'success', 'name': filename, + 'lines': entries, 'truncated': truncated, }) + except ValueError as e: + return jsonify({'status': 'error', 'message': _localized_error_text(e)}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/export') +@require_token +def export_logs(): + try: + filename = request.args.get('file', '') + if filename: + path = log_file_path(filename, BASE_DIR) + if path is None or not os.path.exists(path): + return jsonify({ + 'status': 'error', + 'message': get_message('log_file_missing', server.language), + }) + flush_log_queue() + return send_from_directory( + os.path.join(BASE_DIR, 'logs'), filename, + mimetype='text/plain', as_attachment=True, + ) + + category = _requested_category() + if category is None: + return jsonify({'status': 'error', + 'message': get_message('log_category_unknown', server.language)}) + logs = query_log_ring(category, request.args.get('level', 'ALL'), + request.args.get('search', '').strip()) + content = '\n'.join( + f"{entry['time']} - {entry['level']} - {entry['message']}" for entry in logs + ) + return Response(content, mimetype='text/plain', + headers={'Content-Disposition': 'attachment;filename=proxycat_export.log'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + @app.route('/api/logs/clear', methods=['POST']) +@require_token def clear_logs(): try: - global log_messages - log_messages = [] - - with open(log_file, 'w', encoding='utf-8') as f: - f.write('') - + payload = request.get_json(silent=True) or {} + category = payload.get('category', 'all') + if category != 'all' and category not in ALL_CATEGORIES: + return jsonify({'status': 'error', + 'message': get_message('log_category_unknown', server.language)}) + + clear_log_ring(category) + failed = [ + name for name in current_log_filenames(category) + if not truncate_log_file(name, BASE_DIR) + ] + if failed: + return jsonify({ + 'status': 'error', + 'message': get_message('clear_logs_failed', server.language, ', '.join(failed)), + }) + return jsonify({ 'status': 'success', - 'message': get_message('logs_cleared', server.language) + 'message': get_message('logs_cleared', server.language), }) except Exception as e: return jsonify({ 'status': 'error', - 'message': get_message('clear_logs_failed', server.language, str(e)) + 'message': get_message('clear_logs_failed', server.language, str(e)), }) + +def _upstream_label(key: str) -> str: + if key == UPSTREAM_DIRECT: + return get_message('access_direct', server.language) + if key == UPSTREAM_UNKNOWN: + return get_message('access_unknown', server.language) + return key + + +_RECORD_OUTCOMES = ('success', 'failure', 'aborted') + + +def _int_arg(name: str, default: int) -> int: + raw = (request.args.get(name) or '').strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + return default + +_RECORD_CSV_HEADER_KEYS = ( + 'csv_col_time', 'csv_col_kind', 'csv_col_method', 'csv_col_client_ip', + 'csv_col_client_user', 'csv_col_host', 'csv_col_port', 'csv_col_upstream', + 'csv_col_real_ip', 'csv_col_outcome', 'csv_col_status_code', 'csv_col_elapsed', + 'csv_col_reason', +) + + +def _normalize_record_time(raw: str | None, *, end: bool) -> str | None: + text = (raw or '').strip() + if not text: + return None + + normalized = text.replace('T', ' ') + for pattern in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'): + try: + moment = datetime.strptime(normalized, pattern) + except ValueError: + continue + if end and moment.second == 0 and pattern == '%Y-%m-%d %H:%M': + moment = moment.replace(second=59) + return moment.strftime(_RECORD_TIME_FORMAT) + + try: + day = datetime.strptime(normalized, '%Y-%m-%d') + except ValueError: + return None + if end: + day = day.replace(hour=23, minute=59, second=59) + return day.strftime(_RECORD_TIME_FORMAT) + + +_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_records_csv(rows: list[dict]) -> str: + buffer = io.StringIO() + writer = csv.writer(buffer) + _message = lambda key: get_message(key, server.language) + writer.writerow([_message(key) for key in _RECORD_CSV_HEADER_KEYS]) + + outcome_labels = { + 'success': get_message('access_outcome_success', server.language), + 'failure': get_message('access_outcome_failure', server.language), + 'aborted': get_message('access_outcome_aborted', server.language), + } + for row in rows: + writer.writerow([_csv_safe(cell) for cell in ( + row['ts'], + row['kind'], + row['method'], + row['client_ip'], + row['client_user'], + row['host'], + row['port'], + _upstream_label(row['upstream']), + row['real_ip'], + outcome_labels.get(row['outcome'], row['outcome']), + '' if row['status_code'] is None else row['status_code'], + row['elapsed_ms'], + row['reason'], + )]) + return buffer.getvalue() + + +@app.route('/api/logs/domains') +@require_token +def get_domain_stats(): + try: + sort = request.args.get('sort', 'last_seen') + order = request.args.get('order', 'desc') + search = request.args.get('search', '').strip() + limit = max(1, min(int(request.args.get('limit', 100)), 1000)) + offset = max(0, int(request.args.get('offset', 0))) + upstream = request.args.get('upstream', '') + + store = server.domain_stats + payload = { + 'status': 'success', + 'enabled': store.enabled, + 'summary': store.summary(), + } + + if upstream: + rows, total = store.query_domains(upstream, search, sort, order, limit, offset) + payload.update({ + 'mode': 'domains', + 'upstream': upstream, + 'upstream_label': _upstream_label(upstream), + 'domains': rows, + 'total': total, + }) + else: + rows, total = store.query_proxies(search, sort, order, limit, offset) + for row in rows: + row['upstream_label'] = _upstream_label(row['upstream']) + payload.update({'mode': 'proxies', 'proxies': rows, 'total': total}) + + return jsonify(payload) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/domains/clear', methods=['POST']) +@require_token +def clear_domain_stats(): + try: + removed = server.domain_stats.clear() + return jsonify({ + 'status': 'success', + 'removed': removed, + 'message': get_message('domain_stats_cleared', server.language, removed), + }) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/records') +@require_token +def get_access_records(): + store = server.access_records + try: + since = _normalize_record_time(request.args.get('since'), end=False) + until = _normalize_record_time(request.args.get('until'), end=True) + if since is None: + since = (datetime.now() - timedelta(hours=1)).strftime(_RECORD_TIME_FORMAT) + + outcome = request.args.get('outcome', '').strip() + if outcome and outcome not in _RECORD_OUTCOMES: + return jsonify({ + 'status': 'error', + 'message': get_message('access_records_bad_outcome', server.language, outcome), + }) + + flt = RecordFilter( + since=since, + until=until, + upstream=request.args.get('upstream', '').strip() or None, + host=request.args.get('host', '').strip() or None, + outcome=outcome or None, + search=request.args.get('search', '').strip(), + ) + limit = max(1, min(_int_arg('limit', 100), 500)) + offset = max(0, _int_arg('offset', 0)) + + rows, total, counts = store.query( + flt, limit=limit, offset=offset, order=request.args.get('order', 'desc') + ) + for row in rows: + row['upstream_label'] = _upstream_label(row['upstream']) + + return jsonify({ + 'status': 'success', + 'enabled': store.enabled, + 'range': {'since': since, 'until': until or ''}, + 'outcome_counts': counts, + 'total': total, + 'export_limit': access_records.EXPORT_LIMIT, + 'records': rows, + **store.stats(), + }) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/records/export') +@require_token +def export_access_records(): + store = server.access_records + try: + since = _normalize_record_time(request.args.get('since'), end=False) + until = _normalize_record_time(request.args.get('until'), end=True) + if since is None: + since = (datetime.now() - timedelta(hours=1)).strftime(_RECORD_TIME_FORMAT) + + outcome = request.args.get('outcome', '').strip() + if outcome and outcome not in _RECORD_OUTCOMES: + return jsonify({ + 'status': 'error', + 'message': get_message('access_records_bad_outcome', server.language, outcome), + }) + + export_format = (request.args.get('format') or 'csv').strip().lower() + if export_format not in ('csv', 'json'): + return jsonify({ + 'status': 'error', + 'message': get_message('access_records_bad_format', server.language, export_format), + }) + + flt = RecordFilter( + since=since, + until=until, + upstream=request.args.get('upstream', '').strip() or None, + host=request.args.get('host', '').strip() or None, + outcome=outcome or None, + search=request.args.get('search', '').strip(), + ) + rows = store.export_rows(flt) + stamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"access_records_{stamp}.{export_format}" + + if export_format == 'json': + body = json.dumps( + { + 'exported_at': datetime.now().strftime(_RECORD_TIME_FORMAT), + 'range': {'since': since, 'until': until or ''}, + 'upstream': flt.upstream or '', + 'host': flt.host or '', + 'outcome': flt.outcome or '', + 'count': len(rows), + 'truncated': len(rows) >= access_records.EXPORT_LIMIT, + 'records': rows, + }, + ensure_ascii=False, + indent=2, + ) + return Response( + body, + content_type='application/json; charset=utf-8', + headers={'Content-Disposition': f'attachment; filename={filename}'}, + ) + + return Response( + '' + _render_records_csv(rows), + content_type='text/csv; charset=utf-8', + headers={'Content-Disposition': f'attachment; filename={filename}'}, + ) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/records/options') +@require_token +def get_access_record_options(): + try: + options = server.access_records.distinct_options() + for item in options['upstreams']: + item['label'] = _upstream_label(item['value']) + return jsonify({'status': 'success', **options}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + +@app.route('/api/logs/records/clear', methods=['POST']) +@require_token +def clear_access_records(): + try: + removed = server.access_records.clear() + return jsonify({ + 'status': 'success', + 'removed': removed, + 'message': get_message('access_records_cleared', server.language, removed), + }) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}) + + @app.route('/api/switch_proxy') @require_token def switch_proxy(): try: - result = asyncio.run(server.switch_proxy()) - if result: + result = _run_async_safe(server.switch_proxy) + if result is True: return jsonify({ 'status': 'success', - 'current_proxy': server.current_proxy, + 'current_proxy': sanitize_proxy(server.current_proxy), + 'active_proxies': server.active_workers() if hasattr(server, 'active_workers') else [], + 'total_proxies': len(server.proxies) if hasattr(server, 'proxies') else 0, 'message': get_message('switch_success', server.language) }) - else: + elif isinstance(result, dict) and 'cooldown' in result: + remaining = result['cooldown'] return jsonify({ 'status': 'error', - 'message': get_message('switch_failed', server.language, 'Proxy switch not needed or in cooldown') + 'cooldown': True, + 'cooldown_remaining': remaining, + 'message': get_message('switch_cooldown_msg', server.language).replace('{}', str(remaining)) + }) + elif isinstance(result, dict) and 'switching' in result: + return jsonify({ + 'status': 'error', + 'switching': True, + 'message': get_message('switch_in_progress', server.language) + }) + else: + current = sanitize_proxy(server.current_proxy) or get_message('no_current_proxy', server.language) + return jsonify({ + 'status': 'error', + 'current_proxy': current, + 'message': get_message('switch_failed', server.language, current) }) except Exception as e: return jsonify({ @@ -348,105 +1012,140 @@ def switch_proxy(): 'message': get_message('switch_failed', server.language, str(e)) }) +def _close_server_instance(): + server.close_listener() + + +def _wait_proxy_thread_exit(timeout: float = 10.0) -> bool: + proxy_thread = getattr(server, 'proxy_thread', None) + if proxy_thread is None or not proxy_thread.is_alive(): + return True + proxy_thread.join(timeout=timeout) + return not proxy_thread.is_alive() + + +def _wait_server_running(timeout: float = 5.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if server.running: + return True + time.sleep(0.5) + return server.running + + +def _start_server_thread(): + def _runner(): + try: + if server.proxy_source_mode == 'local': + server.run_startup_check_blocking() + _run_async_safe(run_server, server) + except Exception as e: + logging.error(f"代理服务线程异常退出: {e}") + + server.stop_server = False + server.proxy_thread = threading.Thread(target=_runner, daemon=True) + server.proxy_thread.start() + + @app.route('/api/service', methods=['POST']) @require_token def control_service(): try: action = request.json.get('action') if action == 'start': - if not server.running: - server.stop_server = False - if hasattr(server, 'proxy_thread') and server.proxy_thread and server.proxy_thread.is_alive(): - server.proxy_thread.join(timeout=5) - server.proxy_thread = threading.Thread(target=lambda: asyncio.run(run_server(server)), daemon=True) - server.proxy_thread.start() - - for _ in range(10): - if server.running: - break - time.sleep(0.5) - - if server.running: - return jsonify({ - 'status': 'success', - 'message': get_message('service_start_success', server.language), - 'service_status': 'running' - }) - else: - return jsonify({ - 'status': 'error', - 'message': get_message('service_start_failed', server.language), - 'service_status': 'stopped' - }) - return jsonify({ - 'status': 'success', - 'message': get_message('service_already_running', server.language), - 'service_status': 'running' - }) - - elif action == 'stop': if server.running: - server.stop_server = True - server.running = False - - if server.server_instance: - server.server_instance.close() - - if hasattr(server, 'proxy_thread') and server.proxy_thread: - server.proxy_thread = None - - for _ in range(5): - if server.server_instance is None: - break - time.sleep(0.2) - return jsonify({ 'status': 'success', - 'message': get_message('service_stop_success', server.language), + 'message': get_message('service_already_running', server.language), + 'service_status': 'running' + }) + if port_in_use(server.port): + return jsonify({ + 'status': 'error', + 'message': get_message('port_in_use', server.language, server.port), + 'service_status': 'stopped' + }) + if not _wait_proxy_thread_exit(): + return jsonify({ + 'status': 'error', + 'message': get_message( + 'operation_failed', server.language, + get_message('service_thread_still_running', server.language), + ), + 'service_status': 'stopped' + }) + _start_server_thread() + + if _wait_server_running(): + return jsonify({ + 'status': 'success', + 'message': get_message('service_start_success', server.language), + 'service_status': 'running' + }) + reason = f":{server.last_start_error}" if server.last_start_error else "" + return jsonify({ + 'status': 'error', + 'message': get_message('service_start_failed', server.language) + reason, + 'service_status': 'stopped' + }) + + elif action == 'stop': + if not server.running: + return jsonify({ + 'status': 'success', + 'message': get_message('service_not_running', server.language), + 'service_status': 'stopped' + }) + server.stop_server = True + _close_server_instance() + + if not _wait_proxy_thread_exit(): + return jsonify({ + 'status': 'error', + 'message': get_message( + 'operation_failed', server.language, + get_message('service_stop_timeout', server.language), + ), 'service_status': 'stopped' }) return jsonify({ 'status': 'success', - 'message': get_message('service_not_running', server.language), + 'message': get_message('service_stop_success', server.language), 'service_status': 'stopped' }) - + elif action == 'restart': if server.running: server.stop_server = True - if server.server_instance: - server.server_instance.close() - - for _ in range(10): - if not server.running: - break - time.sleep(0.5) - - if server.running: - if hasattr(server, 'proxy_thread') and server.proxy_thread: - server.proxy_thread = None - server.running = False - - server.stop_server = False - server.proxy_thread = threading.Thread(target=lambda: asyncio.run(run_server(server)), daemon=True) - server.proxy_thread.start() - - for _ in range(10): - if server.running: - break - time.sleep(0.5) - - if server.running: + _close_server_instance() + + if not _wait_proxy_thread_exit(): + return jsonify({ + 'status': 'error', + 'message': get_message('service_thread_still_running', server.language), + 'service_status': 'stopped' + }) + + if port_in_use(server.port): + return jsonify({ + 'status': 'error', + 'message': get_message('port_in_use', server.language, server.port), + 'service_status': 'stopped' + }) + + _start_server_thread() + + if _wait_server_running(): return jsonify({ 'status': 'success', 'message': get_message('service_restart_success', server.language) }) - else: - return jsonify({ - 'status': 'error', - 'message': get_message('service_restart_failed', server.language) - }) - + return jsonify({ + 'status': 'error', + 'message': get_message('service_restart_failed', server.language, server.last_start_error or ''), + 'service_status': 'stopped' + }) + return jsonify({ 'status': 'error', 'message': get_message('invalid_action', server.language) @@ -458,6 +1157,7 @@ def control_service(): }) @app.route('/api/language', methods=['POST']) +@require_token def change_language(): try: new_language = request.json.get('language', 'cn') @@ -466,20 +1166,15 @@ def change_language(): 'status': 'error', 'message': get_message('unsupported_language', server.language) }) - - config = ConfigParser() - config.read('config/config.ini', encoding='utf-8') - - if 'Server' not in config: - config.add_section('Server') - - config.set('Server', 'language', new_language) - - with open('config/config.ini', 'w', encoding='utf-8') as f: - config.write(f) - + + with _config_lock: + update_ini_keys(CONFIG_INI_PATH, 'Server', {'language': new_language}) + + _note_config_written() + reload_config_sections() + server.language = new_language - + return jsonify({ 'status': 'success', 'language': new_language @@ -490,50 +1185,15 @@ def change_language(): 'message': get_message('operation_failed', server.language, str(e)) }) +version_checker = VersionChecker( + os.path.join(BASE_DIR, 'logs', 'version_check.json'), + language_provider=lambda: server.language, +) + + @app.route('/api/version') def check_version(): - try: - import re - import httpx - from packaging import version - import logging - - httpx_logger = logging.getLogger('httpx') - original_level = httpx_logger.level - httpx_logger.setLevel(logging.WARNING) - - CURRENT_VERSION = "ProxyCat-V2.0.4" - - try: - client = httpx.Client(transport=httpx.HTTPTransport(retries=3)) - response = client.get("https://y.shironekosan.cn/1.html", timeout=10) - response.raise_for_status() - content = response.text - - match = re.search(r'

(ProxyCat-V\d+\.\d+\.\d+)

', content) - if match: - latest_version = match.group(1) - is_latest = version.parse(latest_version.split('-V')[1]) <= version.parse(CURRENT_VERSION.split('-V')[1]) - - return jsonify({ - 'status': 'success', - 'is_latest': is_latest, - 'current_version': CURRENT_VERSION, - 'latest_version': latest_version - }) - else: - return jsonify({ - 'status': 'error', - 'message': get_message('version_info_not_found', server.language) - }) - finally: - httpx_logger.setLevel(original_level) - - except Exception as e: - return jsonify({ - 'status': 'error', - 'message': get_message('update_check_error', server.language, str(e)) - }) + return jsonify(version_checker.result_payload()) @app.route('/api/users', methods=['GET', 'POST']) @require_token @@ -541,36 +1201,15 @@ def handle_users(): if request.method == 'POST': try: users = request.json.get('users', {}) - config = ConfigParser() - config.read('config/config.ini', encoding='utf-8') - - sections_to_preserve = {} - for section in config.sections(): - if section != 'Users': - sections_to_preserve[section] = dict(config.items(section)) + with _config_lock: + write_ini_section(CONFIG_INI_PATH, 'Users', users) + + _note_config_written() + reload_config_sections() - config = ConfigParser() - - for section, options in sections_to_preserve.items(): - config.add_section(section) - for key, value in options.items(): - config.set(section, key, value) - - if users: - config.add_section('Users') - for username, password in users.items(): - config.set('Users', username, password) - - with open('config/config.ini', 'w', encoding='utf-8') as f: - config.write(f) - server.users = users server.auth_required = bool(users) - - if hasattr(server, 'proxy_server') and server.proxy_server: - server.proxy_server.users = users - server.proxy_server.auth_required = bool(users) - + return jsonify({ 'status': 'success', 'message': get_message('users_save_success', server.language) @@ -582,19 +1221,341 @@ def handle_users(): }) else: try: - config = ConfigParser() - config.read('config/config.ini', encoding='utf-8') + config = ini_parser() + config.read_string(read_config_text(CONFIG_INI_PATH)) users = {} if config.has_section('Users'): users = dict(config.items('Users')) - return jsonify({'users': users}) + return jsonify({'status': 'success', 'users': users}) except Exception as e: logging.error(f"Error getting users: {e}") - return jsonify({'users': {}}) + return jsonify({ + 'status': 'error', + 'users': None, + 'message': get_message('users_load_failed', server.language, str(e)) + }) + +@app.route('/api/api_credentials', methods=['GET', 'POST']) +@require_token +def handle_api_credentials(): + if request.method == 'POST': + try: + action = request.json.get('action', 'save') + config = ini_parser() + config.read_string(read_config_text(CONFIG_INI_PATH)) + + if not config.has_section('api_credentials'): + config.add_section('api_credentials') + + if action == 'save': + cred_name = request.json.get('name', '').strip() + if not cred_name: + return jsonify({ + 'status': 'error', + 'message': get_message('credential_name_required', server.language), + }) + + cred_url = request.json.get('url', '') + cred_user = request.json.get('username', '') + cred_pass = request.json.get('password', '') + + stored = config.get('api_credentials', 'credential_sets', fallback='[]') + try: + sets = json.loads(stored) + except Exception: + sets = [] + + existing = next((s for s in sets if s.get('name') == cred_name), None) + if existing: + existing['url'] = cred_url + existing['username'] = cred_user + existing['password'] = cred_pass + else: + sets.append({ + 'name': cred_name, + 'url': cred_url, + 'username': cred_user, + 'password': cred_pass + }) + + updates = {'credential_sets': json.dumps(sets, ensure_ascii=False)} + + active = config.get('api_credentials', 'active_credential', fallback='') + if not active or active == cred_name: + active = cred_name + updates['active_credential'] = active + + with _config_lock: + update_ini_keys(CONFIG_INI_PATH, 'api_credentials', updates) + + _note_config_written() + reload_config_sections() + + return jsonify({ + 'status': 'success', + 'message': get_message('credential_saved', server.language), + 'sets': sets, + 'active_credential': active + }) + + elif action == 'delete': + cred_name = request.json.get('name', '').strip() + if not cred_name: + return jsonify({'status': 'error', + 'message': get_message('credential_name_required', server.language)}) + + stored = config.get('api_credentials', 'credential_sets', fallback='[]') + try: + sets = json.loads(stored) + except Exception: + sets = [] + + sets = [s for s in sets if s.get('name') != cred_name] + updates = {'credential_sets': json.dumps(sets, ensure_ascii=False)} + + active = config.get('api_credentials', 'active_credential', fallback='') + server_updates = None + if active == cred_name: + active = sets[0]['name'] if sets else '' + updates['active_credential'] = active + replacement = next( + (s for s in sets if s.get('name') == active), None) or {} + server_updates = { + 'api_proxy_url': replacement.get('url', ''), + 'proxy_username': replacement.get('username', ''), + 'proxy_password': replacement.get('password', ''), + } + + with _config_lock: + update_ini_keys(CONFIG_INI_PATH, 'api_credentials', updates) + if server_updates: + update_ini_keys(CONFIG_INI_PATH, 'Server', server_updates) + + _note_config_written() + reload_config_sections() + + return jsonify({ + 'status': 'success', + 'message': get_message('credential_deleted', server.language), + 'sets': sets, + 'active_credential': active + }) + + elif action == 'set_active': + cred_name = request.json.get('name', '').strip() + if not cred_name: + return jsonify({'status': 'error', + 'message': get_message('credential_name_required', server.language)}) + cred_updates = {'active_credential': cred_name} + + stored = config.get('api_credentials', 'credential_sets', fallback='[]') + try: + sets = json.loads(stored) + except Exception: + sets = [] + + target = next((s for s in sets if s.get('name') == cred_name), None) + if target is None: + return jsonify({ + 'status': 'error', + 'message': get_message('credential_not_found', server.language, cred_name) + }) + + server_updates = { + 'api_proxy_url': target.get('url', ''), + 'proxy_username': target.get('username', ''), + 'proxy_password': target.get('password', ''), + } + + with _config_lock: + update_ini_keys(CONFIG_INI_PATH, 'api_credentials', cred_updates) + update_ini_keys(CONFIG_INI_PATH, 'Server', server_updates) + + _note_config_written() + reload_config_sections() + + return jsonify({ + 'status': 'success', + 'message': get_message('credential_switched', server.language), + 'active_credential': cred_name, + 'credential': target + }) + + else: + return jsonify({ + 'status': 'error', + 'message': get_message('unknown_action', server.language), + }) + + except Exception as e: + logging.error(f"Error handling API credentials: {e}") + return jsonify({'status': 'error', 'message': str(e)}) + + else: + try: + config = ini_parser() + config.read_string(read_config_text(CONFIG_INI_PATH)) + + stored = config.get('api_credentials', 'credential_sets', fallback='[]') + try: + sets = json.loads(stored) + except Exception: + sets = [] + + active = config.get('api_credentials', 'active_credential', fallback='') + + return jsonify({ + 'status': 'success', + 'sets': sets, + 'active_credential': active + }) + except Exception as e: + logging.error(f"Error getting API credentials: {e}") + return jsonify({'status': 'error', 'sets': [], 'active_credential': ''}) + +_AD_URL_SCHEMES = ('http://', 'https://') + + +def _safe_ad_url(value) -> str: + text = str(value or '').strip() + if not text: + return '' + lowered = text.lower() + if lowered.startswith(_AD_URL_SCHEMES) or text.startswith('//') or text.startswith('/'): + return text + logging.warning("广告地址协议不受支持,已忽略: %s", text[:80]) + return '' + + +@app.route('/api/ads') +def get_ads(): + try: + ads_dir = os.path.join(BASE_DIR, 'config', 'ads') + ads = [] + if not os.path.isdir(ads_dir): + return jsonify({'status': 'success', 'ads': [], 'total': 0, 'dismissed': _ad_dismissed}) + for filename in sorted(os.listdir(ads_dir)): + if not filename.endswith('.json'): + continue + filepath = os.path.join(ads_dir, filename) + try: + with open(filepath, 'r', encoding='utf-8') as f: + ad = json.load(f) + if not isinstance(ad, dict) or not ad.get('enabled', True): + continue + ads.append({ + 'id': ad.get('id', filename.replace('.json', '')), + 'title': ad.get('title', ''), + 'body': ad.get('body', ''), + 'image_url': _safe_ad_url(ad.get('image_url')), + 'link_url': _safe_ad_url(ad.get('link_url')), + 'link_text': ad.get('link_text', ''), + 'display_time': int(ad.get('display_time', 15)) + }) + except Exception as e: + logging.warning("跳过无法解析的广告文件 %s: %s", filename, e) + continue + return jsonify({'status': 'success', 'ads': ads, 'total': len(ads), 'dismissed': _ad_dismissed}) + except Exception as e: + logging.error(f"获取广告数据失败: {e}") + return jsonify({'status': 'error', 'message': str(e)}) + +@app.route('/api/ads/dismiss', methods=['POST']) +def dismiss_ads(): + global _ad_dismissed + _ad_dismissed = True + return jsonify({'status': 'success', 'dismissed': True}) + +@app.route('/api/ads/reopen', methods=['POST']) +def reopen_ads(): + global _ad_dismissed + _ad_dismissed = False + return jsonify({'status': 'success', 'dismissed': False}) @app.route('/static/') def send_static(path): - return send_from_directory('web/static', path) + return send_from_directory(os.path.join(BASE_DIR, 'web', 'static'), path) + + +@app.after_request +def _revalidate_static(response): + if request.path.startswith('/static/'): + if request.args.get('v'): + response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' + else: + response.headers['Cache-Control'] = 'no-cache' + return response + +def _run_async_safe(async_func, *args): + return server.run_coroutine_sync(lambda: async_func(*args), timeout=30) + + +def _bound_server_port(): + listen_sock = getattr(server, '_listen_sock', None) + try: + if listen_sock is not None: + return listen_sock.getsockname()[1] + except Exception: + pass + return None + +def shutdown_pool_service(): + try: + if not pool_service.has_live_thread: + return + if not pool_service.stop(): + logging.warning("代理池未能在超时时间内停止") + except Exception as e: + logging.error(f"停止代理池失败: {e}") + + +atexit.register(shutdown_pool_service) + + +def shutdown_access_stores(): + for name, store in (('域名统计', getattr(server, 'domain_stats', None)), + ('访问记录', getattr(server, 'access_records', None))): + if store is None: + continue + try: + store.stop() + except Exception as e: + logging.error(f"停止{name}失败: {e}") + + +atexit.register(shutdown_access_stores) + +atexit.register(version_checker.stop) + + +def _signal_handler(signum, frame): + logging.info("收到终止信号,正在清理...") + + version_checker.stop() + shutdown_pool_service() + shutdown_access_stores() + + shutdown_logging(timeout=2.0) + + for handler in logging.getLogger().handlers: + try: + handler.flush() + except Exception: + pass + + def _force_exit(): + time.sleep(3) + os._exit(1) + + threading.Thread(target=_force_exit, daemon=True).start() + + sys.exit(0) + +signal.signal(signal.SIGINT, _signal_handler) +if os.name != 'nt': + signal.signal(signal.SIGTERM, _signal_handler) +if hasattr(signal, 'SIGBREAK'): + signal.signal(signal.SIGBREAK, _signal_handler) def run_proxy_server(): try: @@ -605,16 +1566,46 @@ def run_proxy_server(): logging.error(f"Proxy server error: {e}") if __name__ == '__main__': - setup_logging() - web_port = int(config.get('web_port', '5000')) + enable_safe_console_output() + + setup_logging(config, BASE_DIR) + web_port = int(config.get('web_port', '5001')) + + if port_in_use(web_port): + logging.error(get_message('web_port_in_use', server.language, web_port)) + sys.exit(1) + + proxy_port = int(config.get('port', '1080')) + proxy_port_occupied = port_in_use(proxy_port) + if proxy_port_occupied: + logging.error(get_message('port_in_use', server.language, proxy_port)) + logging.error("代理服务跳过启动:修改配置中的 port 后可通过面板重新启动") + + version_checker.start() + + 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)) + web_url = f"http://127.0.0.1:{web_port}" if config.get('token'): web_url += f"/web?token={config.get('token')}" - logging.info(get_message('web_panel_url', server.language, web_url)) logging.info(get_message('web_panel_notice', server.language)) - proxy_thread = threading.Thread(target=run_proxy_server, daemon=True) - proxy_thread.start() - - app.run(host='0.0.0.0', port=web_port) \ No newline at end of file + if not proxy_port_occupied: + server.proxy_thread = threading.Thread(target=run_proxy_server, daemon=True) + server.proxy_thread.start() + + threading.Thread(target=watch_server_config, daemon=True).start() + + from waitress import serve + try: + serve(app, host='0.0.0.0', port=web_port, threads=16) + finally: + shutdown_pool_service() \ No newline at end of file diff --git a/assets/screenshots/access-dark.png b/assets/screenshots/access-dark.png new file mode 100644 index 0000000..05cdbf7 Binary files /dev/null and b/assets/screenshots/access-dark.png differ diff --git a/assets/screenshots/access-en.png b/assets/screenshots/access-en.png new file mode 100644 index 0000000..eeddde7 Binary files /dev/null and b/assets/screenshots/access-en.png differ diff --git a/assets/screenshots/access.png b/assets/screenshots/access.png new file mode 100644 index 0000000..8202fbe Binary files /dev/null and b/assets/screenshots/access.png differ diff --git a/assets/screenshots/config-advanced-en.png b/assets/screenshots/config-advanced-en.png new file mode 100644 index 0000000..eddc191 Binary files /dev/null and b/assets/screenshots/config-advanced-en.png differ diff --git a/assets/screenshots/config-advanced.png b/assets/screenshots/config-advanced.png new file mode 100644 index 0000000..f95c4f4 Binary files /dev/null and b/assets/screenshots/config-advanced.png differ diff --git a/assets/screenshots/config-check-en.png b/assets/screenshots/config-check-en.png new file mode 100644 index 0000000..95e58d2 Binary files /dev/null and b/assets/screenshots/config-check-en.png differ diff --git a/assets/screenshots/config-check.png b/assets/screenshots/config-check.png new file mode 100644 index 0000000..db1aa64 Binary files /dev/null and b/assets/screenshots/config-check.png differ diff --git a/assets/screenshots/config-exits-dark.png b/assets/screenshots/config-exits-dark.png new file mode 100644 index 0000000..aa62e38 Binary files /dev/null and b/assets/screenshots/config-exits-dark.png differ diff --git a/assets/screenshots/config-exits-en.png b/assets/screenshots/config-exits-en.png new file mode 100644 index 0000000..0f7c80a Binary files /dev/null and b/assets/screenshots/config-exits-en.png differ diff --git a/assets/screenshots/config-exits.png b/assets/screenshots/config-exits.png new file mode 100644 index 0000000..b6d432b Binary files /dev/null and b/assets/screenshots/config-exits.png differ diff --git a/assets/screenshots/config-listen-en.png b/assets/screenshots/config-listen-en.png new file mode 100644 index 0000000..bac0605 Binary files /dev/null and b/assets/screenshots/config-listen-en.png differ diff --git a/assets/screenshots/config-listen.png b/assets/screenshots/config-listen.png new file mode 100644 index 0000000..66ef0f9 Binary files /dev/null and b/assets/screenshots/config-listen.png differ diff --git a/assets/screenshots/config-logs-en.png b/assets/screenshots/config-logs-en.png new file mode 100644 index 0000000..463997f Binary files /dev/null and b/assets/screenshots/config-logs-en.png differ diff --git a/assets/screenshots/config-logs.png b/assets/screenshots/config-logs.png new file mode 100644 index 0000000..ee6b965 Binary files /dev/null and b/assets/screenshots/config-logs.png differ diff --git a/assets/screenshots/config-perf-dark.png b/assets/screenshots/config-perf-dark.png new file mode 100644 index 0000000..7881786 Binary files /dev/null and b/assets/screenshots/config-perf-dark.png differ diff --git a/assets/screenshots/config-perf-en.png b/assets/screenshots/config-perf-en.png new file mode 100644 index 0000000..14777a5 Binary files /dev/null and b/assets/screenshots/config-perf-en.png differ diff --git a/assets/screenshots/config-perf.png b/assets/screenshots/config-perf.png new file mode 100644 index 0000000..ce0de34 Binary files /dev/null and b/assets/screenshots/config-perf.png differ diff --git a/assets/screenshots/config-source-dark.png b/assets/screenshots/config-source-dark.png new file mode 100644 index 0000000..3476c95 Binary files /dev/null and b/assets/screenshots/config-source-dark.png differ diff --git a/assets/screenshots/config-source-en.png b/assets/screenshots/config-source-en.png new file mode 100644 index 0000000..ccadef9 Binary files /dev/null and b/assets/screenshots/config-source-en.png differ diff --git a/assets/screenshots/config-source.png b/assets/screenshots/config-source.png new file mode 100644 index 0000000..c813ae4 Binary files /dev/null and b/assets/screenshots/config-source.png differ diff --git a/assets/screenshots/logs-dark.png b/assets/screenshots/logs-dark.png new file mode 100644 index 0000000..a4f3519 Binary files /dev/null and b/assets/screenshots/logs-dark.png differ diff --git a/assets/screenshots/logs-en.png b/assets/screenshots/logs-en.png new file mode 100644 index 0000000..7af0484 Binary files /dev/null and b/assets/screenshots/logs-en.png differ diff --git a/assets/screenshots/logs.png b/assets/screenshots/logs.png new file mode 100644 index 0000000..610de72 Binary files /dev/null and b/assets/screenshots/logs.png differ diff --git a/assets/screenshots/pool-db-en.png b/assets/screenshots/pool-db-en.png new file mode 100644 index 0000000..eb5ec97 Binary files /dev/null and b/assets/screenshots/pool-db-en.png differ diff --git a/assets/screenshots/pool-db.png b/assets/screenshots/pool-db.png new file mode 100644 index 0000000..7780cfd Binary files /dev/null and b/assets/screenshots/pool-db.png differ diff --git a/assets/screenshots/pool-manage-dark.png b/assets/screenshots/pool-manage-dark.png new file mode 100644 index 0000000..a0ba7ae Binary files /dev/null and b/assets/screenshots/pool-manage-dark.png differ diff --git a/assets/screenshots/pool-manage-en.png b/assets/screenshots/pool-manage-en.png new file mode 100644 index 0000000..a908400 Binary files /dev/null and b/assets/screenshots/pool-manage-en.png differ diff --git a/assets/screenshots/pool-manage.png b/assets/screenshots/pool-manage.png new file mode 100644 index 0000000..fcdf734 Binary files /dev/null and b/assets/screenshots/pool-manage.png differ diff --git a/assets/screenshots/pool-settings-en.png b/assets/screenshots/pool-settings-en.png new file mode 100644 index 0000000..47140d5 Binary files /dev/null and b/assets/screenshots/pool-settings-en.png differ diff --git a/assets/screenshots/pool-settings.png b/assets/screenshots/pool-settings.png new file mode 100644 index 0000000..ac89328 Binary files /dev/null and b/assets/screenshots/pool-settings.png differ diff --git a/assets/主界面图.png b/assets/主界面图.png deleted file mode 100644 index bc388be..0000000 Binary files a/assets/主界面图.png and /dev/null differ diff --git a/config/ads/example copy.json b/config/ads/example copy.json new file mode 100644 index 0000000..97c411a --- /dev/null +++ b/config/ads/example copy.json @@ -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 +} \ No newline at end of file diff --git a/config/ads/example.json b/config/ads/example.json new file mode 100644 index 0000000..32d3b30 --- /dev/null +++ b/config/ads/example.json @@ -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 +} \ No newline at end of file diff --git a/config/bypass_whitelist.txt b/config/bypass_whitelist.txt new file mode 100644 index 0000000..e69de29 diff --git a/config/config.ini b/config/config.ini index b27b697..5d0f650 100644 --- a/config/config.ini +++ b/config/config.ini @@ -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 diff --git a/config/getip.py b/config/getip.py index 3099f4a..d0dde28 100644 --- a/config/getip.py +++ b/config/getip.py @@ -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] diff --git a/config/ip.txt b/config/ip.txt index c56031e..e59249d 100644 --- a/config/ip.txt +++ b/config/ip.txt @@ -1,2 +1 @@ -http://127.0.0.1:7890 -socks5://127.0.0.1:7890 \ No newline at end of file +http://127.0.0.1:7897 \ No newline at end of file diff --git a/config/proxies.txt b/config/proxies.txt new file mode 100644 index 0000000..8193603 --- /dev/null +++ b/config/proxies.txt @@ -0,0 +1 @@ +http://1.2.3.4:8080 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 25e20b2..284b744 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" \ No newline at end of file + # 应用收尾时会排空池的写队列与两个落库线程:池 stop 默认预算 35 秒(见 + # modules/proxypool_service.py 的 stop),加上两次落库与 app.py 的 3 秒硬超时, + # 最坏约 44 秒。降到 Docker 默认的 10 秒宽限期会被 SIGKILL,最后一批验证结果 + # 与访问记录随之丢失。 + stop_grace_period: 60s diff --git a/logs/access.log b/logs/access.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/error.log b/logs/error.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/main.log b/logs/main.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/proxy.log b/logs/proxy.log new file mode 100644 index 0000000..e69de29 diff --git a/logs/version_check.json b/logs/version_check.json new file mode 100644 index 0000000..556e6a2 --- /dev/null +++ b/logs/version_check.json @@ -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": [] +} diff --git a/modules/access_log.py b/modules/access_log.py new file mode 100644 index 0000000..215ced1 --- /dev/null +++ b/modules/access_log.py @@ -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 diff --git a/modules/access_records.py b/modules/access_records.py new file mode 100644 index 0000000..f20e4ea --- /dev/null +++ b/modules/access_records.py @@ -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", +] diff --git a/modules/config_validation.py b/modules/config_validation.py new file mode 100644 index 0000000..c3ee3ba --- /dev/null +++ b/modules/config_validation.py @@ -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", +] diff --git a/modules/domain_stats.py b/modules/domain_stats.py new file mode 100644 index 0000000..acee4a1 --- /dev/null +++ b/modules/domain_stats.py @@ -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) diff --git a/modules/logging_setup.py b/modules/logging_setup.py new file mode 100644 index 0000000..540a3f4 --- /dev/null +++ b/modules/logging_setup.py @@ -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 diff --git a/modules/loop_noise_filter.py b/modules/loop_noise_filter.py new file mode 100644 index 0000000..d340cc3 --- /dev/null +++ b/modules/loop_noise_filter.py @@ -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 diff --git a/modules/modules.py b/modules/modules.py index 124646f..673b6fd 100644 --- a/modules/modules.py +++ b/modules/modules.py @@ -1,9 +1,34 @@ -import asyncio, logging, random, httpx, re, os, time +""" +模块名称:modules.modules +功能描述:宿主侧公共基础层,集中提供跨模块复用的基础能力:配置读写与键迁移、国际化文案、 + 启动与运行期终端展示、代理可用性检测,以及 IP 名单与绕过名单的加载、匹配和变更监视。 +职责边界:负责:配置读写与迁移、国际化文案、终端展示、代理检测、IP 名单和变更监视等公共能力; + 不负责:出口转发与轮换调度(modules.proxyserver)、代理池内部业务(modules.proxypool_service + 与 modules/proxypool/)、日志装配(modules.logging_setup)、Web 路由(app 与 modules.proxypool_api)。 +关键依赖:标准库 configparser / socket / fnmatch / threading / asyncio / locale、colorama(终端着色)、 + httpx(HTTP 代理检测)。 +已知限制: +1. ini 写助手整文件读入、改写后原子替换,不做并发合并,两个写者同时保存时后写者胜。 +2. 临时文件由 mkstemp 创建(固定 0600),换入前用 _copy_file_mode 复制原文件权限位,源文件不存在时保持默认。 +3. read_config_text 先按 utf-8-sig 再按系统本地代码页解码,两级都失败才抛 UnicodeDecodeError,命中本地代码页回退会 warning。 +4. 代理检测结果缓存是进程内的,键为 (代理 URL, 目标地址),TTL 10 秒、上限约 1000 条;检测抛意外异常时不写缓存,本次仍返回 False。 +5. load_config 出错只记日志并回退默认配置副本;IP 名单读取失败返回空集合,均不向上抛出。 +6. 文案表 cn / en 的键必须一一对应,缺键或语言未知时按「目标语言 → 中文 → 键名」回退,键名会被原样显示。 +7. HTTP 出口探测客户端固定 trust_env=False(与转发侧客户端同口径),不继承系统代理环境变量;否则探测会绕经本机系统代理,测得的不是该出口的真实连通性。 +8. SOCKS5 应答长度随 atyp 变(IPv4 10、IPv6 22、域名 7+len 字节),解析须按 atyp 变长读取,固定长度会把域名与 IPv6 代理误判为坏。 +""" + +import asyncio, logging, httpx, os, re, sys, time, fnmatch, threading, socket, locale from configparser import ConfigParser -from packaging import version +from typing import Any from colorama import Fore, Style +CURRENT_VERSION = "ProxyCat-V3.0.0" + +logger = logging.getLogger(__name__) + class ColoredFormatter(logging.Formatter): + COLORS = { logging.INFO: Fore.GREEN, logging.WARNING: Fore.YELLOW, @@ -13,359 +38,728 @@ class ColoredFormatter(logging.Formatter): def format(self, record): log_color = self.COLORS.get(record.levelno, Fore.WHITE) - record.msg = f"{log_color}{record.msg}{Style.RESET_ALL}" - return super().format(record) + colored = logging.makeLogRecord(record.__dict__) + colored.msg = f"{log_color}{record.getMessage()}{Style.RESET_ALL}" + colored.args = None + return super().format(colored) MESSAGES = { 'cn': { - 'getting_new_proxy': '正在获取新的代理IP', - 'new_proxy_is': '新的代理IP为: {}', - 'proxy_check_start': '开始检测代理地址...', 'proxy_check_disabled': '代理检测已禁用', - 'valid_proxies': '有效代理地址: {}', + 'valid_proxies': '检测完成,有效出口: {} 个', 'no_valid_proxies': '没有有效的代理地址', - 'proxy_check_failed': '{}代理 {} 检测失败: {}', - 'proxy_switch': '切换代理: {} -> {}', - 'proxy_consecutive_fails': '代理 {} 连续失败 {} 次,正在切换新代理', - 'proxy_invalid': '代理 {} 无效,立即切换代理', - 'proxy_failure': '代理检测失败: {}', - 'proxy_failure_threshold': '代理无效,开始切换', + 'proxy_check_failed': '代理 {} 检测失败: {}', + 'proxy_switch': '出口批次已更换: {} 个(原 {} 个)', + 'proxy_invalid': '代理 {} 不可达,已从本批出口中剔除', 'proxy_check_error': '代理检查时发生错误: {}', - 'connection_timeout': '连接超时', - 'data_transfer_timeout': '数据传输超时,正在重试...', - 'connection_reset': '连接被重置', - 'transfer_cancelled': '传输被取消', - 'data_transfer_error': '数据传输错误: {}', - 'unsupported_protocol': '不支持的协议请求: {}', - 'client_error': '客户端处理出错: {}', - 'response_write_error': '响应写入错误: {}', 'server_closing': '服务器正在关闭...', - 'program_interrupted': '程序被用户中断', - 'multiple_proxy_fail': '多次尝试获取有效代理失败,退出程序', - 'current_proxy': '当前代理', - 'next_switch': '下次切换', + 'current_proxy': '最近分配出口', + 'next_switch': '最近到期', 'seconds': '秒', - 'no_proxies_available': '没有可用的代理', 'proxy_file_not_found': '代理文件不存在: {}', 'auth_not_set': '未设置 (无需认证)', 'public_account': '公众号', 'blog': '博客', - 'proxy_mode': '代理轮换模式', - 'cycle': '循环', + 'proxy_mode': '运行模式', + 'cycle': '按顺序使用', 'loadbalance': '负载均衡', + 'continuous': '持续更换', + 'request': '触发更换', 'single_round': '单轮', - 'proxy_interval': '代理更换时间', + 'proxy_interval': '出口刷新间隔', 'default_auth': '默认账号密码', 'local_http': '本地监听地址 (HTTP)', 'local_socks5': '本地监听地址 (SOCKS5)', 'star_project': '开源项目求 Star', 'client_handle_error': '客户端处理错误: {}', - 'proxy_invalid_switch': '代理无效,切换代理', - 'request_fail_retry': '请求失败,重试剩余次数: {}', 'user_interrupt': '用户中断程序', - 'new_version_found': '发现新版本!', - 'visit_quark': '夸克网盘: https://pan.quark.cn/s/39b4b5674570', - 'visit_github': 'GitHub: https://github.com/honmashironeko/ProxyCat', - 'visit_baidu': '百度网盘: https://pan.baidu.com/s/1C9LVC9aiaQeYFSj_2mWH1w?pwd=13r5', 'latest_version': '当前已是最新版本', + 'new_version_available': '发现新版本:{}(当前 {})', + 'version_not_checked': '尚未完成版本检查', 'version_info_not_found': '未找到版本信息', 'update_check_error': '检查更新失败: {}', 'unauthorized_ip': '未授权的IP尝试访问: {}', - 'client_cancelled': '客户端连接已取消', 'socks5_connection_error': 'SOCKS5连接错误: {}', 'connect_timeout': '连接超时', - 'connection_reset': '连接被重置', - 'transfer_cancelled': '传输已取消', 'client_request_error': '客户端请求错误: {}', - 'unsupported_protocol': '不支持的协议: {}', 'request_retry': '请求失败,重试中 (剩余{}次)', - 'response_write_error': '写入响应时出错: {}', - 'consecutive_failures': '检测到连续代理失败: {}', - 'invalid_proxy': '当前代理无效: {}', + 'log_suppressed': '(窗口内另有 {} 条同类消息已折叠)', 'whitelist_error': '添加白名单失败: {}', - 'api_mode_notice': '当前为API模式,收到请求将自动获取代理地址', + 'api_mode_notice': '当前为API模式,收到请求将自动批量获取上游出口', 'server_running': '代理服务器运行在 {}:{}', 'server_start_error': '服务器启动错误: {}', 'server_shutting_down': '正在关闭服务器...', - 'client_process_error': '处理客户端请求时出错: {}', - 'request_handling_error': '请求处理错误: {}', - 'proxy_forward_error': '代理转发错误: {}', - 'data_transfer_timeout': '{}数据传输超时', - 'data_transfer_error': '{}数据传输错误: {}', 'status_update_error': '状态更新出错', 'display_level_notice': '当前显示级别: {}', 'display_level_desc': '''显示级别说明: -0: 仅显示代理切换和错误信息 -1: 显示代理切换、倒计时和错误信息 +0: 仅显示出口名册变化和错误信息 +1: 显示出口名册、各出口状态、倒计时和错误信息 2: 显示所有详细信息''', 'new_client_connect': '新客户端连接 - IP: {}, 用户: {}', - 'no_auth': '无认证', - 'connection_error': '连接处理错误: {}', - 'cleanup_error': '清理IP错误: {}', 'port_changed': '端口已更改: {} -> {},需要重启服务器生效', - 'config_updated': '服务器配置已更新', 'load_proxy_file_error': '加载代理文件失败: {}', 'proxy_check_result': '代理检查完成,有效代理:{}个', 'no_proxy': '无代理', - 'cycle_mode': '循环模式', - 'loadbalance_mode': '负载均衡模式', 'proxy_check_start': '开始检查代理...', - 'proxy_check_complete': '代理检查完成', 'proxy_save_success': '代理保存成功', 'proxy_save_failed': '代理保存失败: {}', 'ip_list_save_success': 'IP名单保存成功', 'ip_list_save_failed': 'IP名单保存失败: {}', - 'switch_success': '代理切换成功', - 'switch_failed': '代理切换失败: {}', + 'switch_success': '出口批次已刷新', + 'switch_failed': '刷新出口批次失败: {}', + 'switch_cooldown_msg': '刷新冷却中,请等待 {} 秒后再试', + 'switch_in_progress': '出口批次刷新正在进行中,请稍后再试', 'service_start_success': '服务启动成功', - 'service_start_failed': '服务启动失败', 'service_already_running': '服务已在运行', 'service_stop_success': '服务停止成功', 'service_not_running': '服务未在运行', 'service_restart_success': '服务重启成功', - 'service_restart_failed': '服务重启失败', 'invalid_action': '无效的操作', 'operation_failed': '操作失败: {}', 'logs_cleared': '日志已清除', 'clear_logs_failed': '清除日志失败: {}', 'unsupported_language': '不支持的语言', - 'language_changed': '语言已切换为{}', - 'loading': '加载中...', - 'get_proxy_failed': '获取新代理失败: {}', - 'log_level_all': '全部', - 'log_level_info': '信息', - 'log_level_warning': '警告', - 'log_level_error': '错误', - 'log_level_critical': '严重错误', - 'confirm_clear_logs': '确定要清除所有日志吗?此操作不可恢复。', - 'language_label': '语言', - 'chinese': '中文', - 'english': 'English', - 'manual_switch_btn': '手动切换', - 'service_control_title': '服务控制', - 'language_switch_success': '', - 'language_switch_failed': '', - 'refresh_failed': '刷新数据失败: {}', - 'auth_username_label': '认证用户名', - 'auth_password_label': '认证密码', - 'proxy_auth_username_label': '代理认证用户名', - 'proxy_auth_password_label': '代理认证密码', - 'progress_bar_label': '切换进度', - 'proxy_settings_title': '代理设置', - 'config_save_success': '配置保存成功', - 'config_save_failed': '配置保存失败:{}', - 'config_restart_required': '配置已更改,需要重启服务器生效', - 'confirm_restart_service': '是否立即重启服务器?', - 'service_status': '服务状态', - 'running': '运行中', - 'stopped': '已停止', - 'restarting': '重启中', - 'unknown': '未知', - 'service_start_failed': '服务启动失败:{}', - 'service_stop_failed': '服务停止失败:{}', + 'service_start_failed': '服务启动失败', 'service_restart_failed': '服务重启失败:{}', 'invalid_token': '无效的访问令牌', 'config_file_changed': '检测到配置文件更改,正在重新加载...', 'proxy_file_changed': '代理文件已更改,正在重新加载...', - 'test_target_label': '测试目标地址', - 'invalid_test_target': '无效的测试目标地址', + 'ip_lists_reloaded': '访问名单已重新加载:白名单 {} 条、黑名单 {} 条、绕过名单 {} 条', 'users_save_success': '用户保存成功', 'users_save_failed': '用户保存失败:{}', - 'user_management_title': '用户管理', - 'username_column': '用户名', - 'password_column': '密码', - 'actions_column': '操作', - 'add_user_btn': '添加用户', - 'enter_username': '请输入用户名', - 'enter_password': '请输入密码', - 'confirm_delete_user': '确定要删除该用户吗?', - 'no_logs_found': '未找到匹配的日志', - 'clear_search': '清除搜索', + 'users_load_failed': '用户列表读取失败:{}', 'web_panel_url': '网页控制面板地址: {}', 'web_panel_notice': '请使用浏览器访问上述地址来管理代理服务器', - 'api_proxy_settings_title': 'API代理设置', 'all_retries_failed': '所有重试均已失败,最后错误: {}', 'proxy_get_failed': '获取代理失败', 'proxy_get_error': '获取代理错误: {}', - 'request_error': '请求错误: {}', - 'proxy_switch_error': '代理切换错误: {}', + 'proxy_config_error': '代理配置有误: {}', + 'proxy_source_error_request': '取代理接口请求失败(网络不通、接口地址不可达或对方拒绝)', + 'proxy_source_error_config': '取代理接口的配置有误(接口地址缺失,或接口返回了错误码)', + 'proxy_source_error_unknown': '取代理接口失败', + 'proxy_switch_error': '刷新出口批次出错: {}', + 'proxy_allocate_error': '获取上游出口出错: {}', + 'proxy_get_timeout': '获取上游出口超时,等待下次补货', + 'pool_mode_notice': '当前为维护池模式,收到请求将自动批量获取上游出口', + 'pool_no_proxy': '维护池中无可用代理', + 'pool_unavailable': '维护池接口不可用: {}', + 'pool_fetch_failed': '从维护池获取代理失败: {}', + 'proxy_workers_ready': '已就绪 {} 个上游出口', + 'elastic_expanded': '出口池已扩容至 {} 个出口(高峰后自动收回)', + 'elastic_capacity_boosted': '来源暂时拿不出新出口,单出口并发额度已临时放宽至 {} 倍', + 'elastic_released': '出口池已停止扩容,多出的出口将在寿命到期后逐个撤下(当前 {} 个出口)', + 'exit_pool_drained': '没有任务在跑,已到寿命的出口直接移除(当前 {} 个出口,下次请求会重新取货)', + 'exit_pool_trimmed': '出口池已按容量收回多余的出口,当前 {} 个出口', + 'proxy_exit_added': '新增出口 {}', + 'proxy_exit_rotated': '轮换出口 {} -> {}', + 'proxy_worker_retired': '上游出口 {} 近期失败占比过高,已停用并等待补货', + 'proxy_batch_fetch_failed': '批量获取代理失败: {}', + 'proxy_batch_partial': '批量获取到 {} 个代理,期望 {} 个', + 'exits_summary': '上游出口: {}/{}', + 'exits_summary_expanded': '上游出口: {}/{}(高峰期扩容中)', + 'exit_line': ' {} 在用 {} 剩余 {}', + 'exit_load_no_cap': '{}(不限)', + 'exit_left_requests': '{} 次', + 'exit_left_none': '不换', + 'pool_start_success': '维护池启动成功', + 'pool_start_failed': '维护池启动失败: {}', + 'pool_stop_success': '维护池停止成功', + 'pool_stop_failed': '维护池停止失败: {}', + 'pool_restart_success': '维护池重启成功', + 'pool_restart_failed': '维护池重启失败: {}', + 'pool_status_error': '获取维护池状态失败: {}', + 'pool_starting': '正在启动代理池...', + 'pool_stopping': '正在停止代理池...', + 'pool_unknown_error': '未知原因', + 'pool_start_timeout': '维护池初始化超时({} 秒)', + 'pool_stop_timeout': '维护池未能在 {} 秒内停止', + 'pool_previous_stop_incomplete': ( + '上一次停止未完成,旧线程仍在运行;为避免两个池实例共用同一数据库,' + '本次启动已拒绝。请重启进程。' + ), + 'pool_loop_crashed': '维护池事件循环异常退出: {}', + 'pool_config_unreadable': '配置文件暂时读不出来,保持当前池配置,下一轮重试', + 'pool_invalid_parameters': '维护池请求参数错误: {}', + 'pool_request_failed': '维护池请求处理失败: {}', + 'pool_internal_error': '维护池内部错误', + 'pool_restore_requires_stop': '恢复备份前请先停止代理池(正在关闭中也需等它完全退出)', + 'pool_backup_filename_required': '缺少备份文件名', + 'pool_db_backup_disabled': '数据库备份功能未启用', + 'pool_db_maintenance_disabled': '数据库维护功能不可用', + 'pool_db_optimize_done': '数据库优化完成', + 'pool_db_optimize_failed': '数据库优化失败,请查看日志', + 'pool_backup_restore_success': '备份恢复成功,请重启代理池以加载恢复后的数据', + 'pool_backup_restore_bad_name': '备份恢复失败:文件不存在或名称非法', + 'pool_backup_restore_invalid_backup': '备份恢复失败:该文件不是有效的 SQLite 数据库', + 'pool_backup_restore_wal_locked': '备份恢复失败:无法删除原数据库的 WAL 边车文件,可能仍有进程持有数据库', + 'pool_backup_restore_verify_failed': '备份恢复失败:恢复后的数据库校验未通过,已回滚到原数据库', + 'pool_backup_restore_error': '备份恢复失败:{}', + 'pool_autostart_source_switched': '代理来源已切换为维护池,自动启动代理池', + 'pool_autostart_required': '配置要求使用内置代理池,自动启动代理池', + 'pool_autostart_skipped': '当前代理来源不使用内置代理池,跳过启动池', + 'pool_error_http_status': '维护池返回异常状态码: {}', + 'pool_error_connect': '无法连接到维护池: {}', + 'pool_error_timeout': '维护池请求超时', + 'pool_error_invalid_format': '维护池返回的代理格式无效: {}', + 'port_in_use': '端口 {} 已被其他程序占用,请更换其他端口后重试', + 'web_port_in_use': 'Web 面板端口 {} 已被其他程序占用,请修改 config.ini 的 web_port 后重新启动', + 'access_ok': '[成功] 客户端 {} 上游 {} 目标 {} {} {}ms 状态 {}', + 'access_failed': '[失败] 客户端 {} 上游 {} 目标 {} {} {}ms 状态 {} 原因 {}', + 'access_aborted': '[中断] 客户端 {} 上游 {} 目标 {} {} {}ms 状态 {} 原因 {}', + 'access_direct': '直连', + 'access_unknown': '未知', + 'access_incomplete': '请求未完成(客户端断开或请求被取消)', + 'access_tunnel_no_data': '隧道已建立但上游未返回任何数据(客户端等不到响应)', + 'access_tunnel_idle': '隧道已建立,客户端未发送数据即断开', + 'access_tunnel_client_left': '隧道已建立,客户端在收到上游数据前主动断开', + 'access_tunnel_closed_by_upstream': '隧道已建立,上游未发送任何数据即关闭连接', + 'proxy_silent_upstream': '代理 {} 收下 CONNECT 后 {} 秒内未回传任何数据,判定为不可用并触发健康检查', + 'domain_stats_cleared': '已清除 {} 个域名的访问统计', + 'access_outcome_success': '成功', + 'access_outcome_failure': '失败', + 'access_outcome_aborted': '中断', + 'access_records_cleared': '已清除 {} 条访问记录', + 'access_records_bad_outcome': '结果筛选取值非法(只能是 success、failure、aborted 之一):{}', + 'access_records_bad_format': '导出格式非法(只能是 csv 或 json):{}', + 'pool_geo_update_task_created': '离线归属地库更新任务已创建', + 'pool_geo_recompute_disabled': '归属地解析服务未启用,无法重算', + 'pool_geo_recompute_task_created': '归属地重算任务已创建', + 'pool_plugin_enabled': '插件 {} 已启用', + 'pool_plugin_disabled': '插件 {} 已禁用', + 'pool_plugin_enable_failed': '启用插件失败: {}', + 'pool_plugin_disable_failed': '禁用插件失败: {}', + 'pool_plugin_not_loaded': '插件 {} 未加载', + 'pool_plugin_interval_set': '插件 {} 运行周期已设置为 {} 分钟', + 'pool_plugin_interval_failed': '设置插件运行周期失败: {}', + 'pool_plugin_test_url_set': '插件 {} 的测试地址已设置为 {}', + 'pool_plugin_test_url_inherited': '插件 {} 的测试地址已恢复为继承全局地址', + 'pool_plugin_test_url_failed': '设置插件测试地址失败: {}', + 'pool_plugin_validation_set': '插件 {} 的验证策略已更新', + 'pool_plugin_validation_failed': '设置插件验证策略失败: {}', + 'pool_plugin_run_task_created': '插件 {} 抓取任务已创建', + 'pool_plugin_reload_success': '插件 {} 热重载成功', + 'pool_plugin_reload_rejected': '插件 {} 热重载失败(可能正在执行中或文件不存在)', + 'pool_plugin_reload_failed': '热重载失败: {}', + 'pool_no_matching_proxies': '没有符合条件的代理', + 'pool_proxy_not_found': '代理不存在', + 'config_option_invalid': '配置项 {} 非法(当前值: {}):{}', + 'config_reason_expected_int': '期望整数', + 'config_reason_expected_float': '期望小数', + 'config_reason_expected_bool': '期望布尔值(true/false)', + 'config_reason_expected_comma_list': '期望至少一个逗号分隔的取值', + 'config_reason_expected_value': '期望至少一个取值', + 'config_reason_min': '不能小于 {}', + 'config_reason_max': '不能大于 {}', + 'config_reason_expected_choice': '只能是 {} 之一', + 'config_reason_expected_ip': '必须是一到两个合法的 IP 地址(IPv4 与 IPv6 各至多一个,逗号分隔)', + 'config_reason_derived_key': '该配置项由 [Server] 段派生,不能单独设置', + 'config_reason_unknown_key': '不是可修改的配置项', + 'config_reason_users_via_api': '用户账号请通过用户管理接口修改', + + 'task_stage_prescreen_text': '正在初筛端口连通性...', + 'task_stage_validate_text': '正在验证代理...', + 'task_stage_ingest_text': '正在直接入库(该插件已关闭入库验证)...', + 'task_cancelled_with_counts': '任务已取消,{}', + 'task_failed': '任务失败: {}', + 'task_counts_saved_unusable': '已入库 {} 个,不可用 {} 个', + 'task_plugin_run_done': '插件 {} 执行成功,{}', + 'task_validating': '正在验证代理...', + 'task_validate_selected_done': '验证完成,有效: {}/{}', + 'task_inconclusive_suffix': ',{} 个因检测链路问题未得出结论', + 'task_validate_all_done': '验证完成,共验证 {} 个代理', + 'task_inconclusive_among_suffix': ',其中 {} 个因检测链路问题未得出结论', + 'task_validate_failed': '验证失败: {}', + 'task_no_proxies_to_validate': '没有需要验证的代理', + 'task_checking': '正在检测代理...', + 'task_pool_empty': '池里还没有代理', + 'task_check_done': '检测完成,共处理 {} 个代理', + 'task_check_failed': '检测失败: {}', + 'task_plugin_fetching': '正在抓取插件 {}...(外部源较多时最长约 {} 分钟)', + 'task_plugin_no_new_proxies': '插件 {} 执行完成,未获取到新代理', + 'task_plugin_fetch_cancelled': '插件 {} 抓取任务已取消,{}', + 'task_plugin_failed': '插件 {} 执行失败: {}', + 'task_geo_downloading': '正在下载离线归属地库...', + 'task_geo_downloading_file': '正在下载 {}({}%)', + 'task_geo_update_cancelled': '离线归属地库更新已取消', + 'task_geo_update_failed': '更新失败: {}', + 'task_geo_update_done': '离线归属地库更新完成', + 'task_geo_update_partial': '部分文件更新失败({}),其余已更新', + 'task_geo_recompute_disabled': '归属地解析服务未启用,无法重算', + 'task_geo_picking': '正在挑选待重算的记录...', + 'task_geo_nothing_to_recompute': '没有需要重算的记录', + 'task_geo_queued': '已排入 {} 个出口 IP,正在解析...', + 'task_geo_resolving': '正在解析归属地 {}/{}', + 'task_geo_recompute_cancelled': '归属地重算已取消(已解析的部分保留)', + 'task_geo_recompute_failed': '重算失败: {}', + 'task_geo_recompute_done': '归属地重算完成,共处理 {} 个出口 IP', + 'ingest_summary_candidates': '候选 {} 个', + 'ingest_summary_prescreened_out': '初筛淘汰 {} 个', + 'ingest_summary_validated': '验证 {} 个', + 'ingest_summary_saved': '入库 {} 个', + 'ingest_summary_invalid': '不可用 {} 个', + 'ingest_summary_incomplete': '信息不全 {} 个', + 'ingest_summary_inconclusive': '未能判定 {} 个', + 'ingest_summary_failed': '失败 {} 个', + 'list_separator': ',', + + 'config_body_must_be_json': '请求体必须是 JSON 对象', + 'proxies_must_be_list': '代理列表必须是数组', + 'config_sections_must_be_json': 'server 与 pool 都必须是 JSON 对象', + 'config_nothing_to_save': '没有需要保存的配置项', + 'ip_list_type_invalid': '类型必须为 whitelist 或 blacklist', + 'log_category_unknown': '未知的日志类别', + 'log_file_missing': '日志文件不存在', + 'log_file_name_invalid': '非法的日志文件名: {}', + 'log_file_read_failed': '读取日志文件失败: {}', + 'credential_name_required': '凭据名称不能为空', + 'credential_saved': '凭据已保存', + 'credential_deleted': '凭据已删除', + 'credential_switched': '已切换凭据', + 'credential_not_found': '凭据不存在: {}', + 'unknown_action': '未知操作', + 'no_current_proxy': '无', + 'service_thread_still_running': '旧服务线程未能在超时时间内退出,请稍后重试', + 'service_stop_timeout': '服务未能在超时时间内停止', + + 'param_invalid_protocol': '不支持的协议: {}', + 'param_invalid_status': '不支持的状态: {}', + 'param_negative_min_delay': '最小延迟不能为负数: {}', + 'param_negative_max_delay': '最大延迟不能为负数: {}', + 'param_delay_range_reversed': '最小延迟不能大于最大延迟: {} > {}', + 'param_negative_min_health': '最低健康分不能为负数: {}', + 'param_invalid_anonymity': '不支持的匿名等级: {}', + 'param_invalid_export_format': '不支持的格式: {},仅支持 {}', + 'param_invalid_validation_mode': '不支持的验证强度: {}(只能是 {} 之一)', + 'param_page_must_be_positive': '页码必须大于 0: {}', + 'param_page_size_must_be_positive': '每页数量必须大于 0: {}', + 'param_page_size_too_large': '每页数量不能大于 {}: {}', + 'param_limit_must_be_positive': 'limit 必须大于 0: {}', + 'param_limit_too_large': 'limit 不能大于 {}: {}', + 'param_proxy_list_empty': '代理列表不能为空', + 'param_proxy_ids_empty': '代理ID列表不能为空', + 'param_body_must_be_json': '请求体必须是 JSON 对象', + 'param_missing_fields': '缺少必需的参数: {}', + 'param_interval_must_be_positive': '运行周期必须大于 0 分钟: {}', + 'param_reval_interval_not_int': '重验证间隔必须是整数: {}', + + 'pool_not_running': '代理池未运行', + 'pool_stopped': '代理池已停止', + 'access_reason_bad_target': '非法的目标地址: {}', + 'access_reason_direct_failed': '直连目标失败: {}', + 'pool_call_timeout': '代理池调用超时({} 秒)', + 'pool_not_enabled': '代理池未启用', + + 'getip_api_error_code': 'API返回error000x-13', + + 'csv_col_protocol': '协议', 'csv_col_ip': 'IP', 'csv_col_port': '端口', + 'csv_col_username': '用户名', 'csv_col_password': '密码', + 'csv_col_proxy_url': '代理URL', 'csv_col_region': '地区', + 'csv_col_delay_ms': '延迟(ms)', 'csv_col_status': '状态', + 'csv_col_real_ip': '真实IP', 'csv_col_source': '来源', + 'csv_col_validated_at': '验证时间', + 'csv_status_valid': '有效', 'csv_status_invalid': '失效', + 'csv_col_time': '时间', 'csv_col_kind': '类型', 'csv_col_method': '方法', + 'csv_col_client_ip': '客户端IP', 'csv_col_client_user': '客户端用户', + 'csv_col_host': '目标域名', 'csv_col_upstream': '上游代理', + 'csv_col_outcome': '结果', 'csv_col_status_code': '状态码', + 'csv_col_elapsed': '耗时(ms)', 'csv_col_reason': '原因', + + 'plugin_error_bad_return_type': '返回值类型错误', + 'plugin_error_timeout': '执行超时', + 'plugin_error_bad_spec': '无效的模块规范', + 'plugin_error_missing_interface': '未实现必需的接口', + 'pool_field_protocol_required': '协议不能为空', + 'pool_field_protocol_unsupported': '不支持的协议: {}', + 'pool_field_ip_required': 'IP 地址不能为空', + 'pool_field_port_invalid': '无效的端口: {}', + 'pool_field_source_required': '来源插件不能为空', + 'pool_field_update_not_allowed': '不允许更新的字段: {}', + 'pool_field_where_not_allowed': '不允许作为更新条件的字段: {}', + 'pool_field_is_valid_invalid': '无效的 is_valid 值: {}', + 'pool_field_anonymity_invalid': '无效的匿名等级: {}', + 'pool_field_plugin_name_required': '插件名称不能为空', + 'pool_field_plugin_interval_invalid': '运行间隔必须大于 0: {}', + 'pool_field_reval_interval_invalid': '重验证间隔不能为负数: {}', + 'pool_repo_integrity_error': '代理数据完整性错误: {}', + 'pool_repo_plugin_integrity_error': '插件配置数据完整性错误: {}', + 'pool_repo_query_build_failed': '查询构建失败: {}', + 'pool_random_proxy_bad_format': 'format 参数必须是 json 或 text', + 'pool_import_no_valid_proxy': '没有有效的代理可导入', + 'pool_import_duplicated': '所有代理已存在,未导入新代理', + 'pool_import_validating': '正在后台验证 {} 个代理,通过后才入库', + 'pool_favorite_added': '代理已收藏', + 'pool_favorite_removed': '代理已取消收藏', + 'pool_validation_task_created': '验证任务已创建', + 'pool_full_check_task_created': '代理全面检测任务已创建', + 'pool_validation_no_valid_proxy': '未找到有效的代理', + 'pool_validating_proxies': '正在验证 {} 个代理', + 'pool_no_invalid_proxies': '没有失效代理需要删除', + 'pool_invalid_proxies_deleted': '成功删除 {} 个失效代理', + 'pool_proxies_deleted': '成功删除 {} 个代理', + 'pool_task_not_found': '任务不存在', + 'pool_task_already_finished': '任务已经结束,无需取消', + 'pool_task_cancelled': '任务已取消', }, 'en': { - 'getting_new_proxy': 'Getting new proxy IP', - 'new_proxy_is': 'New proxy IP is: {}', - 'proxy_check_start': 'Starting proxy check...', 'proxy_check_disabled': 'Proxy check is disabled', - 'valid_proxies': 'Valid proxies: {}', + 'valid_proxies': 'Check done, valid exits: {}', 'no_valid_proxies': 'No valid proxies found', - 'proxy_check_failed': '{} proxy {} check failed: {}', - 'proxy_switch': 'Switch proxy: {} -> {}', - 'proxy_consecutive_fails': 'Proxy {} failed {} times consecutively, switching to new proxy', - 'proxy_invalid': 'Proxy {} is invalid, switching proxy immediately', - 'proxy_failure': 'Proxy check failed: {}', - 'proxy_failure_threshold': 'Proxy invalid, switching now', + 'proxy_check_failed': 'Proxy {} check failed: {}', + 'proxy_switch': 'Upstream exits replaced: {} (was {})', + 'proxy_invalid': 'Proxy {} unreachable, dropped from this batch', 'proxy_check_error': 'Error occurred during proxy check: {}', - 'connection_timeout': 'Connection timeout', - 'data_transfer_timeout': 'Data transfer timeout, retrying...', - 'connection_reset': 'Connection reset', - 'transfer_cancelled': 'Transfer cancelled', - 'data_transfer_error': 'Data transfer error: {}', - 'unsupported_protocol': 'Unsupported protocol request: {}', - 'client_error': 'Client handling error: {}', - 'response_write_error': 'Response write error: {}', 'server_closing': 'Server is closing...', - 'program_interrupted': 'Program interrupted by user', - 'multiple_proxy_fail': 'Multiple attempts to get valid proxy failed, exiting', - 'current_proxy': 'Current Proxy', - 'next_switch': 'Next Switch', + 'current_proxy': 'Last Assigned Exit', + 'next_switch': 'Next expiry', 'seconds': 's', - 'no_proxies_available': 'No proxies available', 'proxy_file_not_found': 'Proxy file not found: {}', 'auth_not_set': 'Not set (No authentication required)', 'public_account': 'WeChat Public Number', 'blog': 'Blog', - 'proxy_mode': 'Proxy Rotation Mode', - 'cycle': 'Cycle', - 'loadbalance': 'Load Balance', + 'proxy_mode': 'Run Mode', + 'cycle': 'In order', + 'loadbalance': 'Least loaded', + 'continuous': 'Continuous rotation', + 'request': 'On-request rotation', 'single_round': 'Single Round', - 'proxy_interval': 'Proxy Change Interval', + 'proxy_interval': 'Exit Refresh Interval', 'default_auth': 'Default Username and Password', 'local_http': 'Local Listening Address (HTTP)', 'local_socks5': 'Local Listening Address (SOCKS5)', 'star_project': 'Star the Project', 'client_handle_error': 'Client handling error: {}', - 'proxy_invalid_switch': 'Proxy invalid, switching proxy', - 'request_fail_retry': 'Request failed, retrying remaining times: {}', 'user_interrupt': 'User interrupted the program', - 'new_version_found': 'New version available!', - 'visit_quark': 'Quark Drive: https://pan.quark.cn/s/39b4b5674570', - 'visit_github': 'GitHub: https://github.com/honmashironeko/ProxyCat', - 'visit_baidu': 'Baidu Drive: https://pan.baidu.com/s/1C9LVC9aiaQeYFSj_2mWH1w?pwd=13r5', 'latest_version': 'You are using the latest version', + 'new_version_available': 'New version available: {} (current {})', + 'version_not_checked': 'Version check has not completed yet', 'version_info_not_found': 'Version information not found', 'update_check_error': 'Failed to check for updates: {}', 'unauthorized_ip': 'Unauthorized IP attempt: {}', - 'client_cancelled': 'Client connection cancelled', 'socks5_connection_error': 'SOCKS5 connection error: {}', 'connect_timeout': 'Connection timeout', - 'connection_reset': 'Connection reset', - 'transfer_cancelled': 'Transfer cancelled', - 'data_transfer_error': 'Data transfer error: {}', 'client_request_error': 'Client request handling error: {}', - 'unsupported_protocol': 'Unsupported protocol: {}', 'request_retry': 'Request failed, retrying ({} left)', - 'request_error': 'Error during request: {}', - 'response_write_error': 'Error writing response: {}', - 'consecutive_failures': 'Consecutive proxy failures detected for {}', - 'invalid_proxy': 'Current proxy is invalid: {}', + 'log_suppressed': ' ({} similar messages suppressed in this window)', 'whitelist_error': 'Failed to add whitelist: {}', - 'api_mode_notice': 'Currently in API mode, proxy address will be automatically obtained upon request', + 'api_mode_notice': 'Currently in API mode, a batch of upstream exits will be fetched on demand', 'all_retries_failed': 'All retries failed, last error: {}', 'proxy_get_failed': 'Failed to get proxy', 'proxy_get_error': 'Error getting proxy: {}', - 'proxy_switch_error': 'Error switching proxy: {}', + 'proxy_config_error': 'Invalid proxy configuration: {}', + 'proxy_source_error_request': 'Fetching proxies failed (network unreachable, bad endpoint, or the provider refused)', + 'proxy_source_error_config': 'Proxy-fetch configuration is wrong (endpoint missing, or the provider returned an error code)', + 'proxy_source_error_unknown': 'Fetching proxies failed', + 'proxy_switch_error': 'Error refreshing upstream exits: {}', + 'proxy_allocate_error': 'Error obtaining an upstream exit: {}', + 'proxy_get_timeout': 'Upstream exit fetch timed out; waiting for the next replenish', 'server_running': 'Proxy server running at {}:{}', 'server_start_error': 'Server startup error: {}', 'server_shutting_down': 'Server shutting down...', - 'client_process_error': 'Client processing error: {}', - 'request_handling_error': 'Request handling error: {}', - 'proxy_forward_error': 'Proxy forwarding error: {}', - 'data_transfer_timeout': '{}data transfer timeout', 'status_update_error': 'Status update error', 'display_level_notice': 'Current display level: {}', 'display_level_desc': '''Display level description: -0: Only show proxy switch and error messages -1: Show proxy switch, countdown and error messages +0: Only show exit roster changes and error messages +1: Show the exit roster, per-exit state, countdown and error messages 2: Show all detailed information''', 'new_client_connect': 'New client connection - IP: {}, User: {}', - 'no_auth': 'No authentication', - 'connection_error': 'Connection handling error: {}', - 'cleanup_error': 'Cleanup IP error: {}', 'port_changed': 'Port changed: {} -> {}, server restart required to take effect', - 'config_updated': 'Server configuration updated', 'load_proxy_file_error': 'Failed to load proxy file: {}', 'proxy_check_result': 'Proxy check completed, valid proxies: {}', 'no_proxy': 'No proxy', - 'cycle_mode': 'Cycle mode', - 'loadbalance_mode': 'Load balance mode', 'proxy_check_start': 'Starting proxy check...', - 'proxy_check_complete': 'Proxy check completed', 'proxy_save_success': 'Proxy saved successfully', 'proxy_save_failed': 'Failed to save proxy: {}', 'ip_list_save_success': 'IP list saved successfully', 'ip_list_save_failed': 'Failed to save IP list: {}', - 'switch_success': 'Proxy switched successfully', - 'switch_failed': 'Failed to switch proxy: {}', + 'switch_success': 'Upstream exit batch refreshed', + 'switch_failed': 'Failed to refresh upstream exits: {}', + 'switch_cooldown_msg': 'Refresh cooldown, wait {} seconds', + 'switch_in_progress': 'Exit batch refresh in progress, please try again later', 'service_start_success': 'Service started successfully', - 'service_start_failed': 'Failed to start service', 'service_already_running': 'Service is already running', 'service_stop_success': 'Service stopped successfully', 'service_not_running': 'Service is not running', 'service_restart_success': 'Service restarted successfully', - 'service_restart_failed': 'Failed to restart service', 'invalid_action': 'Invalid action', 'operation_failed': 'Operation failed: {}', 'logs_cleared': 'Logs cleared', 'clear_logs_failed': 'Failed to clear logs: {}', 'unsupported_language': 'Unsupported language', - 'language_changed': 'Language changed to {}', - 'loading': 'Loading...', - 'get_proxy_failed': 'Failed to get new proxy: {}', - 'log_level_all': 'All', - 'log_level_info': 'Info', - 'log_level_warning': 'Warning', - 'log_level_error': 'Error', - 'log_level_critical': 'Critical', - 'confirm_clear_logs': 'Are you sure you want to clear all logs? This action cannot be undone.', - 'language_label': 'Language', - 'chinese': 'Chinese', - 'english': 'English', - 'manual_switch_btn': 'Switch Manually', - 'service_control_title': 'Service Control', - 'language_switch_success': 'Language switched successfully', - 'language_switch_failed': 'Failed to switch language', - 'refresh_failed': 'Failed to refresh data: {}', - 'auth_username_label': 'Authentication Username', - 'auth_password_label': 'Authentication Password', - 'proxy_auth_username_label': 'Proxy Authentication Username', - 'proxy_auth_password_label': 'Proxy Authentication Password', - 'progress_bar_label': 'Switch Progress', - 'proxy_settings_title': 'Proxy Settings', - 'config_save_success': 'Configuration saved successfully', - 'config_save_failed': 'Failed to save configuration: {}', - 'config_restart_required': 'Configuration changed, server restart required to take effect', - 'confirm_restart_service': 'Restart server now?', - 'service_status': 'Service Status', - 'running': 'Running', - 'stopped': 'Stopped', - 'restarting': 'Restarting', - 'unknown': 'Unknown', - 'service_start_failed': 'Failed to start service: {}', - 'service_stop_failed': 'Failed to stop service: {}', + 'service_start_failed': 'Failed to start service', 'service_restart_failed': 'Failed to restart service: {}', 'invalid_token': 'Invalid access token', 'config_file_changed': 'Config file change detected, reloading...', 'proxy_file_changed': 'Proxy file changed, reloading...', - 'test_target_label': 'Test Target Address', - 'invalid_test_target': 'Invalid test target address', + 'ip_lists_reloaded': 'Access lists reloaded: {} whitelist, {} blacklist, {} bypass entries', 'users_save_success': 'Users saved successfully', 'users_save_failed': 'Failed to save users: {}', - 'user_management_title': 'User Management', - 'username_column': 'Username', - 'password_column': 'Password', - 'actions_column': 'Actions', - 'add_user_btn': 'Add User', - 'enter_username': 'Please enter username', - 'enter_password': 'Please enter password', - 'confirm_delete_user': 'Are you sure you want to delete this user?', - 'no_logs_found': 'No matching logs found', - 'clear_search': 'Clear Search', + 'users_load_failed': 'Failed to load the user list: {}', 'web_panel_url': 'Web panel URL: {}', 'web_panel_notice': 'Please use a browser to access the above URL to manage the proxy server', - 'api_proxy_settings_title': 'API Proxy Settings', + 'pool_mode_notice': 'Currently in pool mode, a batch of upstream exits will be fetched on demand', + 'pool_no_proxy': 'No proxy available in pool', + 'pool_unavailable': 'Pool API unavailable: {}', + 'pool_fetch_failed': 'Failed to fetch proxy from pool: {}', + 'proxy_workers_ready': '{} upstream exits ready', + 'elastic_expanded': 'Exit pool expanded to {} exits (released automatically after the peak)', + 'elastic_capacity_boosted': 'Source has no new exits right now; per-exit concurrency temporarily raised to {}x', + 'elastic_released': 'Elastic capacity released; surplus exits retire as their lifetime ends ({} exits now)', + 'exit_pool_drained': 'No tasks are running, so expired exits were removed outright ({} exits now; the next request fetches fresh ones)', + 'exit_pool_trimmed': 'Exit pool trimmed back to the configured size; {} exits now', + 'proxy_exit_added': 'Exit added: {}', + 'proxy_exit_rotated': 'Exit rotated: {} -> {}', + 'proxy_worker_retired': 'Upstream exit {} is failing too often; retired and awaiting replacement', + 'proxy_batch_fetch_failed': 'Batch proxy fetch failed: {}', + 'proxy_batch_partial': 'Fetched {} proxies, expected {}', + 'exits_summary': 'Upstream exits: {}/{}', + 'exits_summary_expanded': 'Upstream exits: {}/{} (peak expansion active)', + 'exit_line': ' {} in use {} left {}', + 'exit_load_no_cap': '{} (no cap)', + 'exit_left_requests': '{} reqs', + 'exit_left_none': 'kept', + 'pool_start_success': 'Pool started successfully', + 'pool_start_failed': 'Failed to start pool: {}', + 'pool_stop_success': 'Pool stopped successfully', + 'pool_stop_failed': 'Failed to stop pool: {}', + 'pool_restart_success': 'Pool restarted successfully', + 'pool_restart_failed': 'Failed to restart pool: {}', + 'pool_status_error': 'Failed to get pool status: {}', + 'pool_starting': 'Starting proxy pool...', + 'pool_stopping': 'Stopping proxy pool...', + 'pool_unknown_error': 'unknown reason', + 'pool_start_timeout': 'Pool initialization timed out ({}s)', + 'pool_stop_timeout': 'Pool did not stop within {}s', + 'pool_previous_stop_incomplete': ( + 'The previous stop did not finish and the old thread is still running; ' + 'refusing to start a second pool instance against the same database. ' + 'Restart the process.' + ), + 'pool_loop_crashed': 'Pool event loop exited with an error: {}', + 'pool_config_unreadable': 'Config file is temporarily unreadable, keeping the current pool config and retrying next round', + 'pool_invalid_parameters': 'Invalid pool request parameters: {}', + 'pool_request_failed': 'Pool request failed: {}', + 'pool_internal_error': 'Pool internal error', + 'pool_restore_requires_stop': 'Stop the pool before restoring a backup (wait for a stopping pool to exit completely)', + 'pool_backup_filename_required': 'Backup file name is required', + 'pool_db_backup_disabled': 'Database backup is not enabled', + 'pool_db_maintenance_disabled': 'Database maintenance is unavailable', + 'pool_db_optimize_done': 'Database optimization completed', + 'pool_db_optimize_failed': 'Database optimization failed, please check the logs', + 'pool_backup_restore_success': 'Backup restored; restart the pool to load the restored data', + 'pool_backup_restore_bad_name': 'Restore failed: the file does not exist or its name is invalid', + 'pool_backup_restore_invalid_backup': 'Restore failed: the file is not a valid SQLite database', + 'pool_backup_restore_wal_locked': 'Restore failed: cannot remove the original database WAL sidecar files (another process may still hold the database)', + 'pool_backup_restore_verify_failed': 'Restore failed: the restored database failed verification and was rolled back', + 'pool_backup_restore_error': 'Restore failed: {}', + 'pool_autostart_source_switched': 'Proxy source switched to the pool, starting it automatically', + 'pool_autostart_required': 'Config requires the built-in pool, starting it automatically', + 'pool_autostart_skipped': 'The current proxy source does not use the built-in pool, skipping pool start', + 'pool_error_http_status': 'Pool returned abnormal status code: {}', + 'pool_error_connect': 'Cannot connect to pool: {}', + 'pool_error_timeout': 'Pool request timeout', + 'pool_error_invalid_format': 'Invalid proxy format from pool: {}', + 'port_in_use': 'Port {} is already in use by another program, please choose a different port and retry', + 'web_port_in_use': 'Web panel port {} is already in use by another program, please change web_port in config.ini and restart', + 'access_ok': '[OK] client {} upstream {} target {} {} {}ms status {}', + 'access_failed': '[FAIL] client {} upstream {} target {} {} {}ms status {} reason {}', + 'access_aborted': '[ABORT] client {} upstream {} target {} {} {}ms status {} reason {}', + 'access_direct': 'direct', + 'access_unknown': 'unknown', + 'access_incomplete': 'request not completed (client disconnected or cancelled)', + 'access_tunnel_no_data': 'tunnel established but upstream returned no data (client got no response)', + 'access_tunnel_idle': 'tunnel established, client disconnected without sending data', + 'access_tunnel_client_left': 'tunnel established, client gave up before any upstream data', + 'access_tunnel_closed_by_upstream': 'tunnel established, upstream closed without sending data', + 'proxy_silent_upstream': 'Proxy {} accepted CONNECT but sent no data within {}s; marked unusable and health-checked', + 'domain_stats_cleared': 'Cleared access statistics for {} domains', + 'access_outcome_success': 'Success', + 'access_outcome_failure': 'Failure', + 'access_outcome_aborted': 'Aborted', + 'access_records_cleared': 'Cleared {} access records', + 'access_records_bad_outcome': 'Invalid outcome filter (must be one of success, failure, aborted): {}', + 'access_records_bad_format': 'Invalid export format (must be csv or json): {}', + 'pool_geo_update_task_created': 'Geo database update task created', + 'pool_geo_recompute_disabled': 'Geo resolver is not enabled, cannot recompute', + 'pool_geo_recompute_task_created': 'Geo recompute task created', + 'pool_plugin_enabled': 'Plugin {} enabled', + 'pool_plugin_disabled': 'Plugin {} disabled', + 'pool_plugin_enable_failed': 'Failed to enable plugin: {}', + 'pool_plugin_disable_failed': 'Failed to disable plugin: {}', + 'pool_plugin_not_loaded': 'Plugin {} is not loaded', + 'pool_plugin_interval_set': 'Plugin {} interval set to {} minutes', + 'pool_plugin_interval_failed': 'Failed to set the plugin interval: {}', + 'pool_plugin_test_url_set': 'Test URL of plugin {} set to {}', + 'pool_plugin_test_url_inherited': 'Test URL of plugin {} restored to inherit the global URL', + 'pool_plugin_test_url_failed': 'Failed to set the plugin test URL: {}', + 'pool_plugin_validation_set': 'Validation policy of plugin {} updated', + 'pool_plugin_validation_failed': 'Failed to set the plugin validation policy: {}', + 'pool_plugin_run_task_created': 'Fetch task created for plugin {}', + 'pool_plugin_reload_success': 'Plugin {} reloaded successfully', + 'pool_plugin_reload_rejected': 'Failed to reload plugin {} (it may be running or the file does not exist)', + 'pool_plugin_reload_failed': 'Hot reload failed: {}', + 'pool_no_matching_proxies': 'No proxies match the filter', + 'pool_proxy_not_found': 'Proxy not found', + 'config_option_invalid': 'Invalid option {} (current value: {}): {}', + 'config_reason_expected_int': 'expected an integer', + 'config_reason_expected_float': 'expected a decimal number', + 'config_reason_expected_bool': 'expected a boolean (true/false)', + 'config_reason_expected_comma_list': 'expected at least one comma-separated value', + 'config_reason_expected_value': 'expected at least one value', + 'config_reason_min': 'must not be less than {}', + 'config_reason_max': 'must not be greater than {}', + 'config_reason_expected_choice': 'must be one of {}', + 'config_reason_expected_ip': 'must be one or two valid IP addresses (at most one IPv4 and one IPv6, comma-separated)', + 'config_reason_derived_key': 'this option is derived from the [Server] section and cannot be set separately', + 'config_reason_unknown_key': 'not an editable option', + 'config_reason_users_via_api': 'manage user accounts through the user management API', + + 'task_stage_prescreen_text': 'Checking port reachability...', + 'task_stage_validate_text': 'Validating proxies...', + 'task_stage_ingest_text': 'Storing directly (ingest validation is off for this plugin)...', + 'task_cancelled_with_counts': 'Task cancelled, {}', + 'task_failed': 'Task failed: {}', + 'task_counts_saved_unusable': '{} stored, {} unusable', + 'task_plugin_run_done': 'Plugin {} finished, {}', + 'task_validating': 'Validating proxies...', + 'task_validate_selected_done': 'Done, valid: {}/{}', + 'task_inconclusive_suffix': ', {} inconclusive (detection chain issue)', + 'task_validate_all_done': 'Done, {} proxies checked', + 'task_inconclusive_among_suffix': ', {} of them inconclusive (detection chain issue)', + 'task_validate_failed': 'Validation failed: {}', + 'task_no_proxies_to_validate': 'No proxies need validating', + 'task_checking': 'Running a full check...', + 'task_pool_empty': 'The pool has no proxies yet', + 'task_check_done': 'Check finished, {} proxies processed', + 'task_check_failed': 'Check failed: {}', + 'task_plugin_fetching': 'Fetching plugin {}... (up to about {} minutes with many sources)', + 'task_plugin_no_new_proxies': 'Plugin {} finished without new proxies', + 'task_plugin_fetch_cancelled': 'Fetch task for plugin {} cancelled, {}', + 'task_plugin_failed': 'Plugin {} failed: {}', + 'task_geo_downloading': 'Downloading the offline geo databases...', + 'task_geo_downloading_file': 'Downloading {} ({}%)', + 'task_geo_update_cancelled': 'Geo database update cancelled', + 'task_geo_update_failed': 'Update failed: {}', + 'task_geo_update_done': 'Geo databases updated', + 'task_geo_update_partial': 'Some files failed to update ({}), the rest are up to date', + 'task_geo_recompute_disabled': 'The geo resolver is not enabled, cannot recompute', + 'task_geo_picking': 'Picking records to recompute...', + 'task_geo_nothing_to_recompute': 'No records need recomputing', + 'task_geo_queued': '{} exit IPs queued for resolution...', + 'task_geo_resolving': 'Resolving geo {}/{}', + 'task_geo_recompute_cancelled': 'Geo recompute cancelled (already resolved parts are kept)', + 'task_geo_recompute_failed': 'Recompute failed: {}', + 'task_geo_recompute_done': 'Geo recompute finished, {} exit IPs processed', + 'ingest_summary_candidates': '{} candidates', + 'ingest_summary_prescreened_out': '{} unreachable', + 'ingest_summary_validated': '{} validated', + 'ingest_summary_saved': '{} stored', + 'ingest_summary_invalid': '{} unusable', + 'ingest_summary_incomplete': '{} incomplete', + 'ingest_summary_inconclusive': '{} inconclusive', + 'ingest_summary_failed': '{} failed', + 'list_separator': ', ', + + 'config_body_must_be_json': 'The request body must be a JSON object', + 'proxies_must_be_list': 'The proxy list must be an array', + 'config_sections_must_be_json': 'Both server and pool must be JSON objects', + 'config_nothing_to_save': 'Nothing to save', + 'ip_list_type_invalid': 'Type must be whitelist or blacklist', + 'log_category_unknown': 'Unknown log category', + 'log_file_missing': 'Log file does not exist', + 'log_file_name_invalid': 'Invalid log file name: {}', + 'log_file_read_failed': 'Failed to read the log file: {}', + 'credential_name_required': 'Credential name is required', + 'credential_saved': 'Credential saved', + 'credential_deleted': 'Credential deleted', + 'credential_switched': 'Credential switched', + 'credential_not_found': 'Credential not found: {}', + 'unknown_action': 'Unknown action', + 'no_current_proxy': 'none', + 'service_thread_still_running': 'the previous service thread did not exit within the timeout, please retry later', + 'service_stop_timeout': 'the service did not stop within the timeout', + + 'param_invalid_protocol': 'Unsupported protocol: {}', + 'param_invalid_status': 'Unsupported status: {}', + 'param_negative_min_delay': 'Minimum delay cannot be negative: {}', + 'param_negative_max_delay': 'Maximum delay cannot be negative: {}', + 'param_delay_range_reversed': 'Minimum delay cannot exceed maximum delay: {} > {}', + 'param_negative_min_health': 'Minimum health score cannot be negative: {}', + 'param_invalid_anonymity': 'Unsupported anonymity level: {}', + 'param_invalid_export_format': 'Unsupported format: {}; only {} are supported', + 'param_invalid_validation_mode': 'Unsupported validation mode: {} (must be one of {})', + 'param_page_must_be_positive': 'Page number must be greater than 0: {}', + 'param_page_size_must_be_positive': 'Page size must be greater than 0: {}', + 'param_page_size_too_large': 'Page size cannot exceed {}: {}', + 'param_limit_must_be_positive': 'limit must be greater than 0: {}', + 'param_limit_too_large': 'limit cannot exceed {}: {}', + 'param_proxy_list_empty': 'The proxy list cannot be empty', + 'param_proxy_ids_empty': 'The proxy ID list cannot be empty', + 'param_body_must_be_json': 'The request body must be a JSON object', + 'param_missing_fields': 'Missing required parameters: {}', + 'param_interval_must_be_positive': 'The run interval must be greater than 0 minutes: {}', + 'param_reval_interval_not_int': 'Re-validation interval must be an integer: {}', + + 'pool_not_running': 'The proxy pool is not running', + 'pool_stopped': 'The proxy pool has stopped', + 'access_reason_bad_target': 'Invalid target address: {}', + 'access_reason_direct_failed': 'Could not connect directly to the target: {}', + 'pool_call_timeout': 'Pool call timed out ({} s)', + 'pool_not_enabled': 'The proxy pool is not enabled', + 'getip_api_error_code': 'the API returned error000x-13', + + 'csv_col_protocol': 'Protocol', 'csv_col_ip': 'IP', 'csv_col_port': 'Port', + 'csv_col_username': 'Username', 'csv_col_password': 'Password', + 'csv_col_proxy_url': 'Proxy URL', 'csv_col_region': 'Region', + 'csv_col_delay_ms': 'Delay (ms)', 'csv_col_status': 'Status', + 'csv_col_real_ip': 'Exit IP', 'csv_col_source': 'Source', + 'csv_col_validated_at': 'Validated at', + 'csv_status_valid': 'valid', 'csv_status_invalid': 'invalid', + 'csv_col_time': 'Time', 'csv_col_kind': 'Kind', 'csv_col_method': 'Method', + 'csv_col_client_ip': 'Client IP', 'csv_col_client_user': 'Client user', + 'csv_col_host': 'Host', 'csv_col_upstream': 'Upstream proxy', + 'csv_col_outcome': 'Outcome', 'csv_col_status_code': 'Status code', + 'csv_col_elapsed': 'Elapsed (ms)', 'csv_col_reason': 'Reason', + + 'plugin_error_bad_return_type': 'unexpected return type', + 'plugin_error_timeout': 'execution timed out', + 'plugin_error_bad_spec': 'invalid module spec', + 'plugin_error_missing_interface': 'required interface not implemented', + 'pool_field_protocol_required': 'Protocol is required', + 'pool_field_protocol_unsupported': 'Unsupported protocol: {}', + 'pool_field_ip_required': 'IP address is required', + 'pool_field_port_invalid': 'Invalid port: {}', + 'pool_field_source_required': 'Source plugin is required', + 'pool_field_update_not_allowed': 'Fields that may not be updated: {}', + 'pool_field_where_not_allowed': 'Fields that may not be used as update conditions: {}', + 'pool_field_is_valid_invalid': 'Invalid is_valid value: {}', + 'pool_field_anonymity_invalid': 'Invalid anonymity level: {}', + 'pool_field_plugin_name_required': 'Plugin name is required', + 'pool_field_plugin_interval_invalid': 'Run interval must be greater than 0: {}', + 'pool_field_reval_interval_invalid': 'Re-validation interval cannot be negative: {}', + 'pool_repo_integrity_error': 'Proxy data integrity error: {}', + 'pool_repo_plugin_integrity_error': 'Plugin config data integrity error: {}', + 'pool_repo_query_build_failed': 'Query build failed: {}', + 'pool_random_proxy_bad_format': 'format must be json or text', + 'pool_import_no_valid_proxy': 'No valid proxies to import', + 'pool_import_duplicated': 'All proxies already exist, none imported', + 'pool_import_validating': 'Validating {} proxies in the background; only working ones are saved', + 'pool_favorite_added': 'Proxy added to favorites', + 'pool_favorite_removed': 'Proxy removed from favorites', + 'pool_validation_task_created': 'Validation task created', + 'pool_full_check_task_created': 'Full proxy check task created', + 'pool_validation_no_valid_proxy': 'No valid proxies to validate', + 'pool_validating_proxies': 'Validating {} proxies', + 'pool_no_invalid_proxies': 'No invalid proxies to delete', + 'pool_invalid_proxies_deleted': 'Deleted {} invalid proxies', + 'pool_proxies_deleted': 'Deleted {} proxies', + 'pool_task_not_found': 'Task not found', + 'pool_task_already_finished': 'The task has already finished, nothing to cancel', + 'pool_task_cancelled': 'Task cancelled', } } class MessageManager: + def __init__(self, messages=MESSAGES): self.messages = messages self.default_lang = 'cn' @@ -379,18 +773,47 @@ class MessageManager: message_manager = MessageManager(MESSAGES) get_message = message_manager.get + +def enable_safe_console_output() -> None: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(errors='replace') + except (AttributeError, ValueError, OSError): + continue + +MODE_BY_SOURCE = { + 'local': ('cycle', 'loadbalance'), + 'api': ('request', 'continuous'), + 'pool': ('request', 'continuous'), +} + + +def normalize_rotation_mode(mode, source_mode) -> str: + allowed = MODE_BY_SOURCE.get(str(source_mode or '').strip().lower(), + MODE_BY_SOURCE['api']) + text = str(mode or '').strip().lower() + return text if text in allowed else allowed[0] + + +def mode_display_text(config, language) -> str: + source = str(config.get('proxy_source_mode', 'local')).strip().lower() + return get_message(normalize_rotation_mode(config.get('mode'), source), language) + + def print_banner(config): language = config.get('language', 'cn').lower() - has_auth = config.get('username') and config.get('password') - auth_info = f"{config.get('username')}:{config.get('password')}" if has_auth else get_message('auth_not_set', language) - - http_addr = f"http://{auth_info}@127.0.0.1:{config.get('port')}" if has_auth else f"http://127.0.0.1:{config.get('port')}" - socks5_addr = f"socks5://{auth_info}@127.0.0.1:{config.get('port')}" if has_auth else f"socks5://127.0.0.1:{config.get('port')}" + + users = config.get('Users') or {} + has_auth = bool(users) + auth_info = "、".join(users.keys()) if has_auth else get_message('auth_not_set', language) + + http_addr = f"http://<用户>:<密码>@127.0.0.1:{config.get('port')}" if has_auth else f"http://127.0.0.1:{config.get('port')}" + socks5_addr = f"socks5://<用户>:<密码>@127.0.0.1:{config.get('port')}" if has_auth else f"socks5://127.0.0.1:{config.get('port')}" banner_info = [ (get_message('public_account', language), '樱花庄的本间白猫'), (get_message('blog', language), 'https://y.shironekosan.cn'), - (get_message('proxy_mode', language), get_message('cycle', language) if config.get('mode') == 'cycle' else get_message('loadbalance', language) if config.get('mode') == 'loadbalance' else get_message('single_round', language)), + (get_message('proxy_mode', language), mode_display_text(config, language)), (get_message('proxy_interval', language), f"{config.get('interval')}{get_message('seconds', language)}"), (get_message('default_auth', language), auth_info), (get_message('local_http', language), http_addr), @@ -408,92 +831,395 @@ def print_banner(config): else: print(f"\n{Fore.CYAN}{get_message('display_level_notice', language).format(display_level)}{Style.RESET_ALL}") -logo1 = r""" - |\ _,,,---,,_ by 本间白猫 -ZZZzz /,`.-'`' -. ;-;;,_ - |,4- ) )-,_. ,\ ( `'-' - '---''(_/--' `-'\_) ProxyCat -""" -logo2 = r""" - * ,MMM8&&&. * - MMMM88&&&&& . - MMMM88&&&&&&& - * MMM88&&&&&&&& - MMM88&&&&&&&& - 'MMM88&&&&&&' - 'MMM8&&&' * - /\/|_ __/\\ - / -\ /- ~\ . ' - \ =_YT_ = / - /==*(` `\ ~ \ ProxyCat - / \ / `\ by 本间白猫 - | | ) ~ ( - / \ / ~ \\ - \ / \~ ~/ - _/\_/\_/\__ _/_/\_/\__~__/_/\_/\_/\_/\_/\_ - | | | | ) ) | | | (( | | | | | | - | | | |( ( | | | \\ | | | | | | - | | | | )_) | | | |))| | | | | | - | | | | | | | | (/ | | | | | | - | | | | | | | | | | | | | | | -""" -logo3 = r""" - /\_/\ _ - /`` \ / ) - |n n |__ ( ( - =(Y =.'` `\ \ \\ - {`"` \ ) ) - { / |/ / - \\ ,( / / -ProxyCat) ) /-'\ ,_.' by 本间白猫 - (,(,/ ((,,/ -""" -logo4 = r""" - .-o=o-. - , /=o=o=o=\ .--. - _|\|=o=O=o=O=| \\ - __.' a`\=o=o=o=(`\ / - '. a 4/`|.-""'`\ \ ;'`) .---. - \ .' / .--' |_.' / .-._) - by 本间白猫 `) _.' / /`-.__.' / - ProxyCat `'-.____; /'-.___.-' - `\"""` -""" - -logos_list = [logo1, logo2, logo3, logo4] -def logos(): - selected_logo = random.choice(logos_list) - print(selected_logo) - DEFAULT_CONFIG = { 'port': '1080', 'mode': 'cycle', 'interval': '300', - 'username': 'neko', - 'password': '123456', - 'use_getip': 'False', + 'proxy_source_mode': 'local', + 'api_proxy_url': 'http://example.com/getip', + 'pool_remote_url': '', + 'proxy_file': 'ip.txt', - 'check_proxies': 'True', - 'whitelist_file': '', - 'blacklist_file': '', + 'check_proxies_on_startup': 'True', + 'check_proxies_on_use': 'True', + 'check_concurrency': '50', + 'whitelist_file': 'whitelist.txt', + 'blacklist_file': 'blacklist.txt', 'ip_auth_priority': 'whitelist', - 'language': 'cn' + 'language': 'cn', + 'display_level': '1', + + 'log_level': 'INFO', + 'log_max_bytes': '10485760', + 'log_access_enabled': 'true', + 'log_backup_count': '3', + + 'domain_stats_enabled': 'true', + 'domain_stats_flush_interval': '15', + 'domain_stats_retention_days': '30', + + 'access_records_enabled': 'true', + 'access_records_flush_interval': '5', + 'access_records_retention_days': '7', + 'access_records_max_rows': '200000', + 'access_records_buffer_size': '20000', + 'access_records_real_ip_probe': 'false', + + 'web_port': '5001', + 'token': '', + 'proxy_username': '', + 'proxy_password': '', + 'test_url': 'https://www.baidu.com', + 'request_interval': '0', + 'bypass_whitelist_file': 'bypass_whitelist.txt', + + 'switch_cooldown': '2', + 'proxy_check_ttl': '60', + 'check_cooldown': '10', + 'proxy_failure_cooldown': '3', + 'tunnel_idle_timeout': '10', + 'buffer_size': '8192', + 'max_concurrent_requests': '1000', + 'max_pool_size': '500', + 'max_concurrent_per_proxy': '0', + 'auto_expand_enabled': 'true', + 'exit_count': '5', + 'exit_wait_timeout': '15', + 'client_max_connections': '1000', + 'client_max_keepalive': '8', + 'client_keepalive_expiry': '30', + 'client_idle_timeout': '300' } +DEPRECATED_SERVER_KEYS = frozenset({ + 'check_proxies', + 'getip_url', + 'pool_autostart', + 'pool_port', + 'pool_proxy_url', + 'pool_web_url', + 'pool_proxy_count', +}) + + +def migrate_pool_proxy_count(config_file: str) -> bool: + try: + config = ini_parser() + config.read_string(read_config_text(os.path.abspath(str(config_file)))) + if not config.has_section('Server'): + return False + old_value = config.get('Server', 'pool_proxy_count', fallback='').strip() + new_value = config.get('Server', 'exit_count', fallback='').strip() + if not old_value or new_value: + return False + except Exception as e: + logging.warning(f"迁移 pool_proxy_count 时读取配置失败(已跳过): {e}") + return False + + if not update_ini_keys(config_file, 'Server', {'exit_count': old_value}): + return False + logging.info(f"已把 pool_proxy_count = {old_value} 迁移为 exit_count") + return True + + +def migrate_getip_url(config_file: str) -> bool: + try: + config = ini_parser() + config.read_string(read_config_text(os.path.abspath(str(config_file)))) + if not config.has_section('Server'): + return False + legacy_value = config.get('Server', 'getip_url', fallback='').strip() + if not legacy_value: + return False + except Exception as e: + logging.warning(f"迁移 getip_url 时读取配置失败(已跳过): {e}") + return False + + if not update_ini_keys(config_file, 'Server', + {'api_proxy_url': legacy_value, 'getip_url': ''}): + return False + logging.info("已把 getip_url 合并进 api_proxy_url 并清空" + "(两者语义相同,合并后不再有「接口与凭据错配」)") + return True + + +def strip_deprecated_server_keys(config_file: str) -> bool: + try: + removed = remove_ini_keys(config_file, 'Server', DEPRECATED_SERVER_KEYS) + except OSError as e: + logging.warning(f"清理废弃配置项时读写配置失败(已跳过): {e}") + return False + except Exception as e: + logging.warning(f"清理废弃配置项时读取配置失败(已跳过): {e}") + return False + + if not removed: + return False + + logging.info(f"已从 [Server] 段清除 {len(removed)} 个废弃配置项: {', '.join(sorted(removed))}") + return True + + +def remove_ini_keys(path, section: str, keys) -> list: + target = os.path.abspath(str(path)) + original = read_config_text(target) + + lines = original.splitlines(keepends=True) + span = _ini_section_span(lines, section) + if span is None: + return [] + + start, end = span + wanted = {str(key) for key in keys} + removed = [] + kept = [] + + for i, line in enumerate(lines): + if start < i < end: + stripped = line.lstrip() + if stripped and not stripped.startswith(('#', ';')) and '=' in line: + key = line.split('=', 1)[0].strip() + if key in wanted: + removed.append(key) + continue + kept.append(line) + + if not removed: + return [] + + write_text_atomic(target, ''.join(kept)) + return removed + + +TRUE_WORDS = frozenset({"1", "true", "yes", "on"}) +FALSE_WORDS = frozenset({"0", "false", "no", "off"}) + + +def render_config_error(key: str, value: Any, reason_key: str, reason_args: tuple, + language: str) -> str: + reason = get_message(reason_key, language, *reason_args) + return get_message('config_option_invalid', language, key, str(value), reason) + + +def parse_bool_lenient(value, default: bool = False) -> bool: + text = str(value).strip().lower() + if text in TRUE_WORDS: + return True + if text in FALSE_WORDS: + return False + return default + + +def parse_int_lenient(value, default: int, *, low: int) -> int: + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError): + return default + return parsed if parsed >= low else default + + +def write_text_atomic(path, text: str) -> None: + import tempfile + + directory = os.path.dirname(os.path.abspath(str(path))) + fd, temp_path = tempfile.mkstemp(dir=directory, prefix='.config-', suffix='.tmp') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + _copy_file_mode(str(path), temp_path) + os.replace(temp_path, str(path)) + except BaseException: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + +def _copy_file_mode(source, target) -> None: + try: + os.chmod(target, os.stat(source).st_mode & 0o7777) + except OSError: + pass + + +def ini_parser() -> ConfigParser: + parser = ConfigParser(interpolation=None) + parser.optionxform = str + return parser + + +def read_config_text(path) -> str: + with open(os.path.abspath(str(path)), 'rb') as handle: + raw = handle.read() + + try: + return _universal_newlines(raw.decode('utf-8-sig')) + except UnicodeDecodeError as utf8_error: + local = locale.getpreferredencoding(False) or '' + if local.replace('_', '-').lower() in ('utf-8', 'utf8', 'cp65001', 'ascii'): + raise + try: + text = raw.decode(local) + except (UnicodeDecodeError, LookupError): + raise utf8_error + logging.warning( + "%s 不是 UTF-8 编码,已按系统代码页 %s 读取;" + "请用编辑器另存为 UTF-8,避免后续内容被误读", + path, local, + ) + return _universal_newlines(text) + + +def _universal_newlines(text: str) -> str: + return text.replace('\r\n', '\n').replace('\r', '\n') + + +_INI_SECTION_HEADER = re.compile(r'^\s*\[[^\]]+\]\s*$') + + +def _ini_section_span(lines: list, section: str): + header = re.compile(rf'^\s*\[{re.escape(section)}\]\s*$') + start = next((i for i, line in enumerate(lines) if header.match(line)), None) + if start is None: + return None + end = len(lines) + for i in range(start + 1, len(lines)): + if _INI_SECTION_HEADER.match(lines[i]): + end = i + break + return start, end + + +def _line_ending(line: str) -> str: + if line.endswith('\r\n'): + return '\r\n' + if line.endswith('\n'): + return '\n' + return '\n' + + +def update_ini_keys(path, section: str, updates) -> bool: + target = os.path.abspath(str(path)) + original = read_config_text(target) + + lines = original.splitlines(keepends=True) + span = _ini_section_span(lines, section) + + if span is None: + block = f'[{section}]\n' + ''.join( + f'{key} = {value}\n' for key, value in updates.items() + ) + head = original.rstrip('\n') + updated = f'{head}\n\n{block}' if head else block + else: + start, end = span + remaining = dict(updates) + for i in range(start + 1, end): + line = lines[i] + if line.lstrip().startswith(('#', ';')) or '=' not in line: + continue + key = line.split('=', 1)[0].strip() + if key in remaining: + lines[i] = f'{key} = {remaining.pop(key)}{_line_ending(line)}' + + for key, value in remaining.items(): + insert_at = end + while insert_at > start + 1 and not lines[insert_at - 1].strip(): + insert_at -= 1 + lines.insert(insert_at, f'{key} = {value}\n') + end += 1 + + updated = ''.join(lines) + + if updated == original: + return False + + write_text_atomic(target, updated) + return True + + +def _trim_section_tail(lines: list, start: int, end: int) -> int: + content_end = end + while content_end > start + 1: + stripped = lines[content_end - 1].strip() + if stripped and not stripped.startswith(('#', ';')): + break + content_end -= 1 + return content_end + + +def write_ini_section(path, section: str, entries) -> bool: + target = os.path.abspath(str(path)) + original = read_config_text(target) + + lines = original.splitlines(keepends=True) + span = _ini_section_span(lines, section) + + if not entries: + if span is None: + return False + start, end = span + del lines[start:_trim_section_tail(lines, start, end)] + updated = ''.join(lines) + else: + block = f'[{section}]\n' + ''.join( + f'{key} = {value}\n' for key, value in entries.items() + ) + '\n' + if span is None: + head = original.rstrip('\n') + updated = f'{head}\n\n{block}' if head else block + else: + start, end = span + content_end = _trim_section_tail(lines, start, end) + updated = ''.join(lines[:start]) + block + ''.join(lines[content_end:]) + + if updated == original: + return False + + write_text_atomic(target, updated) + return True + + +class NoPoolConsoleFilter(logging.Filter): + + POOL_LOGGER_PREFIXES = ( + "core.", + "plugin.", + "modules.proxypool_service", + "modules.proxypool_api", + "modules.pool_config_ini", + ) + + def filter(self, record): + return not record.name.startswith(self.POOL_LOGGER_PREFIXES) + + +def port_in_use(port, host='127.0.0.1', timeout=0.5): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(timeout) + return s.connect_ex((host, int(port))) == 0 + except Exception: + return False + + def load_config(config_file='config/config.ini'): try: - config = ConfigParser() - config.read(config_file, encoding='utf-8') - + config = ini_parser() + if os.path.exists(config_file): + config.read_string(read_config_text(config_file)) + if not config.has_section('Server'): config.add_section('Server') for key, value in DEFAULT_CONFIG.items(): - config.set('Server', key, str(value)) - with open(config_file, 'w', encoding='utf-8') as f: - config.write(f) - - result = dict(config.items('Server')) - + if not config.has_option('Server', key): + config.set('Server', key, str(value)) + + result = {key.lower(): value for key, value in config.items('Server')} + if config.has_section('Users'): result['Users'] = dict(config.items('Users')) @@ -504,161 +1230,308 @@ def load_config(config_file='config/config.ini'): def load_ip_list(file_path): try: - config_path = os.path.join('config', os.path.basename(file_path)) - if os.path.exists(config_path): - with open(config_path, 'r', encoding='utf-8') as f: - return set(line.strip() for line in f if line.strip()) + if os.path.exists(file_path): + return set( + line.strip() for line in read_config_text(file_path).splitlines() + if line.strip() and not line.strip().startswith('#') + ) except Exception as e: logging.error(f"Error loading IP list: {e}") return set() + +def load_bypass_whitelist(file_path): + try: + if os.path.exists(file_path): + return set(line.strip() for line in read_config_text(file_path).splitlines() + if line.strip() and not line.strip().startswith('#')) + except Exception as e: + logging.error(f"加载代理绕过白名单失败: {e}") + return set() + + +def check_bypass_match(host, bypass_list): + if not bypass_list or not host: + return False + normalized_host = str(host).strip().lower() + for pattern in bypass_list: + pattern = pattern.strip() + if not pattern: + continue + if fnmatch.fnmatchcase(normalized_host, pattern.lower()): + return True + return False + + +def list_files_snapshot(server) -> dict: + snapshot = {} + for path in (server.whitelist_file, server.blacklist_file, + server.bypass_whitelist_file): + try: + snapshot[path] = os.path.getmtime(path) + except OSError: + snapshot[path] = 0 + return snapshot + + +def reload_ip_lists_if_changed(server, snapshot: dict) -> dict: + current = list_files_snapshot(server) + if current != snapshot: + server.reload_ip_lists() + return current + _proxy_check_cache = {} _proxy_check_ttl = 10 +_proxy_check_max_size = 1000 +_proxy_check_cache_lock = threading.Lock() + +_proxy_cache_clean_interval = 10.0 +_last_proxy_cache_clean = 0.0 + + +def _clean_proxy_cache(): + global _last_proxy_cache_clean + current_time = time.time() + if current_time - _last_proxy_cache_clean < _proxy_cache_clean_interval: + return + _last_proxy_cache_clean = current_time + + expired_keys = [ + k for k, (cache_time, _) in _proxy_check_cache.items() + if current_time - cache_time >= _proxy_check_ttl + ] + for k in expired_keys: + del _proxy_check_cache[k] + if len(_proxy_check_cache) > _proxy_check_max_size: + excess = len(_proxy_check_cache) - int(_proxy_check_max_size * 0.8) + oldest_keys = sorted( + _proxy_check_cache.keys(), + key=lambda k: _proxy_check_cache[k][0] + )[:excess] + for k in oldest_keys: + del _proxy_check_cache[k] + +def split_host_port(value: str, default_port=None): + text = value.strip() + port_text = '' + if text.startswith('['): + host, closed, rest = text[1:].partition(']') + if not closed: + raise ValueError(f'IPv6 字面量缺少闭合方括号: {value}') + if rest and not rest.startswith(':'): + raise ValueError(f'IPv6 字面量后只能是端口: {value}') + port_text = rest[1:] + else: + head, sep, tail = text.rpartition(':') + if not sep or ':' in head: + host = text + else: + host, port_text = head, tail + + if not port_text: + if default_port is None: + raise ValueError(f'缺少端口: {value}') + return host, default_port + if not port_text.isdigit(): + raise ValueError(f'端口不是数字: {value}') + return host, int(port_text) + +def parse_proxy_url(proxy): + protocol, separator, remaining = proxy.partition('://') + if not separator: + raise ValueError(f'缺少协议: {proxy}') + auth = None + if '@' in remaining: + auth, remaining = remaining.rsplit('@', 1) + host, port = split_host_port(remaining) + return protocol, auth, host, port + def parse_proxy(proxy): try: - protocol = proxy.split('://')[0] - remaining = proxy.split('://')[1] - - if '@' in remaining: - auth, address = remaining.split('@') - host, port = address.split(':') - return protocol, auth, host, int(port) - else: - host, port = remaining.split(':') - return protocol, None, host, int(port) + return parse_proxy_url(proxy) except Exception: return None, None, None, None +def format_proxy_url(protocol, host, port, auth=None): + host_text = f'[{host}]' if ':' in host else host + if auth: + return f'{protocol}://{auth}@{host_text}:{port}' + return f'{protocol}://{host_text}:{port}' + + +def sanitize_proxy(proxy: str) -> str: + if not proxy: + return '' + text = str(proxy) + if '@' not in text: + return text + + scheme, _, rest = text.rpartition('://') + credentials, _, host = rest.rpartition('@') + if not credentials: + return text + + user = credentials.split(':', 1)[0] + masked = f"{user}:***" if ':' in credentials else credentials + return f"{scheme}://{masked}@{host}" if scheme else f"{masked}@{host}" + async def check_http_proxy(proxy, test_url=None): if test_url is None: test_url = 'https://www.baidu.com' protocol, auth, host, port = parse_proxy(proxy) - proxies = {} - if auth: - proxies['http://'] = f'{protocol}://{auth}@{host}:{port}' - proxies['https://'] = f'{protocol}://{auth}@{host}:{port}' - else: - proxies['http://'] = f'{protocol}://{host}:{port}' - proxies['https://'] = f'{protocol}://{host}:{port}' - + url = format_proxy_url(protocol, host, port, auth) + proxies = {'http://': url, 'https://': url} + try: - async with httpx.AsyncClient(proxies=proxies, timeout=10, verify=False) as client: + async with httpx.AsyncClient(proxies=proxies, timeout=10, verify=False, + trust_env=False) as client: try: - response = await client.get(test_url) - return response.status_code == 200 - except: + response = await client.get(test_url, follow_redirects=True) + return response.status_code < 400 + except (httpx.HTTPError, OSError, asyncio.TimeoutError): if test_url.startswith('https://'): http_url = 'http://' + test_url[8:] - response = await client.get(http_url) - return response.status_code == 200 + try: + response = await client.get(http_url, follow_redirects=True) + return response.status_code < 400 + except (httpx.HTTPError, OSError, asyncio.TimeoutError): + pass return False - except: + except (httpx.HTTPError, OSError, asyncio.TimeoutError): return False +def build_socks5_auth_packet(username: str, password: str) -> bytes: + username_bytes = username.encode() + password_bytes = password.encode() + for label, raw in (('用户名', username_bytes), ('密码', password_bytes)): + if not 1 <= len(raw) <= 255: + raise ValueError(f'SOCKS5 {label}长度须为 1-255 字节,实际 {len(raw)}') + return (b'\x01' + bytes([len(username_bytes)]) + username_bytes + + bytes([len(password_bytes)]) + password_bytes) + + async def check_socks_proxy(proxy, test_url=None): if test_url is None: test_url = 'https://www.baidu.com' protocol, auth, host, port = parse_proxy(proxy) if not all([host, port]): return False - + + writer = None try: reader, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout=5) - + if auth: writer.write(b'\x05\x02\x00\x02') else: writer.write(b'\x05\x01\x00') - + await writer.drain() - + auth_method = await asyncio.wait_for(reader.readexactly(2), timeout=5) if auth_method[0] != 0x05: return False - + if auth_method[1] == 0x02 and auth: - username, password = auth.split(':') - auth_packet = bytes([0x01, len(username)]) + username.encode() + bytes([len(password)]) + password.encode() - writer.write(auth_packet) + username, password = auth.split(':', 1) + writer.write(build_socks5_auth_packet(username, password)) await writer.drain() - + auth_response = await asyncio.wait_for(reader.readexactly(2), timeout=5) if auth_response[1] != 0x00: return False - + from urllib.parse import urlparse - domain = urlparse(test_url).netloc if '://' in test_url else test_url - domain = domain.encode() - - writer.write(b'\x05\x01\x00\x03' + bytes([len(domain)]) + domain + b'\x00\x50') + parsed = urlparse(test_url) + domain = (parsed.hostname or test_url).encode() + target_port = parsed.port or (443 if parsed.scheme == 'https' else 80) + writer.write( + b'\x05\x01\x00\x03' + + bytes([len(domain)]) + + domain + + target_port.to_bytes(2, 'big') + ) await writer.drain() - - response = await asyncio.wait_for(reader.readexactly(10), timeout=5) - writer.close() - try: - await writer.wait_closed() - except: - pass - - return response[1] == 0x00 - + + head = await asyncio.wait_for(reader.readexactly(4), timeout=5) + atyp = head[3] + if atyp == 0x01: + tail = await asyncio.wait_for(reader.readexactly(6), timeout=5) + elif atyp == 0x04: + tail = await asyncio.wait_for(reader.readexactly(18), timeout=5) + elif atyp == 0x03: + length = await asyncio.wait_for(reader.readexactly(1), timeout=5) + tail = length + await asyncio.wait_for( + reader.readexactly(length[0] + 2), timeout=5) + else: + return False + + return head[1] == 0x00 + except Exception: return False + finally: + await _close_socks_writer(writer) + + +async def _close_socks_writer(writer) -> None: + if writer is None: + return + try: + writer.close() + await writer.wait_closed() + except Exception: + pass async def check_proxy(proxy, test_url=None): + test_url = test_url or 'https://www.baidu.com' current_time = time.time() cache_key = f"{proxy}:{test_url}" - - if cache_key in _proxy_check_cache: - cache_time, is_valid = _proxy_check_cache[cache_key] - if current_time - cache_time < _proxy_check_ttl: - return is_valid - + + with _proxy_check_cache_lock: + _clean_proxy_cache() + if cache_key in _proxy_check_cache: + cache_time, is_valid = _proxy_check_cache[cache_key] + if current_time - cache_time < _proxy_check_ttl: + return is_valid + proxy_type = proxy.split('://')[0] check_funcs = { 'http': check_http_proxy, 'https': check_http_proxy, 'socks5': check_socks_proxy } - + if proxy_type not in check_funcs: return False - + try: - test_url = test_url or 'https://www.baidu.com' is_valid = await check_funcs[proxy_type](proxy, test_url) - _proxy_check_cache[cache_key] = (current_time, is_valid) + with _proxy_check_cache_lock: + _proxy_check_cache[cache_key] = (current_time, is_valid) return is_valid - except Exception: - _proxy_check_cache[cache_key] = (current_time, False) + except Exception as e: + logger.debug(f"代理检测异常(不写缓存,下次重试) {sanitize_proxy(proxy)}: {e}") return False -async def check_proxies(proxies, test_url=None): - valid_proxies = [] - for proxy in proxies: - if await check_proxy(proxy, test_url): - valid_proxies.append(proxy) - return valid_proxies +DEFAULT_CHECK_CONCURRENCY = 50 -async def check_for_updates(language='cn'): - try: - async with httpx.AsyncClient() as client: - response = await asyncio.wait_for(client.get("https://y.shironekosan.cn/1.html"), timeout=10) - response.raise_for_status() - content = response.text - match = re.search(r'

(ProxyCat-V\d+\.\d+\.\d+)

', content) - if match: - latest_version = match.group(1) - CURRENT_VERSION = "ProxyCat-V2.0.4" - if version.parse(latest_version.split('-V')[1]) > version.parse(CURRENT_VERSION.split('-V')[1]): - print(f"{Fore.YELLOW}{get_message('new_version_found', language)} 当前版本: {CURRENT_VERSION}, 最新版本: {latest_version}{Style.RESET_ALL}") - print(f"{Fore.YELLOW}{get_message('visit_quark', language)}{Style.RESET_ALL}") - print(f"{Fore.YELLOW}{get_message('visit_github', language)}{Style.RESET_ALL}") - print(f"{Fore.YELLOW}{get_message('visit_baidu', language)}{Style.RESET_ALL}") - else: - print(f"{Fore.GREEN}{get_message('latest_version', language)} ({CURRENT_VERSION}){Style.RESET_ALL}") - else: - print(f"{Fore.RED}{get_message('version_info_not_found', language)}{Style.RESET_ALL}") - except Exception as e: - print(f"{Fore.RED}{get_message('update_check_error', language, e)}{Style.RESET_ALL}") \ No newline at end of file + +async def check_proxies(proxies, test_url=None, concurrency=DEFAULT_CHECK_CONCURRENCY): + if not proxies: + return [] + + limit = max(1, int(concurrency)) + semaphore = asyncio.Semaphore(limit) + + async def check_one(proxy): + async with semaphore: + return proxy if await check_proxy(proxy, test_url) else None + + chunk_size = max(1, limit * 4) + results = [] + for start in range(0, len(proxies), chunk_size): + chunk = proxies[start:start + chunk_size] + results.extend(await asyncio.gather(*(check_one(p) for p in chunk))) + return [proxy for proxy in results if proxy is not None] diff --git a/modules/pool_config_ini.py b/modules/pool_config_ini.py new file mode 100644 index 0000000..08208d5 --- /dev/null +++ b/modules/pool_config_ini.py @@ -0,0 +1,1041 @@ +""" +模块名称:modules.pool_config_ini +功能描述:把 config/config.ini 的 [Pool] 段与代理池 Config 对象树互转:反射数据模型得到键名与类型、强制取值、校验数值区间与 + 格式、渲染该段文本,并负责该段的引导式创建与旧 config.yaml 迁移;界面呈现与取值规则需随新增字段手工补。 +职责边界:负责:键名映射、类型强制、取值校验、共享项派生、[Pool] 段渲染与落盘重写、引导式创建与存量迁移、启动与热重载两条读取路径、 + 界面提交规范化、表单描述与进程内池启动判断;不负责:配置保存接口的流程编排(POST /api/config)、热重载与变更通知 + (core.infrastructure.config_manager)、区间与格式之外的运行期校验(由各使用方校验)。 +关键依赖:core.config(配置数据模型)、core.domain.models(canonical_ip)、modules.modules(原子写、真值字面量)、PyYAML(仅迁移路径)。 +已知限制: + 1. 导入本模块会经 modules.proxypool 把池子项目目录注入 sys.path,其后的 core.* 才能导入;该导入勿删、勿后置。 + 2. 布尔值只认 TRUE_WORDS 与 FALSE_WORDS,其余字面量一律抛 PoolConfigError:strict=True 直接抛出,strict=False 丢该项回默认值。 + 3. 数值区间查 _POOL_RANGE_RULES(含上界),格式查 _POOL_VALUE_RULES(host_public_ip 走 IP 文本校验);未列入的键只做类型强制。 + 4. 列表类配置在 ini 里用英文逗号分隔;读取时关闭插值,[Users] 段的键名大小写敏感,其余段的键名折回小写。 + 5. 引导式创建与排版同步会重写 [Pool] 段全部内容,该段原有注释不保留;其余段内容不动,行尾统一为平台换行符。 + 6. 按插件的重验证开关与周期存放在数据库而非本段;DEPRECATED_KEYS 解析时跳过、渲染时清除、界面不展示。 + 7. describe_pool_schema 的 unit 不走 _text:空译文回落到中文会把 en 的计次单位显示成「个」。 +""" + +import dataclasses +import logging +import re +import shutil +import typing +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from modules.modules import FALSE_WORDS, TRUE_WORDS, ini_parser, render_config_error + +from modules.proxypool import POOL_DIR # noqa: F401 +from core.config import Config +from core.domain.models import canonical_ip + +logger = logging.getLogger(__name__) + +POOL_SECTION = "Pool" + +USERS_SECTION = "Users" + +_TRUE_VALUES = TRUE_WORDS +_FALSE_VALUES = FALSE_WORDS + +LocalizedText = Mapping[str, str] + +_LANGUAGE_CN = "cn" + + +def _text(spec: LocalizedText, language: str = _LANGUAGE_CN) -> str: + return spec.get(language) or spec.get(_LANGUAGE_CN, "") + + +DERIVED_KEYS = frozenset({ + "validator.target_url", +}) + +DEPRECATED_KEYS = frozenset({ + "enabled", + "auto_cleanup.interval_minutes", + "performance.auto_adjust_concurrency", + "performance.cpu_threshold_percent", + "performance.memory_threshold_percent", + "validator.max_ip_retries", + "validator.deadline_seconds", + "validator.adaptive_timeout_enabled", + "validator.min_timeout_seconds", + "plugins.auto_validate_on_fetch", + "plugins.auto_geoip_on_fetch", + "performance.invalid_proxy_retention_days", + "performance.cleanup_interval_hours", + "geoip.api_url", + "geoip.use_proxy", + "geoip.timeout_seconds", + "geoip.max_retries", + "logging.format", + "logging.level", +}) + + +class PoolConfigError(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 _iter_leaf_fields() -> typing.Iterator[tuple[str, type, Any]]: + default_config = Config() + for section_field in dataclasses.fields(Config): + section_cls = section_field.type + if isinstance(section_cls, str): + section_cls = type(getattr(default_config, section_field.name)) + if not dataclasses.is_dataclass(section_cls): + continue + + default_section = getattr(default_config, section_field.name) + for leaf in dataclasses.fields(section_cls): + yield ( + f"{section_field.name}.{leaf.name}", + leaf.type, + getattr(default_section, leaf.name), + ) + + +def _coerce(key: str, raw: str, target_type: type) -> Any: + text = raw.strip() + + if target_type is bool: + lowered = text.lower() + if lowered in _TRUE_VALUES: + return True + if lowered in _FALSE_VALUES: + return False + raise PoolConfigError(key, raw, "config_reason_expected_bool") + + if target_type is int: + try: + return int(text) + except ValueError: + raise PoolConfigError(key, raw, "config_reason_expected_int") from None + + if target_type is float: + try: + return float(text) + except ValueError: + raise PoolConfigError(key, raw, "config_reason_expected_float") from None + + if typing.get_origin(target_type) is list: + items = [item.strip() for item in text.split(",") if item.strip()] + if not items: + raise PoolConfigError(key, raw, "config_reason_expected_comma_list") + return items + + return raw + + +_POOL_RANGE_RULES: dict[str, tuple[float | None, float | None]] = { + "database.backup_interval_hours": (1, None), + "database.backup_retention_days": (1, None), + "database.pool_max_readers": (1, 64), + "validator.timeout_seconds": (1, None), + "validator.connect_timeout_seconds": (0, None), + "validator.max_concurrent": (1, 10000), + "plugins.scan_interval_seconds": (1, None), + "plugins.default_interval_minutes": (1, None), + "plugins.execution_timeout_seconds": (1, None), + "plugins.request_timeout_seconds": (1, None), + "plugins.max_parallel_plugins": (1, None), + "plugins.max_retries_on_failure": (0, None), + "plugins.retry_base_delay_seconds": (0, 3600), + "auto_revalidation.interval_minutes": (1, None), + "auto_revalidation.full_recheck_interval_minutes": (0, None), + "auto_cleanup.max_failures": (1, None), + "auto_cleanup.min_health_score": (0, None), + "auto_cleanup.min_checks_before_cleanup": (1, None), + "auto_cleanup.max_age_days_if_invalid": (1, None), + "use_feedback.invalid_threshold": (1, None), + "write_queue.batch_size": (1, None), + "write_queue.max_retries": (0, None), + "write_queue.max_queue_size": (1, None), + "logging.max_bytes": (1024, None), + "logging.backup_count": (1, None), +} + + +def _check_range(key: str, value: Any, raw: Any) -> None: + bounds = _POOL_RANGE_RULES.get(key) + if bounds is None: + return + + low, high = bounds + if low is not None and value < low: + raise PoolConfigError(key, raw, "config_reason_min", low) + if high is not None and value > high: + raise PoolConfigError(key, raw, "config_reason_max", high) + + +def _check_ip_text(key: str, value: Any, raw: Any) -> None: + text = str(value).strip() + if not text: + return + + seen: set[int] = set() + for part in text.replace(",", ",").split(","): + canonical = canonical_ip(part.strip()) + if canonical is None: + raise PoolConfigError(key, raw, "config_reason_expected_ip") + version = 6 if ":" in canonical else 4 + if version in seen: + raise PoolConfigError(key, raw, "config_reason_expected_ip") + seen.add(version) + + +_POOL_VALUE_RULES: dict[str, typing.Callable[[str, Any, Any], None]] = { + "validator.host_public_ip": _check_ip_text, +} + + +def _check_value(key: str, value: Any, raw: Any) -> None: + rule = _POOL_VALUE_RULES.get(key) + if rule is not None: + rule(key, value, raw) + + + +from modules.modules import read_config_text as _read_config_text +from modules.modules import write_text_atomic as _atomic_write_text + + + +def parse_pool_section(pool_section: Mapping[str, str], strict: bool = True) -> Config: + from core.config import build_config + + nested: dict[str, dict[str, Any]] = {} + field_types = {key: target for key, target, _ in _iter_leaf_fields()} + + for key, raw in pool_section.items(): + if key in DERIVED_KEYS: + logger.debug("池配置项 %s 由 [Server] 派生,忽略 [Pool] 段中的取值", key) + continue + if key in DEPRECATED_KEYS: + logger.debug("池配置项 %s 已废弃,忽略", key) + continue + if key not in field_types: + logger.warning("忽略无法识别的池配置项: %s", key) + continue + + try: + value = _coerce(key, raw, field_types[key]) + _check_range(key, value, raw) + _check_value(key, value, raw) + except PoolConfigError: + if strict: + raise + logger.warning("池配置项 %s 的值非法(%r),该项使用默认值", key, raw) + continue + + section_name, leaf_name = key.split(".", 1) + nested.setdefault(section_name, {})[leaf_name] = value + + return build_config(nested) + + +def build_pool_config( + server_section: Mapping[str, str], + pool_section: Mapping[str, str], + strict: bool = True, +) -> Config: + config = parse_pool_section(pool_section, strict=strict) + + test_url = (server_section.get("test_url") or "").strip() + if test_url: + config.validator.target_url = test_url + + return config + + +def _to_ini_text(value: Any, target_type: type) -> str: + if target_type is bool: + return "true" if value else "false" + if typing.get_origin(target_type) is list: + return ", ".join(str(item) for item in value) + return str(value) + + +def dump_pool_config(config: Config) -> dict[str, str]: + dumped: dict[str, str] = {} + for key, target_type, _ in _iter_leaf_fields(): + if key in DERIVED_KEYS: + continue + section_name, leaf_name = key.split(".", 1) + value = getattr(getattr(config, section_name), leaf_name) + dumped[key] = _to_ini_text(value, target_type) + + return dumped + + +def _field_type_name(target_type: Any) -> str: + if target_type is bool: + return "bool" + if target_type is int: + return "int" + if target_type is float: + return "float" + if typing.get_origin(target_type) is list: + return "list" + return "text" + +POOL_FIELD_LABELS = { + 'database.path': { + 'title': {'cn': '数据库文件路径', 'en': 'Database file path'}, + 'hint': {'cn': '改动需重启池服务;相对路径从 proxypool 目录起算', 'en': 'Relative paths start from the proxypool directory; changes need a pool restart'}, + }, + 'database.pool_max_readers': { + 'title': {'cn': '读连接数上限', 'en': 'Max read connections'}, + 'hint': {'cn': '调大要多占文件句柄;改动需重启池服务', 'en': 'Raising it costs more file handles; needs a pool restart'}, + 'unit': {'cn': '个', 'en': ''}, + }, + 'database.backup_enabled': { + 'title': {'cn': '自动备份', 'en': 'Automatic backup'}, + 'hint': {'cn': '保存即生效;关掉后不再生成新备份', 'en': 'Applies immediately; turning it off stops new backups'}, + }, + 'database.backup_interval_hours': { + 'title': {'cn': '备份间隔', 'en': 'Backup interval'}, + 'hint': {'cn': '自动备份关闭时不生效;调小会增加磁盘占用', 'en': 'No effect while automatic backup is off; a shorter interval uses more disk'}, + 'unit': {'cn': '小时', 'en': 'h'}, + }, + 'database.backup_retention_days': { + 'title': {'cn': '备份保留', 'en': 'Backup retention'}, + 'hint': {'cn': '决定能恢复到多久以前', 'en': 'Determines how far back you can restore from'}, + 'unit': {'cn': '天', 'en': 'd'}, + }, + + 'validator.check_anonymity': { + 'title': {'cn': '检查匿名度', 'en': 'Check anonymity'}, + 'hint': {'cn': '开启后每个代理会多一次明文探测请求', 'en': 'Costs one extra plaintext probe request per proxy'}, + }, + 'validator.host_public_ip': { + 'title': {'cn': '本机公网 IP', 'en': 'Host public IP'}, + 'hint': {'cn': '双栈主机两族各填一个、逗号隔开;留空则无法判定匿名度', 'en': 'Dual-stack: one address per family, comma-separated; empty disables anonymity judging'}, + }, + 'validator.timeout_seconds': { + 'title': {'cn': '验证总超时', 'en': 'Overall timeout'}, + 'hint': {'cn': '一次验证的总上限;调小会误杀偏慢的可用代理', 'en': 'The cap for a full validation; too low kills slow but usable proxies'}, + 'unit': {'cn': '秒', 'en': 's'}, + }, + 'validator.connect_timeout_seconds': { + 'title': {'cn': '连接超时', 'en': 'Connect timeout'}, + 'hint': {'cn': '0 表示不单独限制,只受总超时约束;调小可加快整批验证', 'en': '0 = no separate limit (overall timeout still applies); lowering it speeds up batches'}, + 'unit': {'cn': '秒', 'en': 's'}, + }, + 'validator.max_concurrent': { + 'title': {'cn': '同时验证数', 'en': 'Concurrent validations'}, + 'hint': {'cn': '调高会多占本机连接与 socket;站点限流时调低', 'en': 'Higher values use more local sockets; lower it if the test site rate-limits'}, + 'unit': {'cn': '个', 'en': ''}, + }, + 'validator.identity_echo_apis': { + 'title': {'cn': '身份回显接口', 'en': 'Identity echo endpoints'}, + 'hint': {'cn': '必须是 https,逗号分隔;连不上的自动跳过', 'en': 'https only, comma-separated; unreachable ones are skipped'}, + }, + 'validator.plain_identity_echo_apis': { + 'title': {'cn': '明文回显接口', 'en': 'Plaintext echo endpoints'}, + 'hint': {'cn': '必须是 http,逗号分隔;代理够不到默认站点时补', 'en': 'http only, comma-separated; fill in sites your proxies can reach'}, + }, + 'validator.ip_check_apis': { + 'title': {'cn': 'IP 回显接口', 'en': 'IP-only echo endpoints'}, + 'hint': {'cn': '必须是 https;上面的接口拿不到结果时兜底', 'en': 'https only; used when the endpoints above get nothing'}, + }, + + 'plugins.scan_interval_seconds': { + 'title': {'cn': '插件检查间隔', 'en': 'Plugin scan interval'}, + 'hint': {'cn': '改了只会变检查快慢,各插件自己的抓取周期不动', 'en': 'Only how quickly due plugins are noticed; their own intervals are unchanged'}, + 'unit': {'cn': '秒', 'en': 's'}, + }, + 'plugins.default_interval_minutes': { + 'title': {'cn': '新插件周期', 'en': 'New plugin interval'}, + 'hint': {'cn': '改动只影响之后新建的插件,已有插件保持各自的周期', 'en': 'Applies to newly added plugins only; existing ones keep their own intervals'}, + 'unit': {'cn': '分钟', 'en': 'min'}, + }, + 'plugins.execution_timeout_seconds': { + 'title': {'cn': '整轮抓取超时', 'en': 'Whole-run timeout'}, + 'hint': {'cn': '给整轮抓取设的硬时限,调太小会打断耗时长的正常抓取', 'en': 'Caps a whole run; too low aborts slow fetches that would otherwise finish'}, + 'unit': {'cn': '秒', 'en': 's'}, + }, + 'plugins.request_timeout_seconds': { + 'title': {'cn': '单次请求超时', 'en': 'Request timeout'}, + 'hint': {'cn': '按单个网页请求计算,每个请求都单独受它限制', 'en': 'Counted per web request, not per plugin run'}, + 'unit': {'cn': '秒', 'en': 's'}, + }, + 'plugins.max_parallel_plugins': { + 'title': {'cn': '插件并发上限', 'en': 'Max parallel plugins'}, + 'hint': {'cn': '调高可缩短整批抓取时间,同时更占资源', 'en': 'Higher finishes batches sooner but uses more resources'}, + 'unit': {'cn': '个', 'en': ''}, + }, + 'plugins.max_retries_on_failure': { + 'title': {'cn': '失败重试次数', 'en': 'Retries on failure'}, + 'hint': {'cn': '0 表示失败即放弃;它数的是额外次数,不是总次数', 'en': '0 = give up at once; counts extra attempts, not total tries'}, + 'unit': {'cn': '次', 'en': ''}, + }, + 'plugins.retry_base_delay_seconds': { + 'title': {'cn': '首次重试等待', 'en': 'First retry wait'}, + 'hint': {'cn': '每次重试等待翻倍;0 表示不等待、立即重试', 'en': 'Wait doubles after each retry; 0 = retry immediately'}, + 'unit': {'cn': '秒', 'en': 's'}, + }, + + 'auto_revalidation.enabled': { + 'title': {'cn': '定时重新验证', 'en': 'Periodic re-validation'}, + 'hint': {'cn': '关掉后失效的代理不会自动被发现,状态保持原样', 'en': 'Off means a proxy that dies keeps its last recorded state'}, + }, + 'auto_revalidation.interval_minutes': { + 'title': {'cn': '重验证周期', 'en': 'Re-check interval'}, + 'hint': {'cn': '只作全局默认,单个插件的周期在插件管理里另设', 'en': "A default only; each plugin's own interval is set under Plugins"}, + 'unit': {'cn': '分钟', 'en': 'min'}, + }, + 'auto_revalidation.only_valid_proxies': { + 'title': {'cn': '只查可用代理', 'en': 'Valid proxies only'}, + 'hint': {'cn': '失效的不会被自动翻回可用(须手动验证),换来每轮更快', 'en': 'Dead proxies will not come back automatically, only via manual runs; each round is faster'}, + }, + 'auto_revalidation.run_on_start': { + 'title': {'cn': '启动时先查一遍', 'en': 'Check on startup'}, + 'hint': {'cn': '关掉时首轮检查要排到一个周期之后', 'en': 'Off schedules the first round a full interval after startup'}, + }, + 'auto_revalidation.full_recheck_interval_minutes': { + 'title': {'cn': '完整检测周期', 'en': 'Full recheck interval'}, + 'hint': {'cn': '0 表示每轮都做完整检测(更慢);平时只测存活', 'en': '0 = full assessment every round (slower); routine rounds stay liveness-only'}, + 'unit': {'cn': '分钟', 'en': 'min'}, + }, + + 'auto_cleanup.enabled': { + 'title': {'cn': '自动清理', 'en': 'Auto cleanup'}, + 'hint': {'cn': '关闭后不删任何代理;删除不可恢复', 'en': 'Nothing is deleted when off; deletions cannot be undone'}, + }, + 'auto_cleanup.min_health_score': { + 'title': {'cn': '健康分下限', 'en': 'Minimum health score'}, + 'hint': {'cn': '还要检查次数达标才淘汰;0 表示不按评分淘汰', 'en': 'Also requires the check-count minimum; 0 disables score-based cleanup'}, + 'unit': {'cn': '分', 'en': 'pts'}, + }, + 'auto_cleanup.min_checks_before_cleanup': { + 'title': {'cn': '最少检查次数', 'en': 'Minimum checks before cleanup'}, + 'hint': {'cn': '检查次数未达标不按评分淘汰;调小易误删新代理', 'en': 'Score cleanup waits for this many checks; lowering it risks deleting fresh proxies'}, + 'unit': {'cn': '次', 'en': ''}, + }, + 'auto_cleanup.max_failures': { + 'title': {'cn': '连续验证失败', 'en': 'Consecutive check failures'}, + 'hint': {'cn': '一次成功就把计数清零;调小会更快删除不可用代理', 'en': 'One success resets the count; lower it to delete unusable proxies sooner'}, + 'unit': {'cn': '次', 'en': ''}, + }, + 'auto_cleanup.max_age_days_if_invalid': { + 'title': {'cn': '失效代理保留', 'en': 'Invalid proxy retention'}, + 'hint': {'cn': '从判定失效起算;调小会更快删除', 'en': 'The clock starts at invalidation; lower it to delete sooner'}, + 'unit': {'cn': '天', 'en': 'd'}, + }, + + 'use_feedback.enabled': { + 'title': {'cn': '使用期反馈', 'en': 'Use-time feedback'}, + 'hint': {'cn': '转发失败达阈值才判失效;关掉后只信验证结果', 'en': 'Forwarding failures count toward invalidation; off means only validation results decide'}, + }, + 'use_feedback.invalid_threshold': { + 'title': {'cn': '转发失败阈值', 'en': 'Forwarding failure threshold'}, + 'hint': {'cn': '成功一次即清零;调低会让更多代理被判失效', 'en': 'One success resets the streak; lowering it flags more proxies as invalid'}, + 'unit': {'cn': '次', 'en': ''}, + }, + + 'write_queue.batch_size': { + 'title': {'cn': '每批写入条数', 'en': 'Records per write batch'}, + 'hint': {'cn': '调大更省写入次数,但记录落库更晚', 'en': 'Bigger batches mean fewer writes but records reach the database later'}, + 'unit': {'cn': '条', 'en': ''}, + }, + 'write_queue.max_retries': { + 'title': {'cn': '写入尝试次数', 'en': 'Write attempts per batch'}, + 'hint': {'cn': '0 或 1 都表示失败就放弃;调大可扛写库临时故障', 'en': '0 or 1 both mean give up on failure; raise it to ride out transient DB failures'}, + 'unit': {'cn': '次', 'en': ''}, + }, + 'write_queue.max_queue_size': { + 'title': {'cn': '待写队列上限', 'en': 'Pending write queue cap'}, + 'hint': {'cn': '保存后要重启才生效;排满后新写入会被拒', 'en': 'Needs a restart to take effect; new writes are rejected once full'}, + 'unit': {'cn': '条', 'en': ''}, + }, + + 'logging.file_path': { + 'title': {'cn': '日志文件路径', 'en': 'Log file path'}, + 'hint': {'cn': '相对路径从 modules/proxypool/ 算起', 'en': 'Relative paths are resolved from modules/proxypool/'}, + }, + 'logging.max_bytes': { + 'title': {'cn': '单文件上限', 'en': 'Max file size'}, + 'hint': {'cn': '调小则轮转更频繁,磁盘占用 ≈ 单文件上限 × 保留份数', 'en': 'Lower it to rotate more often; disk use ≈ size × files kept'}, + 'unit': {'cn': '字节', 'en': 'bytes'}, + }, + 'logging.backup_count': { + 'title': {'cn': '历史文件保留', 'en': 'Rotated files to keep'}, + 'hint': {'cn': '超出后自动删掉最旧的,磁盘占用不会无限增长', 'en': 'Oldest rotated files are deleted once the count is exceeded'}, + 'unit': {'cn': '份', 'en': ''}, + }, +} + +POOL_GROUP_SUBTITLES = { + 'database': {'cn': '数据存在哪、怎么备份', 'en': 'Where data lives and how it is backed up'}, + 'validator': {'cn': '什么样的代理算可用', 'en': 'What counts as a usable proxy'}, + 'plugins': {'cn': '多久去各来源抓一次', 'en': 'How often each source is scraped'}, + 'auto_revalidation': {'cn': '失效的代理怎么被发现', 'en': 'How dead proxies get noticed'}, + 'auto_cleanup': {'cn': '什么样的代理会被删掉', 'en': 'Which proxies get deleted'}, + 'use_feedback': {'cn': '转发失败算不算数', 'en': 'Whether forwarding failures count'}, + 'write_queue': {'cn': '记录攒多少再落库', 'en': 'How records are batched into the database'}, + 'logging': {'cn': '日志写多大、留几份', 'en': 'Log size and how many are kept'}, +} + +_POOL_VISIBLE_WHEN = { + 'database.backup_interval_hours': 'database.backup_enabled=true', + 'database.backup_retention_days': 'database.backup_enabled=true', + 'auto_revalidation.interval_minutes': 'auto_revalidation.enabled=true', + 'auto_revalidation.only_valid_proxies': 'auto_revalidation.enabled=true', + 'auto_revalidation.run_on_start': 'auto_revalidation.enabled=true', + 'auto_revalidation.full_recheck_interval_minutes': 'auto_revalidation.enabled=true', + 'auto_cleanup.min_health_score': 'auto_cleanup.enabled=true', + 'auto_cleanup.min_checks_before_cleanup': 'auto_cleanup.enabled=true', + 'auto_cleanup.max_failures': 'auto_cleanup.enabled=true', + 'auto_cleanup.max_age_days_if_invalid': 'auto_cleanup.enabled=true', + 'use_feedback.invalid_threshold': 'use_feedback.enabled=true', +} + + +def describe_pool_schema(language: str = _LANGUAGE_CN) -> list[dict[str, Any]]: + field_types = {key: target for key, target, _ in _iter_leaf_fields()} + + groups = [] + for group_title, fields in POOL_SECTION_LAYOUT: + known = [] + for key, doc in fields: + if key not in field_types: + continue + descriptor: dict[str, Any] = { + "key": key, + "doc": _text(doc, language), + "type": _field_type_name(field_types[key]), + } + label = POOL_FIELD_LABELS.get(key) + if label: + descriptor["title"] = _text(label["title"], language) + descriptor["hint"] = _text(label["hint"], language) + unit = (label.get("unit") or {}).get(language, "") + if unit: + descriptor["unit"] = unit + if key in _POOL_ADVANCED_KEYS: + descriptor["tier"] = "advanced" + visible_when = _POOL_VISIBLE_WHEN.get(key) + if visible_when: + descriptor["visible_when"] = visible_when + bounds = _POOL_RANGE_RULES.get(key) + if bounds is not None: + low, high = bounds + if low is not None: + descriptor["low"] = low + if high is not None: + descriptor["high"] = high + known.append(descriptor) + if known: + group_id = known[0]["key"].split(".", 1)[0] + group = { + "title": _text(group_title, language), + "id": group_id, + "fields": known, + } + subtitle = POOL_GROUP_SUBTITLES.get(group_id) + if subtitle: + group["sub"] = _text(subtitle, language) + groups.append(group) + return groups + +_POOL_ADVANCED_KEYS = frozenset({ + "database.path", "database.pool_max_readers", + "database.backup_interval_hours", "database.backup_retention_days", + "validator.host_public_ip", "validator.timeout_seconds", + "validator.connect_timeout_seconds", "validator.max_concurrent", + "validator.identity_echo_apis", "validator.plain_identity_echo_apis", + "validator.ip_check_apis", + "plugins.execution_timeout_seconds", "plugins.request_timeout_seconds", + "plugins.max_parallel_plugins", "plugins.max_retries_on_failure", + "plugins.retry_base_delay_seconds", + "auto_revalidation.full_recheck_interval_minutes", + "auto_cleanup.min_health_score", "auto_cleanup.min_checks_before_cleanup", + "auto_cleanup.max_failures", "auto_cleanup.max_age_days_if_invalid", + "use_feedback.invalid_threshold", + "write_queue.max_retries", "write_queue.max_queue_size", + "logging.file_path", +}) + + +def normalize_pool_updates(updates: Mapping[str, Any]) -> dict[str, str]: + field_types = {key: target for key, target, _ in _iter_leaf_fields()} + normalized: dict[str, str] = {} + + for key, value in updates.items(): + if key in DEPRECATED_KEYS: + logger.debug("忽略已废弃的池配置项: %s", key) + continue + + if key in DERIVED_KEYS: + raise PoolConfigError(key, value, "config_reason_derived_key") + + if key not in field_types: + raise PoolConfigError(key, value, "config_reason_unknown_key") + + target = field_types[key] + if typing.get_origin(target) is list and isinstance(value, (list, tuple)): + items = [str(item).strip() for item in value if str(item).strip()] + if not items: + raise PoolConfigError(key, value, "config_reason_expected_value") + coerced = items + else: + coerced = _coerce(key, str(value), target) + _check_range(key, coerced, value) + _check_value(key, coerced, value) + + normalized[key] = _to_ini_text(coerced, target) + + return normalized + + +def should_autostart_pool(server_section: Mapping[str, str]) -> bool: + source_mode = (server_section.get("proxy_source_mode") or "local").strip().lower() + if source_mode != "pool": + return False + return not (server_section.get("pool_remote_url") or "").strip() + + +def migrate_from_yaml(yaml_path: str | Path) -> dict[str, str]: + path = Path(yaml_path) + if not path.is_file(): + logger.info("未找到旧的池配置文件 %s,跳过迁移", path) + return {} + + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError) as e: + logger.error("读取旧池配置文件失败,将使用默认配置: %s", e) + return {} + + if not isinstance(data, dict): + logger.error("旧池配置文件的根节点不是映射,将使用默认配置") + return {} + + known_keys = {key for key, _, _ in _iter_leaf_fields()} + migrated: dict[str, str] = {} + + for section_name, section_data in data.items(): + if not isinstance(section_data, dict): + continue + for leaf_name, value in section_data.items(): + key = f"{section_name}.{leaf_name}" + if key in DERIVED_KEYS or key not in known_keys: + logger.info("迁移时跳过配置项: %s", key) + continue + target_type = next(t for k, t, _ in _iter_leaf_fields() if k == key) + if target_type is bool: + migrated[key] = "true" if value else "false" + elif typing.get_origin(target_type) is list and isinstance(value, list): + migrated[key] = ", ".join(str(item) for item in value) + else: + migrated[key] = str(value) + + logger.info("已从 %s 迁移 %d 个池配置项", path, len(migrated)) + return migrated + + +def default_pool_section() -> dict[str, str]: + return dump_pool_config(Config()) + + + + +POOL_SECTION_LAYOUT: tuple[ + tuple[LocalizedText, tuple[tuple[str, LocalizedText], ...]], ... +] = ( + ({"cn": "数据库", "en": "Database"}, ( + ("database.path", { + "cn": "代理池所有数据存放的文件(SQLite 格式)。填相对路径时以 modules/proxypool/ 目录为起点。改这一项保存后不会立即生效,需重启池服务 —— 库在启动时就已经打开了", + "en": "File holding all pool data (SQLite). A relative path is resolved from the modules/proxypool/ directory. Changes here do not take effect until the pool service restarts — the database is opened at startup", + }), + ("database.pool_max_readers", { + "cn": "同时查询数据库的连接数上限,写入固定只占 1 个连接。调大能让并发查询更顺畅,也会多占用一些文件句柄;池子不大时保持默认即可。改这一项保存后不会立即生效,需重启池服务", + "en": "Maximum simultaneous read connections; writes always use a single connection. Raising it smooths concurrent queries at the cost of more file handles — keep the default for a small pool. Changes here do not take effect until the pool service restarts", + }), + ("database.backup_enabled", { + "cn": "是否按下面的间隔自动备份数据库;保存后立即生效(关掉即停,重新打开即起)", + "en": "Whether to back the database up automatically at the interval below. Takes effect as soon as it is saved (turning it off stops it, turning it back on starts it)", + }), + ("database.backup_interval_hours", { + "cn": "自动备份的间隔(小时)", + "en": "Automatic backup interval (hours)", + }), + ("database.backup_retention_days", { + "cn": "备份文件保留多少天,超期自动删除", + "en": "How many days to keep backup files before deleting them", + }), + )), + ({"cn": "代理验证", "en": "Proxy validation"}, ( + ("validator.check_anonymity", { + "cn": "是否检查代理的匿名度。匿名度靠「目标站点实际收到了哪些请求头」判定,只在经代理发一次明文请求时才测得出来;关掉后不再发这类请求、也不再判匿名度,已经测出来的等级原样保留", + "en": "Whether to check proxy anonymity. It is judged by the request headers the target actually received, which requires one plaintext request through the proxy. Turning it off stops those requests and stops updating anonymity; levels already measured are kept", + }), + ("validator.host_public_ip", { + "cn": "本机公网出口 IP,判定「透明代理」时的对比基准。双栈主机两族各填一个、用逗号隔开(IPv4 与 IPv6 各至多一个),两族都会参与比对;只填一个也能用。留空时 ProxyCat 会**不经过代理**直接访问 IP 查询站点获取(每小时最多一次);在这里填上你自己的出口 IP 可以完全免掉这次对外请求。留空或拿不到时无法判定匿名度,那一项会保持原样", + "en": "Your machine's public IP, the reference for detecting transparent proxies. On a dual-stack host give one address per family, comma-separated (at most one IPv4 and one IPv6); both take part in the comparison, and a single address also works. When empty, ProxyCat queries an IP lookup site directly (bypassing any proxy) at most once an hour; filling in your own exit IP removes that external request entirely. Without it anonymity cannot be judged and that field keeps its previous value", + }), + ("validator.timeout_seconds", { + "cn": "验证一个代理最多等多少秒,超过就判为不可用。所有探测请求都按这个上限", + "en": "How long one proxy validation may take, in seconds; past that it counts as unusable. Every probe request is bounded by this", + }), + ("validator.connect_timeout_seconds", { + "cn": "连接代理最多等多少秒。失效的代理大多卡在连不上这一步,调小它可以明显加快整批验证;填 0 表示不单独限制,只受上面的总超时约束", + "en": "How long to wait while connecting to the proxy, in seconds. Most dead proxies stall at this step, so lowering it speeds a batch up noticeably; 0 means no separate limit - only the overall timeout above applies", + }), + ("validator.max_concurrent", { + "cn": "同时验证多少个代理。每次验证连的是不同主机,可以放心调高;同时它也决定本机连接池上限与可打开的 socket 数(一次验证会并发发出十几个请求),受限环境应调低;如果测试站点开始限流(表现为大量代理突然超时),也调低一些", + "en": "How many proxies to validate at once. Each validation talks to a different host, so raising it is safe; it also sets this machine's connection-pool ceiling and open-socket budget (one validation fires a dozen-odd requests at once), so lower it on constrained hosts. Lower it too if the test site starts rate-limiting you (many proxies timing out at once)", + }), + ("validator.identity_echo_apis", { + "cn": "用来探测出口 IP 和匿名度的接口,多个用英文逗号分隔,必须是 https。这类接口要能返回 origin 与 headers,一次请求就能同时得出出口 IP、匿名度和 HTTPS 支持情况。连不上的会被自动跳过;可用的按直连延迟排序后逐个补发竞速,不按条数截断,多写的留作替补", + "en": "Endpoints used to detect the exit IP and anonymity, comma-separated, https only. They must return origin and headers so one request yields the exit IP, anonymity and HTTPS support. Unreachable ones are skipped automatically; the reachable ones are raced in order of direct-connect latency, with no cap on how many take part — extras serve as spares", + }), + ("validator.plain_identity_echo_apis", { + "cn": "补充的明文回显接口,多个用英文逗号分隔,必须是 http。匿名度靠「目标实际收到了哪些请求头」判定,明文路径是它的标准观测面,默认用的是上面几个接口的明文地址。如果代理够不到那些站点、导致匿名度总被判成兜底档,可以把代理能访问的回显站点填在这里", + "en": "Extra plaintext echo endpoints, comma-separated, http only. Anonymity is judged by the headers the target actually received, and plain HTTP is its standard vantage point; by default the plaintext addresses of the endpoints above are used. If your proxies cannot reach those sites and anonymity keeps falling back, list echo sites they can reach here", + }), + ("validator.ip_check_apis", { + "cn": "只返回 IP 的接口,多个用英文逗号分隔,必须是 https。上面的接口都拿不到结果时用它们兜底,同样按直连延迟逐个补发竞速,不按条数截断", + "en": "IP-only echo endpoints, comma-separated, https only. They are the fallback when the endpoints above yield nothing; likewise raced in order of direct-connect latency, with no cap on how many take part", + }), + )), + ({"cn": "抓取插件", "en": "Fetch plugins"}, ( + ("plugins.scan_interval_seconds", { + "cn": "多久检查一次有没有插件该抓取了(秒)。它只决定检查的频率,不改变各插件自己的抓取周期", + "en": "How often to check whether any plugin is due, in seconds. It only controls the checking frequency, not each plugin's own fetch interval", + }), + ("plugins.default_interval_minutes", { + "cn": "新插件默认多久抓取一次(分钟)。每个插件都可以单独调整自己的周期", + "en": "Default fetch interval for a new plugin, in minutes. Each plugin's interval can be adjusted individually", + }), + ("plugins.execution_timeout_seconds", { + "cn": "单个插件一次抓取最多跑多少秒,超时就中断", + "en": "How long a single plugin run may take, in seconds; past that it is aborted", + }), + ("plugins.request_timeout_seconds", { + "cn": "插件请求一次网页最多等多少秒;目标站点打不开时按这个时间等待", + "en": "How long a plugin waits for a single web request, in seconds; when the target site is down this is how long it waits", + }), + ("plugins.max_parallel_plugins", { + "cn": "最多同时运行几个插件", + "en": "Maximum number of plugins running at the same time", + }), + ("plugins.max_retries_on_failure", { + "cn": "插件抓取失败后额外重试几次。填 2 表示最多一共尝试 3 次", + "en": "Extra retries after a plugin run fails. A value of 2 means up to 3 attempts in total", + }), + ("plugins.retry_base_delay_seconds", { + "cn": "插件失败后重试前等待的秒数。每多试一次等待翻倍(第 1 次等 1 倍、第 2 次 2 倍、第 3 次 4 倍),避免短时间内反复打同一个站点", + "en": "Seconds to wait before retrying a failed plugin. The wait doubles each attempt (1x, 2x, 4x ...) so the same site is not hammered in quick succession", + }), + )), + ({"cn": "自动重新验证", "en": "Automatic re-validation"}, ( + ("auto_revalidation.enabled", { + "cn": "是否定时重新检查已入库的代理还能不能用", + "en": "Whether stored proxies are re-checked periodically for liveness", + }), + ("auto_revalidation.interval_minutes", { + "cn": "默认多久重新检查一遍(分钟)。每个插件可以在「插件管理」里单独设置自己的周期", + "en": "Default re-check interval in minutes. Each plugin's own interval can be set individually under Plugins", + }), + ("auto_revalidation.only_valid_proxies", { + "cn": "只检查当前可用的代理,跳过已经判定失效的", + "en": "Only check proxies currently marked valid, skipping those already known to be dead", + }), + ("auto_revalidation.run_on_start", { + "cn": "启动后马上先检查一遍,而不是等满一个周期", + "en": "Run one round right after startup instead of waiting a full interval", + }), + ("auto_revalidation.full_recheck_interval_minutes", { + "cn": "多久做一次完整检测(分钟)。日常检查只看代理还通不通;出口 IP、匿名度、协议支持这些信息很少变,隔较长时间重新测一遍即可", + "en": "How often to run a full assessment, in minutes. Routine rounds only check liveness; the exit IP, anonymity and protocol support rarely change, so re-measuring them at a longer interval is enough", + }), + )), + ({"cn": "自动清理", "en": "Automatic cleanup"}, ( + ("auto_cleanup.enabled", { + "cn": "是否自动删除质量太差的代理。删除不可恢复,拿不准时先关掉它只做检查", + "en": "Whether to delete proxies whose quality is too low. Deletion is irreversible - if unsure, turn it off and only run the checks", + }), + ("auto_cleanup.min_health_score", { + "cn": "健康分低于这个值的代理会被删除,同时还要满足下一条的次数要求", + "en": "Proxies scoring below this health value are deleted, provided the check-count requirement below is also met", + }), + ("auto_cleanup.min_checks_before_cleanup", { + "cn": "至少检查过多少次才允许按评分淘汰,避免刚入库的代理因为样本太少被误删", + "en": "How many checks a proxy must have before it can be dropped by score, so freshly stored ones are not deleted on too little evidence", + }), + ("auto_cleanup.max_failures", { + "cn": "连续失败这么多次就删除", + "en": "Delete after this many consecutive failures", + }), + ("auto_cleanup.max_age_days_if_invalid", { + "cn": "判定失效后最多再保留几天,超过就删除", + "en": "Keep a dead proxy for at most this many days before deleting it", + }), + )), + ({"cn": "使用期反馈", "en": "Use-time feedback"}, ( + ("use_feedback.enabled", { + "cn": "代理在实际转发中不通时,是否把它记为一次失败并在连续失败达阈值后标记失效。" + "检测分不清「代理坏了」和「我们自己网络抖动」,阈值就是用来吃掉后者的;" + "关掉后池只信自己的验证结果", + "en": "Whether to count a proxy that fails during real forwarding and mark it invalid " + "after enough consecutive failures. The check cannot tell a broken proxy from a " + "hiccup on our side - the threshold absorbs the latter; when off, the pool trusts " + "only its own validation results", + }), + ("use_feedback.invalid_threshold", { + "cn": "连续失败这么多次就标记失效,期间任何一次成功都会清零", + "en": "Mark the proxy invalid after this many consecutive failures; any success resets it", + }), + )), + ({"cn": "写入队列", "en": "Write queue"}, ( + ("write_queue.batch_size", { + "cn": "攒够多少条记录才一次性写进数据库", + "en": "How many records to accumulate before writing them to the database in one batch", + }), + ("write_queue.max_retries", { + "cn": "一批记录最多尝试写几次(含第一次)。填 0 或 1 都表示失败就放弃", + "en": "How many times to attempt a batch write, first attempt included. 0 and 1 both mean give up on failure", + }), + ("write_queue.max_queue_size", { + "cn": "等待写入的任务最多排多少个。排满后新任务会被拒绝,以免占用过多内存。这一项保存后不会立即生效,需重启服务(队列容量在创建时固定,中途改会丢掉已排队的数据)", + "en": "Maximum number of pending write tasks. Once full, new tasks are rejected to keep memory in check. This one does not take effect on save — restart the service (the queue capacity is fixed at construction, and changing it mid-flight would drop queued data)", + }), + )), + ({"cn": "日志", "en": "Logging"}, ( + ("logging.file_path", { + "cn": "代理池的日志文件。填相对路径时以 modules/proxypool/ 目录为起点", + "en": "The pool log file. A relative path is resolved from the modules/proxypool/ directory", + }), + ("logging.max_bytes", { + "cn": "单个日志文件的大小上限(字节)。写满后自动换一个新文件,旧文件继续保留", + "en": "Maximum size of a single log file in bytes. Once full it rotates to a new file and the old one is kept", + }), + ("logging.backup_count", { + "cn": "最多保留几个历史日志文件", + "en": "Maximum number of rotated log files to keep", + }), + )), +) + + +def render_pool_section(values: Mapping[str, str]) -> str: + effective = default_pool_section() + effective.update(values) + for deprecated_key in DEPRECATED_KEYS: + effective.pop(deprecated_key, None) + + lines = [ + "[Pool]", + "# 代理资源池配置。修改后保存即可热生效,无需重启;", + "# 也可以通过 Web 界面的「池设置」页面修改 —— 两边是同一份配置。", + "# " + "=" * 66, + "", + ] + + rendered_keys: set[str] = set() + for group_spec, fields in POOL_SECTION_LAYOUT: + group_title = _text(group_spec) + group_lines: list[str] = [] + for key, doc_spec in fields: + value = effective.get(key) + if value is None: + continue + rendered_keys.add(key) + group_lines.append(f"# {_text(doc_spec)}") + group_lines.append(f"{key} = {value}") + + if not group_lines: + continue + + if group_title: + lines.append(f"# ── {group_title} " + "─" * max(4, 56 - len(group_title) * 2)) + lines.extend(group_lines) + lines.append("") + + extras = {k: v for k, v in effective.items() if k not in rendered_keys} + if extras: + lines.append("# ── 其他 " + "─" * 56) + for key in sorted(extras): + lines.append(f"{key} = {extras[key]}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def _splice_section(ini_text: str, section: str, block: str) -> str: + section_pattern = re.compile(rf"^\s*\[{re.escape(section)}\]\s*$") + any_section_pattern = re.compile(r"^\s*\[.+\]\s*$") + + lines = ini_text.splitlines(keepends=True) + start = next((i for i, line in enumerate(lines) if section_pattern.match(line)), None) + + if start is None: + head = ini_text.rstrip("\n") + return f"{head}\n\n{block}" if head else block + + end = len(lines) + for i in range(start + 1, len(lines)): + if any_section_pattern.match(lines[i]): + end = i + break + + return "".join(lines[:start]) + block + "".join(lines[end:]) + + +def sync_pool_section_text(ini_path: str | Path, + updates: Mapping[str, str] | None = None) -> bool: + ini_path = Path(ini_path) + if not ini_path.is_file(): + return False + + try: + original = _read_config_text(ini_path) + except (OSError, UnicodeDecodeError) as e: + logger.error("读取 %s 失败,跳过 [Pool] 段排版: %s", ini_path, e) + return False + + current = read_ini_sections(ini_path).get(POOL_SECTION, {}) + if updates: + current.update(updates) + + updated = _splice_section(original, POOL_SECTION, render_pool_section(current)) + + if updated == original: + return False + + try: + _atomic_write_text(ini_path, updated) + except OSError as e: + logger.error("写入 %s 失败,[Pool] 段保持原样: %s", ini_path, e) + return False + + return True + + +def ensure_pool_section(ini_path: str | Path, yaml_path: str | Path) -> bool: + ini_path = Path(ini_path) + ini_path.parent.mkdir(parents=True, exist_ok=True) + try: + original = _read_config_text(ini_path) if ini_path.is_file() else "" + except (OSError, UnicodeDecodeError) as e: + logger.error("读取 %s 失败,池将使用默认配置: %s", ini_path, e) + return False + + known_keys = {key for key, _, _ in _iter_leaf_fields()} + existing = read_ini_sections(ini_path).get(POOL_SECTION, {}) + if any(key in known_keys for key in existing): + return sync_pool_section_text(ini_path) + + migrated = migrate_from_yaml(yaml_path) + source = "旧 config.yaml" if migrated else "默认值" + + if original: + backup_path = ini_path.with_suffix( + f".ini.bak.{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + try: + shutil.copy(ini_path, backup_path) + logger.info("原配置已备份到 %s", backup_path) + except OSError as e: + logger.error("备份 %s 失败,中止配置迁移以避免丢失原配置: %s", ini_path, e) + return False + + updated = _splice_section(original, POOL_SECTION, render_pool_section(migrated)) + + try: + _atomic_write_text(ini_path, updated) + except OSError as e: + logger.error("写入 %s 失败,池将使用默认配置: %s", ini_path, e) + return False + + logger.info("已从%s写入 %d 个池配置项到 [Pool] 段", source, len(migrated)) + + yaml_file = Path(yaml_path) + if yaml_file.is_file(): + try: + yaml_file.rename(yaml_file.with_suffix(".yaml.migrated")) + logger.info("旧配置 %s 已重命名为 .migrated", yaml_file) + except OSError as e: + logger.warning("重命名旧配置 %s 失败(不影响运行): %s", yaml_file, e) + + return True + + +def read_ini_sections(ini_path: str | Path) -> dict[str, dict[str, str]]: + from configparser import ConfigParser, Error as ConfigParserError + + parser = ini_parser() + if not Path(ini_path).is_file(): + return {} + try: + parser.read_string(_read_config_text(ini_path)) + except (OSError, UnicodeDecodeError, ConfigParserError) as e: + logger.error("读取 %s 失败: %s", ini_path, e) + return {} + + sections = {} + for section in parser.sections(): + items = dict(parser.items(section)) + if section != USERS_SECTION: + items = {key.lower(): value for key, value in items.items()} + sections[section] = items + return sections + + +def _load_pool_config(ini_path: str | Path, *, require_sections: bool) -> "Config | None": + sections = read_ini_sections(ini_path) + if require_sections and not sections: + return None + return build_pool_config( + sections.get("Server", {}), + sections.get(POOL_SECTION, {}), + strict=False, + ) + + +def load_pool_config(ini_path: str | Path) -> Config: + return _load_pool_config(ini_path, require_sections=False) + + +def reload_pool_config(ini_path: str | Path) -> "Config | None": + return _load_pool_config(ini_path, require_sections=True) + + +__all__ = [ + "POOL_SECTION", + "PoolConfigError", + "parse_pool_section", + "build_pool_config", + "dump_pool_config", + "read_ini_sections", + "load_pool_config", + "ensure_pool_section", + "sync_pool_section_text", + "normalize_pool_updates", + "describe_pool_schema", + "render_pool_section", + "reload_pool_config", + "POOL_SECTION_LAYOUT", + "POOL_FIELD_LABELS", + "should_autostart_pool", + "migrate_from_yaml", + "default_pool_section", +] diff --git a/modules/proxy_workers.py b/modules/proxy_workers.py new file mode 100644 index 0000000..f85094f --- /dev/null +++ b/modules/proxy_workers.py @@ -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', +] diff --git a/modules/proxypool/README-EN.md b/modules/proxypool/README-EN.md new file mode 100644 index 0000000..ec263a9 --- /dev/null +++ b/modules/proxypool/README-EN.md @@ -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//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//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`. diff --git a/modules/proxypool/README.md b/modules/proxypool/README.md new file mode 100644 index 0000000..0734c95 --- /dev/null +++ b/modules/proxypool/README.md @@ -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//test-url`,留空即恢复继承全局)。 +- **批量验证分两档**(`POST /api/pool/proxies/batch/validate` 的 `mode`):`liveness` 只判「还通不通」(不碰出口 IP、匿名度,也不推进完整评估时间;存活探测本身会把测试地址走通的协议记为支持),`full` 把身份与能力一并刷新,`auto` 保持原有的「按记录状态自己选」。面板的「验证有效性 / 验证全部内容」两个按钮分别对应前两者。 +- **按插件的验证策略**(`PUT /api/pool/plugins//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`。 diff --git a/modules/proxypool/__init__.py b/modules/proxypool/__init__.py new file mode 100644 index 0000000..2057114 --- /dev/null +++ b/modules/proxypool/__init__.py @@ -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"] diff --git a/modules/proxypool/core/__init__.py b/modules/proxypool/core/__init__.py new file mode 100644 index 0000000..ba05c40 --- /dev/null +++ b/modules/proxypool/core/__init__.py @@ -0,0 +1,7 @@ +""" +模块名称:modules.proxypool.core +功能描述:代理池子项目核心包的包标记,不含代码;各项能力由子模块直接提供。 +职责边界:负责:作为 core 包的标记存在;不负责:任何再导出与初始化逻辑。 +关键依赖:无。 +已知限制:本包不做再导出,导入本包不会连带加载任何子模块,使用能力须显式导入对应子模块。 +""" diff --git a/modules/proxypool/core/application/__init__.py b/modules/proxypool/core/application/__init__.py new file mode 100644 index 0000000..ab1e2f8 --- /dev/null +++ b/modules/proxypool/core/application/__init__.py @@ -0,0 +1,8 @@ +""" +模块名称:modules.proxypool.core.application +功能描述:代理池应用层的包标记,不含代码;装配入口 Application 定义在子模块 + core.application.app 中。 +职责边界:负责:作为应用层包的标记存在;不负责:任何再导出与初始化逻辑。 +关键依赖:无。 +已知限制:导入本包不会连带加载 core.application.app,也不触发任何装配流程。 +""" diff --git a/modules/proxypool/core/application/api/__init__.py b/modules/proxypool/core/application/api/__init__.py new file mode 100644 index 0000000..a6a3d2f --- /dev/null +++ b/modules/proxypool/core/application/api/__init__.py @@ -0,0 +1,9 @@ +""" +模块名称:modules.proxypool.core.application.api +功能描述:代理池 HTTP 适配层的包标记,不含代码;依赖装配见 dependencies,响应与 + 异常类型见 http_types,各资源的路由函数见 routes。 +职责边界:负责:作为适配层各子模块的包入口;不负责:路由注册与请求解析(由 + modules.proxypool_api 承担)以及业务逻辑(见 routes 下的各功能域)。 +关键依赖:无。 +已知限制:导入本包不会连带加载 dependencies、http_types 或 routes。 +""" diff --git a/modules/proxypool/core/application/api/dependencies.py b/modules/proxypool/core/application/api/dependencies.py new file mode 100644 index 0000000..e2ecacf --- /dev/null +++ b/modules/proxypool/core/application/api/dependencies.py @@ -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' + diff --git a/modules/proxypool/core/application/api/http_types.py b/modules/proxypool/core/application/api/http_types.py new file mode 100644 index 0000000..88338b1 --- /dev/null +++ b/modules/proxypool/core/application/api/http_types.py @@ -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"] diff --git a/modules/proxypool/core/application/api/routes/__init__.py b/modules/proxypool/core/application/api/routes/__init__.py new file mode 100644 index 0000000..2c6122d --- /dev/null +++ b/modules/proxypool/core/application/api/routes/__init__.py @@ -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 提供。 +""" diff --git a/modules/proxypool/core/application/api/routes/database.py b/modules/proxypool/core/application/api/routes/database.py new file mode 100644 index 0000000..647ad6a --- /dev/null +++ b/modules/proxypool/core/application/api/routes/database.py @@ -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", +] diff --git a/modules/proxypool/core/application/api/routes/geo.py b/modules/proxypool/core/application/api/routes/geo.py new file mode 100644 index 0000000..239f814 --- /dev/null +++ b/modules/proxypool/core/application/api/routes/geo.py @@ -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", +] diff --git a/modules/proxypool/core/application/api/routes/plugins.py b/modules/proxypool/core/application/api/routes/plugins.py new file mode 100644 index 0000000..82b9a08 --- /dev/null +++ b/modules/proxypool/core/application/api/routes/plugins.py @@ -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", +] diff --git a/modules/proxypool/core/application/api/routes/proxies.py b/modules/proxypool/core/application/api/routes/proxies.py new file mode 100644 index 0000000..d22329d --- /dev/null +++ b/modules/proxypool/core/application/api/routes/proxies.py @@ -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", +] diff --git a/modules/proxypool/core/application/api/routes/validation.py b/modules/proxypool/core/application/api/routes/validation.py new file mode 100644 index 0000000..02e2329 --- /dev/null +++ b/modules/proxypool/core/application/api/routes/validation.py @@ -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", +] diff --git a/modules/proxypool/core/application/app.py b/modules/proxypool/core/application/app.py new file mode 100644 index 0000000..59ae336 --- /dev/null +++ b/modules/proxypool/core/application/app.py @@ -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) diff --git a/modules/proxypool/core/application/batch_tasks.py b/modules/proxypool/core/application/batch_tasks.py new file mode 100644 index 0000000..0bc1655 --- /dev/null +++ b/modules/proxypool/core/application/batch_tasks.py @@ -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(), + } diff --git a/modules/proxypool/core/application/ingest.py b/modules/proxypool/core/application/ingest.py new file mode 100644 index 0000000..b87ea9f --- /dev/null +++ b/modules/proxypool/core/application/ingest.py @@ -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 diff --git a/modules/proxypool/core/backup.py b/modules/proxypool/core/backup.py new file mode 100644 index 0000000..d75d508 --- /dev/null +++ b/modules/proxypool/core/backup.py @@ -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 diff --git a/modules/proxypool/core/config.py b/modules/proxypool/core/config.py new file mode 100644 index 0000000..1bf4adb --- /dev/null +++ b/modules/proxypool/core/config.py @@ -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 diff --git a/modules/proxypool/core/data/__init__.py b/modules/proxypool/core/data/__init__.py new file mode 100644 index 0000000..550eaca --- /dev/null +++ b/modules/proxypool/core/data/__init__.py @@ -0,0 +1,8 @@ +""" +模块名称:modules.proxypool.core.data +功能描述:数据访问层的包入口:仓储与写入队列分别定义在 core.data.repositories 与 core.data.write_queue; + 本文件不含代码,仅作包标记。 +职责边界:负责:作为 core.data 包的标记存在;不负责:任何再导出,使用方按各自的子模块路径导入。 +关键依赖:无。 +已知限制:无。 +""" diff --git a/modules/proxypool/core/data/repositories.py b/modules/proxypool/core/data/repositories.py new file mode 100644 index 0000000..3bc2d05 --- /dev/null +++ b/modules/proxypool/core/data/repositories.py @@ -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) diff --git a/modules/proxypool/core/data/write_queue.py b/modules/proxypool/core/data/write_queue.py new file mode 100644 index 0000000..b061dc0 --- /dev/null +++ b/modules/proxypool/core/data/write_queue.py @@ -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"), + ) diff --git a/modules/proxypool/core/data_cleaner.py b/modules/proxypool/core/data_cleaner.py new file mode 100644 index 0000000..cabba0f --- /dev/null +++ b/modules/proxypool/core/data_cleaner.py @@ -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 diff --git a/modules/proxypool/core/database.py b/modules/proxypool/core/database.py new file mode 100644 index 0000000..f211173 --- /dev/null +++ b/modules/proxypool/core/database.py @@ -0,0 +1,161 @@ +""" +模块名称:modules.proxypool.core.database +功能描述:SQLite 建表、建索引与幂等的列迁移,应用启动初始化时调用一次,确保库结构与当前代码期望的字段一致。 +职责边界:负责:创建 proxies 与 plugin_configs 表及索引、为旧库补新增列、按谓词收敛 + 废弃取值;不负责:数据读写(见 core.data.repositories)与连接生命周期管理 + (见 core.infrastructure.connection_pool)。 +关键依赖:aiosqlite。 +已知限制: + 1. 迁移只做新增列与按谓词收敛既有取值,不支持改类型或删列;收敛谓词必须幂等。 + 2. 建表、建索引或末尾提交失败会抛出;补列与取值收敛一段的失败只记 warning,缺列由后续查询的失败暴露。 + 3. 列默认值承载语义:anonymity_level='unverified' 表示从未检测过,anonymity_fallback=0 表示历史行或未验证行。 + 4. 英文归属地列与 country_code / subdivision_code / geo_source 刻意不带 DEFAULT,NULL 表示尚未查询。 + 5. 历史行由 'unknown' 收敛为 'transparent' 时须同时置 anonymity_fallback=1 并清空 quality_assessed_at,否则不会被重新检测。 + 6. plugin_configs 默认值是契约:reval_interval_minutes=0 与 test_url='' 表示继承全局值,skip_validation=0 表示照常验证。 +""" + +import aiosqlite +import logging + +logger = logging.getLogger(__name__) + + +async def init_database(db_path: str) -> None: + async with aiosqlite.connect(db_path) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + protocol TEXT NOT NULL, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + username TEXT, + password TEXT, + region TEXT DEFAULT '未知', + country TEXT DEFAULT '未知', + province TEXT DEFAULT '未知', + city TEXT DEFAULT '未知', + region_en TEXT, + country_en TEXT, + province_en TEXT, + city_en TEXT, + delay_ms REAL, + is_valid BOOLEAN DEFAULT 1, + real_ip TEXT, + source_plugin TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + validated_at TIMESTAMP, + failure_count INTEGER DEFAULT 0, + UNIQUE(ip, port) + ) + """) + + try: + cursor = await db.execute("PRAGMA table_info(proxies)") + columns = await cursor.fetchall() + column_names = [col[1] for col in columns] + + if 'failure_count' not in column_names: + await db.execute("ALTER TABLE proxies ADD COLUMN failure_count INTEGER DEFAULT 0") + + if 'is_favorite' not in column_names: + await db.execute("ALTER TABLE proxies ADD COLUMN is_favorite INTEGER DEFAULT 0") + + if 'username' not in column_names: + await db.execute("ALTER TABLE proxies ADD COLUMN username TEXT") + + if 'password' not in column_names: + await db.execute("ALTER TABLE proxies ADD COLUMN password TEXT") + + migration_columns = { + 'health_score': 'REAL DEFAULT 0.0', + 'success_count': 'INTEGER DEFAULT 0', + 'total_checks': 'INTEGER DEFAULT 0', + 'anonymity_level': "TEXT DEFAULT 'unverified'", + 'anonymity_fallback': 'INTEGER DEFAULT 0', + 'supports_https': 'INTEGER DEFAULT 0', + 'avg_delay_ms': 'REAL', + 'supports_http': 'INTEGER DEFAULT 0', + 'quality_assessed_at': 'TIMESTAMP', + 'delay_variance': 'REAL', + 'last_valid_at': 'TIMESTAMP', + 'total_valid_seconds': 'REAL DEFAULT 0.0', + 'probe_success_count': 'INTEGER DEFAULT 0', + 'probe_total_count': 'INTEGER DEFAULT 0', + 'region_en': 'TEXT', + 'country_en': 'TEXT', + 'province_en': 'TEXT', + 'city_en': 'TEXT', + 'country_code': 'TEXT', + 'subdivision_code': 'TEXT', + 'geo_source': 'TEXT', + } + for col_name, col_def in migration_columns.items(): + if col_name not in column_names: + await db.execute(f"ALTER TABLE proxies ADD COLUMN {col_name} {col_def}") + logger.info(f"数据库迁移: 新增列 {col_name}") + + cursor = await db.execute( + "UPDATE proxies SET anonymity_level = 'transparent'," + " anonymity_fallback = 1, quality_assessed_at = NULL" + " WHERE anonymity_level = 'unknown'" + ) + if cursor.rowcount: + logger.info( + "数据库迁移: %d 条历史记录的匿名度由 unknown 收敛为 transparent" + "(已清空完整评估时间,下轮将重新检测)", cursor.rowcount + ) + + except Exception as e: + logger.warning(f"数据库迁移检查失败(非致命): {e}") + + await db.execute("CREATE INDEX IF NOT EXISTS idx_ip ON proxies(ip)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_protocol ON proxies(protocol)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_is_valid ON proxies(is_valid)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_region ON proxies(region)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_delay ON proxies(delay_ms)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_source ON proxies(source_plugin)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_failure_count ON proxies(failure_count)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_is_favorite ON proxies(is_favorite)") + + await db.execute("CREATE INDEX IF NOT EXISTS idx_health_score ON proxies(health_score)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_anonymity ON proxies(anonymity_level)") + await db.execute("CREATE INDEX IF NOT EXISTS idx_supports_https ON proxies(supports_https)") + + await db.execute(""" + CREATE TABLE IF NOT EXISTS plugin_configs ( + name TEXT PRIMARY KEY, + enabled BOOLEAN DEFAULT 1, + interval_minutes INTEGER DEFAULT 60, + last_run TIMESTAMP, + next_run TIMESTAMP, + reval_enabled BOOLEAN DEFAULT 1, + reval_interval_minutes INTEGER DEFAULT 0, + test_url TEXT DEFAULT '', + skip_validation BOOLEAN DEFAULT 0 + ) + """) + + try: + cursor = await db.execute("PRAGMA table_info(plugin_configs)") + plugin_columns = [col[1] for col in await cursor.fetchall()] + + plugin_migration_columns = { + 'reval_enabled': 'BOOLEAN DEFAULT 1', + 'reval_interval_minutes': 'INTEGER DEFAULT 0', + 'test_url': "TEXT DEFAULT ''", + 'skip_validation': 'BOOLEAN DEFAULT 0', + } + for col_name, col_def in plugin_migration_columns.items(): + if col_name not in plugin_columns: + await db.execute( + f"ALTER TABLE plugin_configs ADD COLUMN {col_name} {col_def}" + ) + logger.info(f"数据库迁移: plugin_configs 新增列 {col_name}") + + except Exception as e: + logger.warning(f"plugin_configs 迁移检查失败(非致命): {e}") + + await db.commit() + + +__all__ = ['init_database'] diff --git a/modules/proxypool/core/domain/__init__.py b/modules/proxypool/core/domain/__init__.py new file mode 100644 index 0000000..fcf8fa7 --- /dev/null +++ b/modules/proxypool/core/domain/__init__.py @@ -0,0 +1,8 @@ +""" +模块名称:modules.proxypool.core.domain +功能描述:领域层的包入口:领域模型与异常类型分别定义在 core.domain.models 与 core.domain.exceptions; + 本文件不含代码,仅作包标记。 +职责边界:负责:作为 core.domain 包的标记存在;不负责:任何再导出,使用方按各自的子模块路径导入。 +关键依赖:无。 +已知限制:无。 +""" diff --git a/modules/proxypool/core/domain/exceptions.py b/modules/proxypool/core/domain/exceptions.py new file mode 100644 index 0000000..5905a82 --- /dev/null +++ b/modules/proxypool/core/domain/exceptions.py @@ -0,0 +1,94 @@ +""" +模块名称:modules.proxypool.core.domain.exceptions +功能描述:代理池全域的异常类型定义:所有异常源于 ProxyPoolException,按出错层次分族,覆盖数据访问、依赖注入、插件、参数、验证、HTTP 客户端、写队列与应用编排。 +职责边界:负责:异常类型定义及少数类型携带的结构化信息(reason 失败分类、文案键与实参);不负责:抛出的时机与捕获策略(各业务模块自定)、错误文案的渲染(由 Web 边界完成)。 +关键依赖:无 +已知限制: + 1. 异常不携带 HTTP 状态码,路由层要映射状态码只能另行判断。 + 2. message_key 为空时按普通异常处理,message 原样透出。 + 3. InvalidParameterException 同时继承 ValueError 是有意的:适配层按 ValueError 映射成 400 invalid_parameter。 + 4. HttpClientException.reason 是机器可读的失败分类(proxy_connect / connect_timeout 等),调用方按它分支,不要解析 message。 + 5. ProxyValidationException 无任何抛出点,仅登记在 Web 适配层异常映射表;验证结论(含无结论)均由结果对象表达,不抛异常。 + 6. WriteQueueFullException 是背压信号而非故障;WriteQueueException 也用于启停失败,批次重试用尽后由消费者丢弃并计数、不向写入方抛出;队列不做重投。 +""" + +from typing import Any + + +class ProxyPoolException(Exception): + message_key: str = "" + message_args: tuple = () + + def with_message_key(self, key: str, *args) -> "ProxyPoolException": + self.message_key = key + self.message_args = args + return self + + +class RepositoryException(ProxyPoolException): + pass + + +class DataValidationException(RepositoryException): + pass + + +class DatabaseConnectionException(RepositoryException): + pass + + +class InvalidParameterException(ProxyPoolException, ValueError): + def __init__(self, key: str, *args: Any): + self.message_key = key + self.message_args = args + super().__init__(f"{key} {args}" if args else key) + + +class QueryBuildException(RepositoryException): + pass + + +class DependencyInjectionException(ProxyPoolException): + pass + + +class DependencyNotRegisteredError(DependencyInjectionException): + pass + + +class PluginException(ProxyPoolException): + pass + + +class PluginExecutionException(PluginException): + pass + + +class ProxyValidationException(ProxyPoolException): + pass + + +class HttpClientException(ProxyPoolException): + def __init__(self, message: str = "", reason: str = "unknown"): + super().__init__(message) + self.reason = reason + + +class HttpRequestException(HttpClientException): + pass + + +class HttpTimeoutException(HttpClientException): + pass + + +class WriteQueueException(ProxyPoolException): + pass + + +class WriteQueueFullException(WriteQueueException): + pass + + +class ApplicationException(ProxyPoolException): + pass diff --git a/modules/proxypool/core/domain/models.py b/modules/proxypool/core/domain/models.py new file mode 100644 index 0000000..ba5cd20 --- /dev/null +++ b/modules/proxypool/core/domain/models.py @@ -0,0 +1,355 @@ +""" +模块名称:modules.proxypool.core.domain.models +功能描述:代理池的领域模型与字段取值契约:定义代理记录、验证结论、探测结果、插件配置与归属地等数据结构, + 以及成功率、存活占比、出口 IP 是否与入口不同等派生只读量。 +职责边界:负责:数据结构定义、字段语义与派生计算、URL 与对象的互转(Proxy.from_url);不负责: + 持久化、验证算法与健康评分、任何 I/O(分别见 core.data.repositories、core.services)。 +关键依赖:仅标准库。 +已知限制: +1. 模型是进程内普通对象,无并发保护,跨线程共享需调用方自行加锁;时间字段为 naive 本地时间,与 aware 时间混用会抛 TypeError。 +2. 中文列用「未知」表示查过但查不到;Proxy 的英文列与 code 列用 None 表示尚未查询,GeoLocation/GeoRecord 的 country_code 用空串表示未查到。 +3. 写归属地前须先看 is_known:全「未知」的 GeoLocation/GeoName 会覆盖库里已有值。 +4. ProxyValidationResult.is_valid 只由 ValidationOutcome.OK 派生;skip_validation 插件跳过验证直接入库 is_valid=1。 +5. ProbeOutcome.ok 的 None(未测成)必须与 False(确实失败)分开,误判会拉低整池成功率。 +6. exit_ip_differs 与 canonical_ip 按 ipaddress 规范化后比较(折叠 IPv4-mapped IPv6),字段缺失或解析失败时返回 False。 +7. Proxy.from_url 只接受点分四段 IPv4 字面量与必填端口;proxy_url 仅在用户名与密码同时存在时注入认证信息。 +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Mapping, Optional +from urllib.parse import urlparse +import ipaddress +import re + + +def canonical_ip(value: str) -> Optional[str]: + try: + addr = ipaddress.ip_address(value.strip()) + except (ValueError, AttributeError): + return None + mapped = getattr(addr, "ipv4_mapped", None) + return str(mapped or addr) + + +ANONYMITY_LEVELS = ('transparent', 'anonymous', 'elite', 'unverified') + +SUPPORTED_PROTOCOLS = ('http', 'https', 'socks5') + + +class ValidationOutcome(str, Enum): + OK = "ok" + PROXY_UNREACHABLE = "proxy_unreachable" + PROXY_FAILED = "proxy_failed" + INCONCLUSIVE = "inconclusive" + + +@dataclass +class Proxy: + id: Optional[int] = None + protocol: str = "" + ip: str = "" + port: int = 0 + username: Optional[str] = None + password: Optional[str] = None + region: str = "未知" + country: str = "未知" + province: str = "未知" + city: str = "未知" + region_en: Optional[str] = None + country_en: Optional[str] = None + province_en: Optional[str] = None + city_en: Optional[str] = None + country_code: Optional[str] = None + subdivision_code: Optional[str] = None + geo_source: Optional[str] = None + delay_ms: Optional[float] = None + is_valid: bool = True + is_favorite: bool = False + failure_count: int = 0 + real_ip: Optional[str] = None + source_plugin: str = "" + created_at: Optional[datetime] = None + validated_at: Optional[datetime] = None + health_score: float = 0.0 + success_count: int = 0 + total_checks: int = 0 + anonymity_level: str = "unverified" + anonymity_fallback: bool = False + supports_https: bool = False + supports_http: bool = False + quality_assessed_at: Optional[datetime] = None + avg_delay_ms: Optional[float] = None + delay_variance: Optional[float] = None + last_valid_at: Optional[datetime] = None + total_valid_seconds: float = 0.0 + probe_success_count: int = 0 + probe_total_count: int = 0 + + @property + def success_rate(self) -> float: + if self.total_checks == 0: + return 0.0 + return self.success_count / self.total_checks + + @property + def probe_success_rate(self) -> float: + if self.probe_total_count <= 0: + return 0.0 + return self.probe_success_count / self.probe_total_count + + @property + def age_hours(self) -> float: + if not self.created_at: + return 0.0 + delta = datetime.now() - self.created_at + return delta.total_seconds() / 3600.0 + + @property + def uptime_ratio(self) -> float: + age_seconds = self.age_hours * 3600.0 + if age_seconds <= 0: + return 0.0 + ratio = self.total_valid_seconds / age_seconds + return max(0.0, min(1.0, ratio)) + + @property + def proxy_url(self) -> str: + if self.username and self.password: + return f"{self.protocol}://{self.username}:{self.password}@{self.ip}:{self.port}" + return f"{self.protocol}://{self.ip}:{self.port}" + + @property + def exit_ip_differs(self) -> bool: + if not self.ip or not self.real_ip: + return False + entry = canonical_ip(self.ip) + exit_address = canonical_ip(self.real_ip) + if entry is None or exit_address is None: + return False + return entry != exit_address + + @staticmethod + def from_url(url: str, source_plugin: str) -> "Proxy": + try: + parsed = urlparse(url) + + if not parsed.scheme: + raise ValueError(f"缺少协议: {url}") + + if not parsed.hostname: + raise ValueError(f"缺少主机名: {url}") + + if not parsed.port: + raise ValueError(f"缺少端口: {url}") + + protocol = parsed.scheme.lower() + if protocol not in SUPPORTED_PROTOCOLS: + raise ValueError(f"不支持的协议: {protocol}") + + ip = parsed.hostname + ip_pattern = r'^(\d{1,3}\.){3}\d{1,3}$' + if not re.match(ip_pattern, ip): + raise ValueError(f"无效的 IP 地址: {ip}") + + parts = ip.split('.') + for part in parts: + if int(part) > 255: + raise ValueError(f"无效的 IP 地址: {ip}") + + port = parsed.port + if not (1 <= port <= 65535): + raise ValueError(f"无效的端口: {port}") + + username = parsed.username + password = parsed.password + + return Proxy( + protocol=protocol, + ip=ip, + port=port, + username=username, + password=password, + source_plugin=source_plugin + ) + except Exception as e: + raise ValueError(f"解析代理 URL 失败 {url}: {str(e)}") + + + +@dataclass +class PluginConfig: + name: str + enabled: bool = True + interval_minutes: int = 60 + last_run: Optional[datetime] = None + next_run: Optional[datetime] = None + reval_enabled: bool = True + reval_interval_minutes: int = 0 + test_url: str = "" + skip_validation: bool = False + + +@dataclass +class ProxyFilter: + protocol: Optional[str] = None + region: Optional[str] = None + min_delay: Optional[int] = None + max_delay: Optional[int] = None + is_valid: Optional[bool] = 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 + + +@dataclass +class DBOperation: + operation_type: str + table: str + data: dict | list[dict] + where: Optional[dict] = None + + +@dataclass +class HttpResponse: + status: int + text: str + headers: dict + + +class ValidationMode(str, Enum): + LIVENESS = "liveness" + IDENTITY = "identity" + FULL = "full" + + +class ProbeKind(str, Enum): + LIVENESS = "liveness" + TUNNEL = "tunnel" + PLAIN_HTTP = "plain_http" + + +@dataclass(frozen=True) +class ProbeOutcome: + kind: ProbeKind + ok: Optional[bool] + latency_ms: Optional[float] = None + reason: Optional[str] = None + + +@dataclass +class ProxyValidationResult: + proxy_url: str + outcome: ValidationOutcome + validated_at: datetime + delay_ms: Optional[float] = None + error_message: Optional[str] = None + real_ip: Optional[str] = None + anonymity_level: Optional[str] = None + anonymity_fallback: bool = False + supports_https: Optional[bool] = None + supports_http: Optional[bool] = None + error_reason: Optional[str] = None + probes: tuple[ProbeOutcome, ...] = () + + @property + def is_valid(self) -> bool: + return self.outcome is ValidationOutcome.OK + + @property + def is_conclusive(self) -> bool: + return self.outcome is not ValidationOutcome.INCONCLUSIVE + + @property + def probe_samples(self) -> tuple[int, int]: + conclusive = [p for p in self.probes if p.ok is not None] + return sum(1 for p in conclusive if p.ok), len(conclusive) + + @property + def fully_assessed(self) -> bool: + kinds = {p.kind for p in self.probes} + if not {ProbeKind.LIVENESS, ProbeKind.TUNNEL} <= kinds: + return False + return all(p.ok is not None for p in self.probes) + + +@dataclass +class GeoName: + country: str = "未知" + province: str = "未知" + city: str = "未知" + + def to_string(self) -> str: + parts = [self.country] + if self.province != "未知": + parts.append(self.province) + if self.city != "未知": + parts.append(self.city) + return " ".join(parts) + + @property + def is_known(self) -> bool: + return any( + part and part != "未知" for part in (self.country, self.province, self.city) + ) + + +@dataclass +class GeoLocation: + zh: GeoName = field(default_factory=GeoName) + en: GeoName = field(default_factory=GeoName) + + country_code: str = "" + subdivision_code: str = "" + source: str = "" + untranslated: Mapping[str, bool] = field(default_factory=dict) + + def to_string(self) -> str: + return self.zh.to_string() + + def to_string_en(self) -> str: + return self.en.to_string() + + @property + def is_known(self) -> bool: + return self.zh.is_known or self.en.is_known + + @property + def is_complete(self) -> bool: + return self.zh.is_known and self.en.is_known + + +@dataclass(frozen=True) +class GeoRecord: + country_code: str = "" + subdivision_code: str = "" + country_names: Mapping[str, str] = field(default_factory=dict) + subdivision_names: Mapping[str, str] = field(default_factory=dict) + city_names: Mapping[str, str] = field(default_factory=dict) + source: str = "" + + @property + def is_china(self) -> bool: + return self.country_code == "CN" + + +@dataclass +class PluginStatus: + name: str + enabled: bool + interval_minutes: int + last_run: Optional[datetime] + next_run: Optional[datetime] + last_error: Optional[str] = None + last_error_key: Optional[str] = None + is_loaded: bool = False + test_url: str = "" + reval_enabled: bool = True + reval_interval_minutes: int = 0 + skip_validation: bool = False diff --git a/modules/proxypool/core/infrastructure/__init__.py b/modules/proxypool/core/infrastructure/__init__.py new file mode 100644 index 0000000..a5b5b9a --- /dev/null +++ b/modules/proxypool/core/infrastructure/__init__.py @@ -0,0 +1,8 @@ +""" +模块名称:modules.proxypool.core.infrastructure +功能描述:基础设施层的包入口:连接池、去重缓存、配置管理器、依赖注入容器、归属地库、HTTP 客户端等实现分别定义在各自子模块;本文件不含代码,仅作包标记。 +职责边界:负责:作为 core.infrastructure 包的标记存在;不负责:任何再导出,使用方按子模块路径导入 + (如 core.infrastructure.http_client 的 AioHttpClient)。 +关键依赖:无。 +已知限制:无。 +""" diff --git a/modules/proxypool/core/infrastructure/config_manager.py b/modules/proxypool/core/infrastructure/config_manager.py new file mode 100644 index 0000000..f83ccb6 --- /dev/null +++ b/modules/proxypool/core/infrastructure/config_manager.py @@ -0,0 +1,58 @@ +""" +模块名称:modules.proxypool.core.infrastructure.config_manager +功能描述:配置的内存态与变更通知:持有当前生效的 Config 对象,登记订阅者,并在 apply 时把新配置推送给全部订阅者。 +职责边界:负责:保存当前生效的 Config、登记订阅者、分发变更通知;不负责:配置文件的读写与持久化 + (见 modules.pool_config_ini)、配置字典到 Config 的解析(见 core.config.build_config)。 +关键依赖:core.config.Config、core.interfaces.infrastructure.IConfigManager;标准库 asyncio、logging。 +已知限制: + 1. get_config 返回内部持有的同一对象而非副本,调用方不得就地修改;改配置必须走 apply(),否则订阅者收不到通知。 + 2. subscribe 按相等性去重,等值回调只登记一次;订阅后无法注销。 + 3. 订阅者回调的异常被逐个捕获并记日志,不阻断其余订阅者,也不会从 apply() 抛出。 + 4. apply() 先替换配置再通知,回调内 get_config() 读到的已是新配置;apply() 无并发保护,并发调用会相互覆盖。 + 5. 回调按注册顺序串行等待,某个异步回调耗时过长会推迟其后订阅者的通知。 + 6. 通知遍历订阅者列表期间新增的订阅者会在本轮通知中被调用。 +""" + +import asyncio +import logging +from typing import Callable, List + +from core.config import Config +from core.interfaces.infrastructure import IConfigManager + +logger = logging.getLogger(__name__) + + +class ConfigManager(IConfigManager): + def __init__(self, config: Config): + self._config: Config = config + self._subscribers: List[Callable[[Config], None]] = [] + logger.info("配置管理器已初始化") + + def get_config(self) -> Config: + return self._config + + async def apply(self, new_config: Config) -> None: + logger.info("开始应用新配置") + self._config = new_config + await self._notify_subscribers(new_config) + logger.info("新配置已应用") + + def subscribe(self, callback: Callable[[Config], None]) -> None: + if callback not in self._subscribers: + self._subscribers.append(callback) + name = callback.__name__ if hasattr(callback, '__name__') else str(callback) + logger.info(f"已订阅配置变更通知: {name}") + + async def _notify_subscribers(self, new_config: Config) -> None: + logger.info(f"通知 {len(self._subscribers)} 个订阅者配置已变更") + + for callback in self._subscribers: + try: + if asyncio.iscoroutinefunction(callback): + await callback(new_config) + else: + callback(new_config) + except Exception as e: + name = callback.__name__ if hasattr(callback, '__name__') else str(callback) + logger.error(f"配置变更回调执行失败 {name}: {e}", exc_info=True) diff --git a/modules/proxypool/core/infrastructure/connection_pool.py b/modules/proxypool/core/infrastructure/connection_pool.py new file mode 100644 index 0000000..8af2a7b --- /dev/null +++ b/modules/proxypool/core/infrastructure/connection_pool.py @@ -0,0 +1,169 @@ +""" +模块名称:modules.proxypool.core.infrastructure.connection_pool +功能描述:SQLite 异步连接池(WAL 模式)。写连接固定 1 个并由 asyncio.Lock 串行化,读连接按 max_readers 池化、经 asyncio.Queue 分发。 +连接在 initialize() 时一次性建立并长期复用;写连接不并行是因为 SQLite 写操作文件级互斥,多开只会互相阻塞。 +职责边界:负责:连接生命周期与借出归还、PRAGMA 调优、启动时清理 WAL 孤儿文件、关闭时关停池中的读连接与写连接。 +不负责:SQL 构造与执行、事务之上的业务语义(见 core.data.repositories)、数据库文件创建与建表(见 core.database)。 +关键依赖:aiosqlite(第三方异步 SQLite 驱动)。 +已知限制: +1. 使用前必须显式 await initialize();未初始化时任何 acquire 抛 RuntimeError,初始化失败不回收已建连接,应丢弃实例重建。 +2. 读连接归还后被下一个借用者复用,块内不得留下未读完的游标或未结束的事务;池空时阻塞等待且无超时。 +3. 读连接预设 row_factory=aiosqlite.Row 与 PRAGMA query_only=ON,任何写入抛 OperationalError。 +4. acquire_writer 的整个 async with 块同属一个事务:正常退出自动 commit,异常退出自动 rollback。 +5. 回滚捕获 BaseException:任务取消时若不回滚,事务保持打开而锁已释放,下一个持锁者提交会带上半截写入。 +6. close() 等待借出的读连接归还,上限 30 秒;超时只关闭池中的读连接,之后归还的读连接无人关闭;实例须在单一事件循环内使用,WAL 孤儿文件被占用时跳过清理。 +""" + +import asyncio +import aiosqlite +import logging +import os +from contextlib import asynccontextmanager + +logger = logging.getLogger(__name__) + + +class ConnectionPool: + def __init__(self, db_path: str, max_readers: int = 4): + self._db_path = db_path + self._max_readers = max_readers + + self._writer: aiosqlite.Connection | None = None + self._write_lock = asyncio.Lock() + + self._reader_pool: asyncio.Queue[aiosqlite.Connection] = asyncio.Queue() + + self._outstanding_readers: int = 0 + self._all_returned: asyncio.Event = asyncio.Event() + self._all_returned.set() + self._closing: bool = False + + self._initialized = False + + @property + def db_path(self) -> str: + return self._db_path + + async def initialize(self) -> None: + if self._initialized: + logger.debug("连接池已初始化,跳过重复调用") + return + + wal_path = self._db_path + "-wal" + if os.path.exists(wal_path): + try: + tmp = await aiosqlite.connect(self._db_path) + await tmp.execute("PRAGMA busy_timeout=2000") + await tmp.execute("PRAGMA wal_checkpoint(TRUNCATE)") + await tmp.close() + logger.info("WAL 孤儿文件已清理: %s", wal_path) + except Exception: + logger.debug("WAL 文件被占用或无法访问,跳过清理: %s", wal_path) + + self._writer = await aiosqlite.connect(self._db_path) + await self._writer.execute("PRAGMA journal_mode=WAL") + await self._writer.execute("PRAGMA synchronous=NORMAL") + await self._writer.execute("PRAGMA cache_size=-8192") + await self._writer.execute("PRAGMA busy_timeout=5000") + await self._writer.execute("PRAGMA mmap_size=67108864") + + for i in range(self._max_readers): + reader = await aiosqlite.connect(self._db_path) + await reader.execute("PRAGMA journal_mode=WAL") + await reader.execute("PRAGMA cache_size=-4096") + await reader.execute("PRAGMA busy_timeout=5000") + await reader.execute("PRAGMA mmap_size=67108864") + await reader.execute("PRAGMA query_only=ON") + reader.row_factory = aiosqlite.Row + await self._reader_pool.put(reader) + + self._closing = False + self._outstanding_readers = 0 + self._all_returned.set() + + self._initialized = True + logger.info( + f"连接池已初始化: db={self._db_path}, " + f"writer=1, readers={self._max_readers}, mode=WAL" + ) + + @asynccontextmanager + async def acquire_reader(self): + if not self._initialized: + raise RuntimeError("连接池尚未初始化,请先调用 initialize()") + + if self._closing: + raise RuntimeError("连接池正在关闭,无法获取读连接") + + conn = await self._reader_pool.get() + + self._outstanding_readers += 1 + self._all_returned.clear() + + try: + yield conn + finally: + await self._reader_pool.put(conn) + + self._outstanding_readers -= 1 + if self._outstanding_readers <= 0: + self._outstanding_readers = 0 + self._all_returned.set() + + @asynccontextmanager + async def acquire_writer(self): + if not self._initialized: + raise RuntimeError("连接池尚未初始化,请先调用 initialize()") + + async with self._write_lock: + try: + yield self._writer + await self._writer.commit() + except BaseException: + await self._writer.rollback() + raise + + async def close(self) -> None: + if not self._initialized: + return + + self._closing = True + + if self._outstanding_readers > 0: + logger.info( + f"等待 {self._outstanding_readers} 个借出的读连接归还..." + ) + try: + await asyncio.wait_for(self._all_returned.wait(), timeout=30.0) + except asyncio.TimeoutError: + logger.warning( + f"等待读连接归还超时(30秒)," + f"仍有 {self._outstanding_readers} 个读连接未归还,强制关闭" + ) + + closed_readers = 0 + while not self._reader_pool.empty(): + try: + conn = self._reader_pool.get_nowait() + await conn.close() + closed_readers += 1 + except asyncio.QueueEmpty: + break + except Exception as e: + logger.warning(f"关闭读连接时出错: {e}") + + if self._writer: + try: + await self._writer.close() + except Exception as e: + logger.warning(f"关闭写连接时出错: {e}") + self._writer = None + + self._initialized = False + self._closing = False + self._outstanding_readers = 0 + self._all_returned.set() + + logger.info( + f"连接池已关闭: 回收读连接 {closed_readers} 个, 写连接 1 个" + ) diff --git a/modules/proxypool/core/infrastructure/dedup_cache.py b/modules/proxypool/core/infrastructure/dedup_cache.py new file mode 100644 index 0000000..1330397 --- /dev/null +++ b/modules/proxypool/core/infrastructure/dedup_cache.py @@ -0,0 +1,65 @@ +""" +模块名称:modules.proxypool.core.infrastructure.dedup_cache +功能描述:代理去重的内存索引,键为 "ip:port";抓取流程在入库前用它判断代理是否已存在。键必须带端口: + 数据库对 (ip, port) 建了唯一约束,同一 IP 的不同端口是两个不同的代理,只按 IP 去重会误丢。 +职责边界:负责:维护全量 ip:port 集合与 contains 判断、批量增删;不负责:数据库写入(写队列落库后由调用方同步)、入库规则(见 PluginManager)、跨进程一致性。 +关键依赖:ConnectionPool(加载与重建时取读连接)。 +已知限制: +1. 纯内存,多进程部署时每个进程各持一份,只能靠 rebuild() 周期性纠偏。 +2. 未初始化时集合为空,contains() 会把所有代理判为不存在;判断前先看 is_initialized,漏判由数据库 (ip, port) 唯一约束兜底。 +3. contains() 同步且不加锁,依赖集合替换与增删的原子性;增删在 asyncio.Lock 内异步完成。 +4. rebuild() 不改变 is_initialized,只用于初始化之后的漂移纠偏。 +5. load_from_db() 抛异常时缓存内容与状态都不变。 +""" + +import asyncio +import logging +from typing import Set + +logger = logging.getLogger(__name__) + + +class DedupCache: + def __init__(self): + self._cache: Set[str] = set() + self._lock = asyncio.Lock() + self._initialized = False + + @property + def is_initialized(self) -> bool: + return self._initialized + + async def load_from_db(self, connection_pool: 'ConnectionPool') -> None: + await self._reload_from_db(connection_pool, mark_initialized=True) + logger.info(f"去重缓存已加载: {len(self._cache)} 个 ip:port 对") + + def contains(self, ip: str, port: int) -> bool: + return f"{ip}:{port}" in self._cache + + async def add_batch(self, pairs: list[tuple[str, int]]) -> None: + if not pairs: + return + async with self._lock: + for ip, port in pairs: + self._cache.add(f"{ip}:{port}") + + async def remove_batch(self, pairs: list[tuple[str, int]]) -> None: + if not pairs: + return + async with self._lock: + for ip, port in pairs: + self._cache.discard(f"{ip}:{port}") + + async def rebuild(self, connection_pool: 'ConnectionPool') -> None: + await self._reload_from_db(connection_pool, mark_initialized=False) + logger.info(f"去重缓存已重建: {len(self._cache)} 个 ip:port 对") + + async def _reload_from_db(self, connection_pool: 'ConnectionPool', + *, mark_initialized: bool) -> None: + async with connection_pool.acquire_reader() as db: + async with db.execute("SELECT ip, port FROM proxies") as cursor: + rows = await cursor.fetchall() + async with self._lock: + self._cache = {f"{row[0]}:{row[1]}" for row in rows} + if mark_initialized: + self._initialized = True diff --git a/modules/proxypool/core/infrastructure/di_container.py b/modules/proxypool/core/infrastructure/di_container.py new file mode 100644 index 0000000..e0523c7 --- /dev/null +++ b/modules/proxypool/core/infrastructure/di_container.py @@ -0,0 +1,36 @@ +""" +模块名称:modules.proxypool.core.infrastructure.di_container +功能描述:极简依赖注入容器,登记 Application 创建好的服务实例,供路由层按接口类型取用。 +职责边界:负责:保存接口到实例的绑定并按接口类型解析出实例;不负责:实例的创建与生命周期,由 core.application.app.Application 统一创建后注册。 +关键依赖:core.domain.exceptions。 +已知限制: +1. 只支持注册现成实例这一种绑定方式,重复注册同一接口会抛 DependencyInjectionException。 +2. resolve 按类型精确匹配,不做子类匹配;未注册的接口会抛 DependencyNotRegisteredError。 +3. 容器不创建也不销毁实例,resolve 返回的始终是注册时传入的那个对象。 +""" + +from typing import Any, Dict, Type +from core.domain.exceptions import ( + DependencyNotRegisteredError, + DependencyInjectionException +) + + +class DIContainer: + def __init__(self): + self._singletons: Dict[Type, Any] = {} + + def register_instance(self, interface: Type, instance: Any) -> None: + if interface in self._singletons: + raise DependencyInjectionException( + f"接口 {interface.__name__} 已经注册" + ) + self._singletons[interface] = instance + + def resolve(self, interface: Type) -> Any: + if interface not in self._singletons: + raise DependencyNotRegisteredError( + f"未注册的依赖: {interface.__name__}" + ) + + return self._singletons[interface] diff --git a/modules/proxypool/core/infrastructure/geodb.py b/modules/proxypool/core/infrastructure/geodb.py new file mode 100644 index 0000000..62e84ce --- /dev/null +++ b/modules/proxypool/core/infrastructure/geodb.py @@ -0,0 +1,369 @@ +""" +模块名称:modules.proxypool.core.infrastructure.geodb +功能描述:离线归属地库的读取与更新:GeoLite2 提供国家/行政区的 ISO 代码与多语言名字,纯真库在国家判为中国时补齐缺失的中文名,查询结果统一为 GeoRecord。 +职责边界:负责:加载两个库、规范化查询结果为 GeoRecord、下载更新数据文件;不负责:选词翻译(geo_names)、按 IP 缓存(geoip 服务)、写库(geo_resolver)。 +关键依赖:maxminddb、ipdb、requests;内部依赖 core.paths.resolve_pool_path 与 core.domain.models.GeoRecord。 +已知限制: +1. 两个数据文件任一缺失或打不开即整体不可用,查询一律返回 None,不做「缺谁用谁」的部分降级,也没有在线兜底。 +2. 国家结论只由 GeoLite2 决定;纯真库仅在国家为中国、且 GeoLite2 缺对应 zh-CN 名字时才补,region_name 原样作为中文省名。 +3. .mmdb 打开先试 C 扩展再降级纯 Python 版:C 扩展在非 ASCII 路径下打不开(默认路径含中文),纯 Python 版 get() 不接受默认值参数。 +4. 查询是同步的内存映射调用;close() 只关 mmdb,ipdb 无 close、丢引用即释放占用,关库窗口内的并发查询表现为「查不到」。 +5. Windows 上被内存映射占用的文件无法 os.replace,故 update_databases() 把关库窗口压到两次换入之间,下载期间不关库。 +6. 数据文件不入库,由 update_databases() 从带 @latest 的 CDN 地址拉取;下载低于体积下限即判失败,原文件保持不动。 +""" + +import gzip +import logging +import os +import shutil +import tempfile +from pathlib import Path +from typing import Optional + +from core.domain.models import GeoRecord + +logger = logging.getLogger(__name__) + +GEOLITE2_FILENAME = "GeoLite2-City.mmdb" +QQWRY_FILENAME = "qqwry.ipdb" + +GEODB_SUBDIR = "data/geodb" + +_GEOLITE2_URL = ( + "https://cdn.jsdelivr.net/npm/geolite2-city@latest/GeoLite2-City.mmdb.gz" +) +_QQWRY_URL = "https://cdn.jsdelivr.net/npm/qqwry.ipdb@latest/qqwry.ipdb" + +_DOWNLOAD_TIMEOUT = 600 + +_MIN_PLAUSIBLE_SIZE = 1024 * 1024 + +LANG_ZH = "zh-CN" +LANG_EN = "en" + +SOURCE_GEOLITE2 = "geolite2" +SOURCE_GEOLITE2_QQWRY = "geolite2+qqwry" + + +def default_data_dir() -> Path: + from core.paths import resolve_pool_path + + return resolve_pool_path(GEODB_SUBDIR) + + +class GeoDatabase: + def __init__(self, data_dir: Optional[Path] = None): + self._data_dir = Path(data_dir) if data_dir else default_data_dir() + self._geolite2 = None + self._qqwry = None + self._load() + + def _load(self) -> None: + mmdb_path = self._data_dir / GEOLITE2_FILENAME + ipdb_path = self._data_dir / QQWRY_FILENAME + + missing = [p.name for p in (mmdb_path, ipdb_path) if not p.is_file()] + if missing: + logger.info( + "离线归属地库不可用(缺少 %s),归属地查询将一律返回未知;" + "可用 update_databases() 或面板的更新入口拉取", + "、".join(missing), + ) + return + + self._geolite2 = self._open_mmdb(mmdb_path) + if self._geolite2 is None: + return + + try: + import ipdb + + self._qqwry = ipdb.City(str(ipdb_path)) + except Exception as e: + logger.warning(f"打开纯真库失败,离线归属地整体不可用: {e}") + self._close_geolite2() + return + + @staticmethod + def _open_mmdb(mmdb_path: Path): + try: + import maxminddb + except ImportError as e: + logger.warning(f"未安装 maxminddb,离线归属地不可用: {e}") + return None + + try: + return maxminddb.open_database(str(mmdb_path)) + except Exception as e: + logger.info(f"C 扩展打开 GeoLite2 失败({type(e).__name__}),改用纯 Python 读取器") + + try: + return maxminddb.open_database(str(mmdb_path), maxminddb.MODE_MMAP) + except Exception as e: + logger.warning(f"打开 GeoLite2 失败,离线归属地整体不可用: {e}") + return None + + @property + def available(self) -> bool: + return self._geolite2 is not None and self._qqwry is not None + + def lookup(self, ip: str) -> Optional[GeoRecord]: + if not self.available: + return None + + base = self._lookup_geolite2(ip) + if base is None: + return None + if not base.is_china or self._qqwry is None: + return base + return self._overlay_qqwry(ip, base) + + def _lookup_geolite2(self, ip: str) -> Optional[GeoRecord]: + try: + data = self._geolite2.get(ip) + except Exception as e: + logger.debug(f"GeoLite2 查询异常 {ip}: {e}") + return None + if not data: + return None + + country = data.get("country") or {} + country_code = country.get("iso_code") or "" + if not country_code: + return None + + subdivision = (data.get("subdivisions") or [{}])[0] + city = data.get("city") or {} + + return GeoRecord( + country_code=country_code, + subdivision_code=subdivision.get("iso_code") or "", + country_names=dict(country.get("names") or {}), + subdivision_names=dict(subdivision.get("names") or {}), + city_names=dict(city.get("names") or {}), + source=SOURCE_GEOLITE2, + ) + + def _overlay_qqwry(self, ip: str, base: GeoRecord) -> GeoRecord: + try: + info = self._qqwry.find_map(ip, "CN") or {} + except Exception as e: + logger.debug(f"纯真库查询异常 {ip}: {e}") + return base + + region = str(info.get("region_name") or "").strip() + city = str(info.get("city_name") or "").strip() + if not region and not city: + return base + + country_names = dict(base.country_names) + country_names.setdefault(LANG_ZH, str(info.get("country_name") or "中国")) + + subdivision_names = dict(base.subdivision_names) + if region and LANG_ZH not in subdivision_names: + subdivision_names[LANG_ZH] = region + + city_names = dict(base.city_names) + if city and LANG_ZH not in city_names: + city_names[LANG_ZH] = city + + return GeoRecord( + country_code=base.country_code, + subdivision_code=base.subdivision_code, + country_names=country_names, + subdivision_names=subdivision_names, + city_names=city_names, + source=SOURCE_GEOLITE2_QQWRY, + ) + + def _close_geolite2(self) -> None: + if self._geolite2 is None: + return + try: + self._geolite2.close() + except Exception: + pass + self._geolite2 = None + + def close(self) -> None: + self._close_geolite2() + self._qqwry = None + + def reload(self) -> None: + self.close() + self._load() + + +def _download(url: str, dest: Path, *, progress=None) -> None: + import requests + + dest.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(dir=str(dest.parent), prefix=".geo-", suffix=".tmp") + downloaded = 0 + try: + with os.fdopen(fd, "wb") as handle: + with requests.get(url, stream=True, timeout=_DOWNLOAD_TIMEOUT) as response: + if response.status_code != 200: + raise RuntimeError(f"HTTP {response.status_code}") + total = int(response.headers.get("Content-Length") or 0) + for chunk in response.iter_content(chunk_size=1 << 16): + if not chunk: + continue + handle.write(chunk) + downloaded += len(chunk) + if progress is not None: + progress(downloaded, total) + handle.flush() + os.fsync(handle.fileno()) + + if downloaded < _MIN_PLAUSIBLE_SIZE: + raise RuntimeError(f"下载体积异常({downloaded} 字节),疑为中途断开") + + os.replace(temp_path, str(dest)) + except BaseException: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + +def _decompress_gz(gz_path: Path, dest: Path) -> None: + fd, temp_path = tempfile.mkstemp(dir=str(dest.parent), prefix=".geo-", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as handle: + with gzip.open(str(gz_path), "rb") as source: + shutil.copyfileobj(source, handle, length=1 << 20) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, str(dest)) + except BaseException: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + +def describe_databases(data_dir: Optional[Path] = None) -> list: + import datetime + + target = Path(data_dir) if data_dir else default_data_dir() + entries = [] + for filename in (GEOLITE2_FILENAME, QQWRY_FILENAME): + path = target / filename + try: + stat = path.stat() + entries.append({ + "filename": filename, + "exists": True, + "size": stat.st_size, + "modified_at": datetime.datetime.fromtimestamp(stat.st_mtime) + .strftime("%Y-%m-%d %H:%M:%S"), + }) + except OSError: + entries.append({ + "filename": filename, + "exists": False, + "size": 0, + "modified_at": None, + }) + return entries + + +def update_databases(data_dir: Optional[Path] = None, *, progress=None, + live: Optional["GeoDatabase"] = None) -> dict: + target = Path(data_dir) if data_dir else default_data_dir() + target.mkdir(parents=True, exist_ok=True) + results: dict = {} + + def _cb(name): + if progress is None: + return None + return lambda done, total: progress(name, done, total) + + def _stage(filename, fetch) -> None: + fd, temp_path = tempfile.mkstemp(dir=str(target), prefix=".geo-", suffix=".tmp") + os.close(fd) + try: + fetch(Path(temp_path)) + except Exception as e: + try: + os.unlink(temp_path) + except OSError: + pass + results[filename] = f"失败: {e}" + logger.warning(f"更新 {filename} 失败: {e}") + return + staged.append((Path(temp_path), target / filename, filename)) + + def _fetch_geolite2(dest: Path) -> None: + gz_path = target / (GEOLITE2_FILENAME + ".gz") + try: + _download(_GEOLITE2_URL, gz_path, progress=_cb(GEOLITE2_FILENAME)) + _decompress_gz(gz_path, dest) + finally: + try: + gz_path.unlink() + except OSError: + pass + + staged: list = [] + _stage(GEOLITE2_FILENAME, _fetch_geolite2) + _stage(QQWRY_FILENAME, + lambda dest: _download(_QQWRY_URL, dest, progress=_cb(QQWRY_FILENAME))) + + if not staged: + return results + + if live is not None: + live.close() + try: + for temp_path, final_path, filename in staged: + try: + os.replace(str(temp_path), str(final_path)) + results[filename] = "已更新" + except OSError as e: + results[filename] = f"失败: {e}" + logger.warning(f"换入 {filename} 失败: {e}") + try: + os.unlink(temp_path) + except OSError: + pass + finally: + if live is not None: + live.reload() + + return results + + +def main(argv=None) -> int: + import argparse + + parser = argparse.ArgumentParser(description="拉取/更新离线归属地库") + parser.add_argument("--dir", default=None, help="数据目录(默认子项目 data/geodb)") + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(message)s") + target = Path(args.dir) if args.dir else default_data_dir() + + last = {} + + def report(name, done, total): + pct = int(done * 100 / total) if total else 0 + if last.get(name) != pct and pct % 10 == 0: + last[name] = pct + print(f" {name}: {pct}% ({done / 1048576:.1f} MB)", flush=True) + + print(f"更新目录: {target}") + results = update_databases(target, progress=report) + print() + failed = False + for name, outcome in results.items(): + print(f" {name:<24}{outcome}") + failed = failed or outcome.startswith("失败") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/modules/proxypool/core/infrastructure/http_client.py b/modules/proxypool/core/infrastructure/http_client.py new file mode 100644 index 0000000..a6118c6 --- /dev/null +++ b/modules/proxypool/core/infrastructure/http_client.py @@ -0,0 +1,416 @@ +""" +模块名称:modules.proxypool.core.infrastructure.http_client +功能描述:基于 aiohttp 的异步 HTTP 客户端,供抓取插件与代理验证共用;统一管理会话与连接池,把底层异常翻译成 + 带可读原因的 HttpClientException,并为 SOCKS 代理提供 aiohttp 原生不支持的连接路径。 +职责边界:负责:会话生命周期、连接池参数、默认超时与 connect_timeout 的下发、SOCKS 代理连接建立、异常翻译;不负责:重试策略 + (由调用方决定)、响应内容解析、代理质量判定。 +关键依赖:aiohttp、core.domain.exceptions、core.domain.models、core.interfaces.infrastructure; + 可选 aiohttp_socks 仅用于 SOCKS 路径,缺失时该路径请求失败。 +已知限制: + 1. 共享会话一律不校验证书(连接器 ssl=False,且按请求传 ssl=True 也会被 aiohttp 降级为不校验;仅按请求传 ssl.SSLContext 才校验), + 故匿名度回显等质量结论类请求实际未校验,第三方/中间人代理可伪造回显;SOCKS 路径按默认仍校验证书。 + 2. connect_timeout 对应 aiohttp 的 sock_connect 而非 connect,隧道建立时间不计入连接预算。 + 3. _translate 须先按具体类型判定,否则丢超时原因;reason 取值是验证器归因依据,proxy_refused 单独归类,是判定不支持 https 转发的唯一确定性信号。 + 4. pool_queue_timeout 由超时类异常、排队达到 _QUEUE_WAIT_INCONCLUSIVE_SECONDS、始终未拿到连接三条共同判定; + 拿到连接后的读超时不归为排队问题。 + 5. SOCKS 请求按请求新建并关闭会话,不参与共享连接池,也不受连接池上限约束。 + 6. 关闭后 _get_session 抛 reason="client_closed",调用方不得重试;close() 幂等且关闭标记不复位,重新启用需新建实例。 + 7. _PLAIN_PROBE_URL_COUNT=3 对应 validation_endpoints 明文 HTTP 探测清单的条数,两处须同步修改;按层间依赖方向,不得改为导入该清单常量。 +""" + +import asyncio +import logging +import socket +import time +from typing import Optional + +import aiohttp + +from core.domain.exceptions import ( + HttpClientException, + HttpRequestException, + HttpTimeoutException, +) +from core.domain.models import HttpResponse +from core.interfaces.infrastructure import IHttpClient + +logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT_SECONDS = 30 + +SOCKS_PROXY_SCHEMES = frozenset({"socks4", "socks4a", "socks5", "socks5h"}) + +_MIN_CONNECTIONS = 256 +_PER_HOST_RATIO = 0.25 + +_QUEUE_WAIT_INCONCLUSIVE_SECONDS = 0.5 +_CONGESTION_LOG_INTERVAL_SECONDS = 60.0 + +_PLAIN_PROBE_URL_COUNT = 3 + +_last_congestion_log = 0.0 + +_BROWSER_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +) + +try: + from aiohttp_socks import ProxyConnector as _SocksProxyConnector + _SOCKS_AVAILABLE = True +except ImportError: # pragma: no cover + _SocksProxyConnector = None # type: ignore[assignment] + _SOCKS_AVAILABLE = False + +_PROXY_CONNECTION_ERROR_TYPES: tuple = (aiohttp.ClientProxyConnectionError,) +if _SOCKS_AVAILABLE: # pragma: no cover + try: + from aiohttp_socks import ProxyConnectionError, ProxyError, ProxyTimeoutError + _PROXY_CONNECTION_ERROR_TYPES = ( + aiohttp.ClientProxyConnectionError, + ProxyError, + ProxyConnectionError, + ProxyTimeoutError, + ) + except ImportError: + pass + + +def describe_error(exc: BaseException) -> str: + text = str(exc).strip() + return text or type(exc).__name__ + + +def proxy_scheme(proxy: Optional[str]) -> str: + if not proxy: + return "" + scheme, separator, _ = proxy.partition("://") + return scheme.lower() if separator else "" + + +class _PoolQueueProbe: + def __init__(self): + self.connected = False + self._settled_seconds = 0.0 + self._waiting_since = None + + def begin_wait(self): + self._waiting_since = time.monotonic() + + def end_wait(self): + if self._waiting_since is not None: + self._settled_seconds += time.monotonic() - self._waiting_since + self._waiting_since = None + + def mark_connected(self): + self.connected = True + + def total_wait_seconds(self) -> float: + if self._waiting_since is None: + return self._settled_seconds + return self._settled_seconds + (time.monotonic() - self._waiting_since) + + +def _probe_of(trace_config_ctx) -> Optional[_PoolQueueProbe]: + probe = getattr(trace_config_ctx, "trace_request_ctx", None) + return probe if isinstance(probe, _PoolQueueProbe) else None + + +async def _trace_queue_started(session, trace_config_ctx, params): + probe = _probe_of(trace_config_ctx) + if probe is not None: + probe.begin_wait() + + +async def _trace_queue_finished(session, trace_config_ctx, params): + probe = _probe_of(trace_config_ctx) + if probe is not None: + probe.end_wait() + + +async def _trace_connected(session, trace_config_ctx, params): + probe = _probe_of(trace_config_ctx) + if probe is not None: + probe.mark_connected() + + +def _build_trace_config() -> aiohttp.TraceConfig: + trace_config = aiohttp.TraceConfig() + trace_config.on_connection_queued_start.append(_trace_queue_started) + trace_config.on_connection_queued_end.append(_trace_queue_finished) + trace_config.on_connection_create_end.append(_trace_connected) + trace_config.on_connection_reuseconn.append(_trace_connected) + return trace_config + + +def _validation_request_upper_bound(validator) -> int: + identity = len(getattr(validator, "identity_echo_apis", None) or ()) + plain_identity = len(getattr(validator, "plain_identity_echo_apis", None) or ()) + ip_apis = len(getattr(validator, "ip_check_apis", None) or ()) + + bound = 1 + identity + ip_apis + if getattr(validator, "check_anonymity", False): + bound += identity + plain_identity + _PLAIN_PROBE_URL_COUNT + return bound + + +def _log_pool_congestion(url: str, waited: float) -> None: + global _last_congestion_log + now = time.monotonic() + if now - _last_congestion_log < _CONGESTION_LOG_INTERVAL_SECONDS: + return + _last_congestion_log = now + logger.warning( + f"本机连接池排队 {waited:.1f} 秒后超时({url}):本轮不下结论。" + f"检测并发可能超出本机连接能力,可调低 validator.max_concurrent。" + ) + + +class AioHttpClient(IHttpClient): + def __init__(self, default_timeout: int = DEFAULT_TIMEOUT_SECONDS): + self._session: Optional[aiohttp.ClientSession] = None + self._family_sessions: dict[int, aiohttp.ClientSession] = {} + self._session_lock = asyncio.Lock() + self._default_timeout = default_timeout + self._max_connections = _MIN_CONNECTIONS + self._session_stale = False + self._closed = False + self._trace_config = _build_trace_config() + + def apply_config(self, config) -> None: + timeout = config.plugins.request_timeout_seconds + if timeout != self._default_timeout: + logger.info(f"HTTP 客户端默认超时已更新: {self._default_timeout} -> {timeout} 秒") + self._default_timeout = timeout + + limit = self._resolve_connection_limit(config) + if limit != self._max_connections: + logger.info( + f"HTTP 连接池上限已更新: {self._max_connections} -> {limit}" + ) + self._max_connections = limit + self._session_stale = True + + @staticmethod + def _resolve_connection_limit(config) -> int: + validator = getattr(config, "validator", None) + max_concurrent = getattr(validator, "max_concurrent", 0) or 0 + try: + max_concurrent = int(max_concurrent) + except (TypeError, ValueError): + max_concurrent = 0 + + return max( + _MIN_CONNECTIONS, + max_concurrent * _validation_request_upper_bound(validator), + ) + + def _new_session(self, family: Optional[int] = None) -> aiohttp.ClientSession: + connector = aiohttp.TCPConnector( + family=socket.AF_UNSPEC if family is None else family, + limit=self._max_connections, + limit_per_host=max(1, int(self._max_connections * _PER_HOST_RATIO)), + ttl_dns_cache=300, + ssl=False, + ) + return aiohttp.ClientSession( + connector=connector, + headers={"User-Agent": _BROWSER_USER_AGENT}, + trace_configs=[self._trace_config], + ) + + async def _drop_sessions(self) -> None: + for session in [self._session, *self._family_sessions.values()]: + if session is not None and not session.closed: + await session.close() + self._session = None + self._family_sessions = {} + self._session_stale = False + + async def _get_session(self, family: Optional[int] = None) -> aiohttp.ClientSession: + if self._closed: + raise HttpClientException("HTTP 客户端已关闭", reason="client_closed") + + async with self._session_lock: + if self._closed: + raise HttpClientException("HTTP 客户端已关闭", reason="client_closed") + + if self._session_stale: + await self._drop_sessions() + + if family is None: + if self._session is not None and self._session.closed: + self._session = None + if self._session is None: + self._session = self._new_session() + return self._session + + session = self._family_sessions.get(family) + if session is not None and session.closed: + session = None + if session is None: + session = self._new_session(family) + self._family_sessions[family] = session + return session + + async def _request(self, method: str, url: str, proxy: Optional[str], + timeout: Optional[int], connect_timeout: Optional[float] = None, + family: Optional[int] = None, **kwargs) -> HttpResponse: + total = timeout or self._default_timeout + probe = _PoolQueueProbe() + + try: + if proxy_scheme(proxy) in SOCKS_PROXY_SCHEMES: + return await self._request_via_socks( + method, url, proxy, total, connect_timeout, **kwargs + ) + + session = await self._get_session(family) + return await self._send( + session, method, url, total, connect_timeout, + probe=probe, proxy=proxy, **kwargs + ) + + except HttpClientException: + raise + except Exception as e: + raise self._translate( + e, url, total, connect_timeout, + pool_wait_seconds=probe.total_wait_seconds(), + connected=probe.connected, + ) from e + + @staticmethod + async def _send(session: aiohttp.ClientSession, method: str, url: str, + total: float, connect_timeout: Optional[float], + probe: Optional[_PoolQueueProbe] = None, + **kwargs) -> HttpResponse: + request_timeout = aiohttp.ClientTimeout(total=total, sock_connect=connect_timeout) + async with session.request( + method, url, timeout=request_timeout, + trace_request_ctx=probe, **kwargs + ) as response: + text = await response.text() + return HttpResponse( + status=response.status, + text=text, + headers=dict(response.headers), + ) + + async def _request_via_socks(self, method: str, url: str, proxy: str, + total: float, connect_timeout: Optional[float], + **kwargs) -> HttpResponse: + if not _SOCKS_AVAILABLE: + raise HttpRequestException( + f"无法使用 SOCKS 代理 {proxy}: 缺少 aiohttp_socks 依赖", + reason="socks_unsupported", + ) + + connector = _SocksProxyConnector.from_url(proxy) + try: + async with aiohttp.ClientSession(connector=connector) as session: + return await self._send( + session, method, url, total, connect_timeout, **kwargs + ) + except HttpClientException: + raise + except Exception as e: + raise self._translate(e, url, total, connect_timeout, proxy) from e + + @staticmethod + def _translate(exc: Exception, url: str, total: float, + connect_timeout: Optional[float], + proxy: Optional[str] = None, + *, pool_wait_seconds: float = 0.0, + connected: bool = False) -> HttpClientException: + connect_budget = connect_timeout or total + + if (not connected + and pool_wait_seconds >= _QUEUE_WAIT_INCONCLUSIVE_SECONDS + and isinstance(exc, asyncio.TimeoutError)): + _log_pool_congestion(url, pool_wait_seconds) + return HttpTimeoutException( + f"本机连接池排队 {pool_wait_seconds:.1f} 秒后超时 {url}", + reason="pool_queue_timeout", + ) + + if isinstance(exc, aiohttp.ConnectionTimeoutError): + return HttpTimeoutException( + f"连接超时 {url}({connect_budget} 秒内未能建立连接)", + reason="connect_timeout", + ) + if isinstance(exc, aiohttp.SocketTimeoutError): + return HttpTimeoutException( + f"读取超时 {url}({total} 秒内未收到响应)", + reason="read_timeout", + ) + if isinstance(exc, asyncio.TimeoutError): + return HttpTimeoutException( + f"请求超时 {url}({total} 秒内无响应)", + reason="total_timeout", + ) + if isinstance(exc, aiohttp.ClientHttpProxyError): + status = getattr(exc, "status", None) + return HttpRequestException( + f"代理拒绝建立隧道(CONNECT 返回 {status}): {describe_error(exc)}", + reason="proxy_refused", + ) + if isinstance(exc, _PROXY_CONNECTION_ERROR_TYPES): + target = proxy or "代理" + return HttpRequestException( + f"连接代理失败 {target}: {describe_error(exc)}", + reason="proxy_connect", + ) + if isinstance(exc, aiohttp.ClientConnectorDNSError): + return HttpRequestException( + f"域名解析失败 {url}: {describe_error(exc)}", + reason="dns", + ) + if isinstance(exc, aiohttp.ServerDisconnectedError): + return HttpRequestException( + f"连接被对端断开 {url}: {describe_error(exc)}", + reason="disconnect", + ) + if isinstance(exc, aiohttp.ClientError): + return HttpRequestException( + f"请求失败 {url}: {describe_error(exc)}", + reason="request", + ) + return HttpClientException( + f"请求异常 {url}: {describe_error(exc)}", + reason="unexpected", + ) + + async def get(self, url: str, proxy: Optional[str] = None, + timeout: Optional[int] = None, + connect_timeout: Optional[float] = None, + family: Optional[int] = None, + **kwargs) -> HttpResponse: + return await self._request( + "GET", url, proxy, timeout, connect_timeout, family=family, **kwargs + ) + + async def post(self, url: str, data: Optional[dict] = None, + json: Optional[dict] = None, + proxy: Optional[str] = None, + timeout: Optional[int] = None, + connect_timeout: Optional[float] = None, + **kwargs) -> HttpResponse: + if data is not None: + kwargs["data"] = data + if json is not None: + kwargs["json"] = json + return await self._request("POST", url, proxy, timeout, connect_timeout, **kwargs) + + async def close(self) -> None: + self._closed = True + await self._drop_sessions() + + +__all__ = [ + "AioHttpClient", + "describe_error", + "proxy_scheme", + "DEFAULT_TIMEOUT_SECONDS", + "SOCKS_PROXY_SCHEMES", +] diff --git a/modules/proxypool/core/interfaces/__init__.py b/modules/proxypool/core/interfaces/__init__.py new file mode 100644 index 0000000..7e22c4a --- /dev/null +++ b/modules/proxypool/core/interfaces/__init__.py @@ -0,0 +1,8 @@ +""" +模块名称:modules.proxypool.core.interfaces +功能描述:代理池抽象接口(端口)所在包的入口,本文件仅为包标记,不含任何代码。 +职责边界:负责:标记 core.interfaces 包;不负责:接口声明与再导出,接口按基础设施、仓储、服务三册声明在对应子模块。 +关键依赖:无。 +已知限制: +1. 从 core.interfaces 直接导入接口会失败,须按册从子模块导入,例如 core.interfaces.repository 里的 IProxyRepository。 +""" diff --git a/modules/proxypool/core/interfaces/infrastructure.py b/modules/proxypool/core/interfaces/infrastructure.py new file mode 100644 index 0000000..6c655a0 --- /dev/null +++ b/modules/proxypool/core/interfaces/infrastructure.py @@ -0,0 +1,77 @@ +""" +模块名称:modules.proxypool.core.interfaces.infrastructure +功能描述:基础设施层的能力契约(写入队列、HTTP 客户端、配置管理器);调用方面向本模块的 ABC 编程, + 不直接依赖 core.infrastructure 下的具体实现,实现可整套替换而调用方无需改动。 +职责边界:负责:声明基础设施能力的方法签名、参数语义与失败方式;不负责:实现细节与资源生命周期(见 core.infrastructure.*)、实例选型(由应用层 DI 装配决定)。 +关键依赖:core.domain.models(DBOperation、HttpResponse)。 +已知限制: +1. 接口只做签名约束,方法体为空;实现方漏实现抽象方法时,实例化才由 ABC 报错,导入时不报。 +2. enqueue 是受理制:返回只代表已入队,落库由后台消费者稍后完成;未启动或已停止抛 WriteQueueException,队列满抛 WriteQueueFullException。 +3. IWriteQueue.stop() 必须有时间上限,已受理未落库的条目必须给出明确数量,不得静默丢弃;未启动时调用直接返回。 +4. 非 2xx 照常返回 HttpResponse,网络层失败才抛 HttpClientException;timeout 为整次请求上限,connect_timeout 仅约束建连。 +5. IHttpClient.get 的 family 缺省 AF_UNSPEC 由系统选;传 AF_INET / AF_INET6 只走该地址族,双栈主机须钉死地址族才能分别问出两族出口 IP。 +6. IConfigManager 的 get_config 返回内部对象而非副本、绝不返回 None,调用方不得就地修改;apply 先替换再通知,不得因单个订阅者失败而中断或抛异常。 +""" + +from abc import ABC, abstractmethod +from typing import Callable, Optional +from core.domain.models import DBOperation, HttpResponse + + +class IWriteQueue(ABC): + @abstractmethod + async def start(self) -> None: + pass + + @abstractmethod + async def stop(self) -> None: + pass + + @abstractmethod + async def enqueue(self, operation: DBOperation) -> None: + pass + + @abstractmethod + def apply_config(self, config: 'Config') -> None: + pass + + +class IHttpClient(ABC): + @abstractmethod + async def get(self, url: str, proxy: Optional[str] = None, + timeout: Optional[int] = None, + connect_timeout: Optional[float] = None, + family: Optional[int] = None, + **kwargs) -> HttpResponse: + pass + + @abstractmethod + async def post(self, url: str, data: Optional[dict] = None, + json: Optional[dict] = None, + proxy: Optional[str] = None, + timeout: Optional[int] = None, + connect_timeout: Optional[float] = None, + **kwargs) -> HttpResponse: + pass + + @abstractmethod + def apply_config(self, config: 'Config') -> None: + pass + + @abstractmethod + async def close(self) -> None: + pass + + +class IConfigManager(ABC): + @abstractmethod + def get_config(self) -> 'Config': + pass + + @abstractmethod + async def apply(self, new_config: 'Config') -> None: + pass + + @abstractmethod + def subscribe(self, callback: Callable[['Config'], None]) -> None: + pass diff --git a/modules/proxypool/core/interfaces/repository.py b/modules/proxypool/core/interfaces/repository.py new file mode 100644 index 0000000..b2cffa1 --- /dev/null +++ b/modules/proxypool/core/interfaces/repository.py @@ -0,0 +1,104 @@ +""" +模块名称:modules.proxypool.core.interfaces.repository +功能描述:数据仓库的抽象契约:声明代理与插件配置的读写方法签名、参数、返回值与失败语义,上层服务只依赖接口。 +职责边界:负责:声明代理与插件配置的读写契约(方法签名与失败语义);不负责:SQL、表结构、连接与事务等实现细节。 +关键依赖:core.domain.models(Proxy / PluginConfig / ProxyFilter);标准库 abc 与 typing。 +已知限制: +1. 业务性结果由返回值表达(数量、对象、None、布尔);参数与基础设施错误仍抛异常,调用方需两者都处理。 +2. 写方法收到空列表或空字典即无操作;add 忽略传入对象的 id,主键由存储层分配。 +3. update_where 的 where 键会直接进 SQL 列名,实现必须限定在白名单列内。 +4. get_existing_ip_ports 的键是 "ip:port",必须带端口,同一 IP 的不同端口算两个代理。 +5. get_geo_fields_by_real_ip 是合并语义,缺失级别不得覆盖已有值为「未知」。 +6. IPluginConfigRepository.save 为整体覆盖,get 无记录返回 None,调用方应回退默认配置。 +7. get_stats 六键:total/valid/invalid_proxies、protocol/region_distribution、 + anonymity_fallback_proxies。 +""" + +from abc import ABC, abstractmethod +from typing import List, Optional +from core.domain.models import Proxy, PluginConfig, ProxyFilter + + +class IProxyRepository(ABC): + @abstractmethod + async def add(self, proxy: Proxy) -> int: + pass + + @abstractmethod + async def add_batch(self, proxies: List[Proxy]) -> int: + pass + + @abstractmethod + async def update(self, proxy_id: int, updates: dict) -> bool: + pass + + @abstractmethod + async def update_batch(self, updates: list[tuple[int, dict]]) -> int: + pass + + @abstractmethod + async def update_where(self, where: dict, updates: dict) -> int: + pass + + @abstractmethod + async def delete_batch(self, proxy_ids: list[int]) -> int: + pass + + @abstractmethod + async def get_by_id(self, proxy_id: int) -> Optional[Proxy]: + pass + + @abstractmethod + async def find(self, filter: ProxyFilter, page: int = 1, + page_size: int = 50) -> List[Proxy]: + pass + + @abstractmethod + async def count(self, filter: ProxyFilter) -> int: + pass + + @abstractmethod + async def get_geo_fields_by_real_ip(self, real_ip: str) -> Optional[dict]: + pass + + @abstractmethod + async def get_ips_missing_geo_codes(self) -> List[str]: + pass + + @abstractmethod + async def find_without_checks(self) -> List[Proxy]: + pass + + @abstractmethod + async def get_existing_ip_ports(self) -> set[str]: + pass + + @abstractmethod + async def delete_invalid(self) -> int: + pass + + @abstractmethod + async def get_stats(self) -> dict: + pass + + @abstractmethod + async def find_by_ip_port(self, ip: str, port: int) -> Optional[Proxy]: + pass + + @abstractmethod + async def get_sources(self) -> List[str]: + pass + + +class IPluginConfigRepository(ABC): + @abstractmethod + async def get(self, plugin_name: str) -> Optional[PluginConfig]: + pass + + @abstractmethod + async def save(self, config: PluginConfig) -> None: + pass + + @abstractmethod + async def get_all(self) -> List[PluginConfig]: + pass diff --git a/modules/proxypool/core/interfaces/services.py b/modules/proxypool/core/interfaces/services.py new file mode 100644 index 0000000..3d67009 --- /dev/null +++ b/modules/proxypool/core/interfaces/services.py @@ -0,0 +1,144 @@ +""" +模块名称:modules.proxypool.core.interfaces.services +功能描述:代理池领域服务的抽象契约:采集插件、代理验证器、GeoIP 查询、插件管理器, + 以及插件执行时注入的 PluginContext;上层(应用层、批量任务)只依赖这些接口。 +职责边界:负责:声明各服务的调用契约与返回值、异常语义;不负责:实现细节与运行时状态, + 接口不做 I/O、不持有配置,具体实现见 core.services(geoip、validator、plugin_manager)与 plugins/*.py。 +关键依赖:core.domain.models(ProxyValidationResult / GeoLocation / PluginStatus / ValidationMode)。 +已知限制: + 1. fetch_proxies 抓取失败必须抛异常,返回空列表会被调度当成「该源本次无代理」并推进成功状态。 + 2. IProxyPlugin.name 与 version 无调用方读取,插件以文件名 stem 作为配置、调度与来源记录键。 + 3. validate_proxy 验证失败仍须返回结果对象(以 outcome 表达失败类型),仅实现自身出错才可抛异常。 + 4. get_location 的 async 签名不表示实现必须异步,可纯本地查表;库不可用时返回「未知」对象而非报错。 + 5. enable_plugin / disable_plugin 对不存在的插件不抛异常,但会新建并落库 PluginConfig; + reload_plugin 失败返回 False,保留原实现。 + 6. set_plugin_test_url 非法地址抛 ValueError,set_plugin_validation 负数间隔抛 InvalidParameterException。 +""" + +from abc import ABC, abstractmethod +from typing import List, Optional +from dataclasses import dataclass +import logging +from core.domain.models import ( + ProxyValidationResult, + GeoLocation, + PluginStatus, + ValidationMode, +) + + +@dataclass +class PluginContext: + http_client: 'IHttpClient' + logger: logging.Logger + config: 'Config' + + +class IProxyPlugin(ABC): + @abstractmethod + async def fetch_proxies(self, context: PluginContext) -> List[str]: + pass + + @property + @abstractmethod + def name(self) -> str: + pass + + @property + @abstractmethod + def version(self) -> str: + pass + + +class IProxyValidator(ABC): + @abstractmethod + async def validate_proxy(self, proxy_url: str, + mode: ValidationMode = ValidationMode.FULL, + test_url: Optional[str] = None) -> ProxyValidationResult: + pass + + @abstractmethod + def apply_config(self, config: 'Config') -> None: + pass + + @abstractmethod + def can_attribute_failure(self, test_url: str) -> bool: + pass + + +class IGeoIPService(ABC): + @abstractmethod + def load_database(self) -> bool: + pass + + @abstractmethod + async def get_location(self, ip: str) -> GeoLocation: + pass + + @abstractmethod + def close(self) -> None: + pass + + +class IPluginManager(ABC): + @abstractmethod + async def start(self) -> None: + pass + + @abstractmethod + async def stop(self) -> None: + pass + + @abstractmethod + async def scan_plugins(self) -> None: + pass + + @abstractmethod + async def execute_plugin(self, plugin_name: str) -> List[str]: + pass + + @abstractmethod + async def deduplicate_proxies(self, proxies: List[str]) -> List[str]: + pass + + @abstractmethod + def enable_plugin(self, plugin_name: str) -> None: + pass + + @abstractmethod + def disable_plugin(self, plugin_name: str) -> None: + pass + + @abstractmethod + def set_plugin_interval(self, plugin_name: str, minutes: int) -> None: + pass + + @abstractmethod + def get_plugin_status(self) -> dict[str, PluginStatus]: + pass + + @abstractmethod + def apply_config(self, config: 'Config') -> None: + pass + + @abstractmethod + async def reload_plugin(self, plugin_name: str) -> bool: + pass + + @abstractmethod + def set_plugin_test_url(self, plugin_name: str, test_url: str) -> None: + pass + + @abstractmethod + def set_plugin_validation(self, plugin_name: str, reval_enabled: bool, + reval_interval_minutes: int, + skip_validation: bool) -> None: + pass + + @abstractmethod + def should_skip_validation(self, plugin_name: str) -> bool: + pass + + @abstractmethod + def resolve_test_url(self, plugin_name: str) -> str: + pass diff --git a/modules/proxypool/core/logger.py b/modules/proxypool/core/logger.py new file mode 100644 index 0000000..82b2790 --- /dev/null +++ b/modules/proxypool/core/logger.py @@ -0,0 +1,83 @@ +""" +模块名称:modules.proxypool.core.logger +功能描述:把池的高频日志(插件抓取、批量验证)写入 logs/proxy_pool.log 轮转文件以免淹没主日志,经 propagate 只汇入面板「运行日志」的内存环形缓冲,不进控制台。 +职责边界:负责:挂载或重挂池的轮转文件 handler、压低第三方库日志级别;不负责:主日志装配与 root 级别设置(见 modules.logging_setup)。 +关键依赖:core.config.LoggingConfig、core.paths.resolve_pool_path、标准库 logging 与 logging.handlers。 +已知限制: +1. 与 ProxyCat 共用同一个 root logger,不清空已有 handler 也不设 root 级别,否则主程序日志面板失效。 +2. handler 与配置签名存为 logger 属性而非模块级变量,保证模块多次导入也只挂一份。 +3. 幂等依据是四项配置签名(文件路径、单文件上限、保留份数、级别):签名未变时直接返回,签名变化时函数内部先摘旧 handler 再挂新,调用方无需先摘后挂。 +4. record_filter 由宿主注入,本模块不反向依赖主程序分类规则;不传时主程序的访问日志会被整份复制进来。 +5. 创建日志目录或文件抛出 OSError 时仅向 stderr 打印警告并放弃挂载,不向上抛出。 +""" + +import logging +import sys +from logging.handlers import RotatingFileHandler + +from core.config import LoggingConfig +from core.paths import resolve_pool_path + +_HANDLER_ATTR = "_proxy_pool_handler" +_SIGNATURE_ATTR = "_proxy_pool_handler_signature" + + +def _detach_pool_log_handler(root_logger: logging.Logger) -> None: + handler = getattr(root_logger, _HANDLER_ATTR, None) + if handler is None: + return + root_logger.removeHandler(handler) + try: + handler.close() + except Exception: + pass + setattr(root_logger, _HANDLER_ATTR, None) + + +def attach_pool_log_handlers(log_config: LoggingConfig, level: int, + record_filter: logging.Filter | None = None) -> None: + root_logger = logging.getLogger() + log_path = resolve_pool_path(log_config.file_path) + signature = (str(log_path), log_config.max_bytes, log_config.backup_count, level) + + if getattr(root_logger, _SIGNATURE_ATTR, None) == signature: + logging.getLogger(__name__).debug("池日志 handler 的配置未变,跳过重挂") + return + + _detach_pool_log_handler(root_logger) + + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"警告: 创建池日志目录失败 {log_path.parent}: {e}", file=sys.stderr) + return + + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + + try: + file_handler = RotatingFileHandler( + log_path, + maxBytes=log_config.max_bytes, + backupCount=log_config.backup_count, + encoding="utf-8", + ) + except OSError as e: + print(f"警告: 创建池日志文件失败 {log_path}: {e}", file=sys.stderr) + return + + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + if record_filter is not None: + file_handler.addFilter(record_filter) + root_logger.addHandler(file_handler) + setattr(root_logger, _HANDLER_ATTR, file_handler) + setattr(root_logger, _SIGNATURE_ATTR, signature) + + for noisy in ("aiohttp", "asyncio", "urllib3", "aiosqlite"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + logging.getLogger(__name__).info("池日志已挂载: %s", log_path) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/modules/proxypool/core/paths.py b/modules/proxypool/core/paths.py new file mode 100644 index 0000000..838ba7c --- /dev/null +++ b/modules/proxypool/core/paths.py @@ -0,0 +1,21 @@ +""" +模块名称:modules.proxypool.core.paths +功能描述:代理池子项目的路径锚点:定位池根目录 modules/proxypool,并把池内相对路径解析为绝对路径。 +职责边界:负责:池根目录定位与相对路径拼接;不负责:校验路径是否存在、按需创建目录,由调用方自行处理。 +关键依赖:标准库 pathlib。 +已知限制: +1. POOL_BASE_DIR 依赖本文件固定位于池根/core/paths.py 这一层级。 +2. resolve_pool_path 对绝对路径原样返回,不做规范化与存在性检查。 +3. 池内代码直接使用相对路径会以进程工作目录为基准,须经 resolve_pool_path 锚定到池根。 +""" + +from pathlib import Path + +POOL_BASE_DIR: Path = Path(__file__).resolve().parents[1] + + +def resolve_pool_path(path: str | Path) -> Path: + candidate = Path(path) + if candidate.is_absolute(): + return candidate + return POOL_BASE_DIR / candidate diff --git a/modules/proxypool/core/services/__init__.py b/modules/proxypool/core/services/__init__.py new file mode 100644 index 0000000..8a5951e --- /dev/null +++ b/modules/proxypool/core/services/__init__.py @@ -0,0 +1,8 @@ +""" +模块名称:modules.proxypool.core.services +功能描述:服务层所在包的入口,本文件仅为包标记,不含任何代码;各服务类定义在自己的子模块里。 +职责边界:负责:标记 core.services 包;不负责:服务实现与再导出,服务类(GeoIP、验证器、插件管理器、重验证、归属地解析等)定义在对应子模块。 +关键依赖:无。 +已知限制: +1. 从 core.services 直接导入服务类会失败,须从具体子模块导入,例如 core.services.validator 里的 ProxyValidator。 +""" diff --git a/modules/proxypool/core/services/auto_revalidator.py b/modules/proxypool/core/services/auto_revalidator.py new file mode 100644 index 0000000..b8907d2 --- /dev/null +++ b/modules/proxypool/core/services/auto_revalidator.py @@ -0,0 +1,360 @@ +""" +模块名称:modules.proxypool.core.services.auto_revalidator +功能描述:对已入库代理的定时重验证:按插件分组把到期代理重新验证一遍,更新可用性、延迟、真实 IP、匿名度、HTTPS 支持与健康评分,命中清理判据的代理会被删除。 +职责边界:负责:重验证调度、质量字段更新、三判据择一自动清理、每轮重建去重缓存; + 不负责:单代理验证算法(ProxyValidator)、健康分计算(HealthScorer)、代理抓取 + (PluginManager)、归属地解析本身(只入队,见 GeoResolver)。 +关键依赖:IProxyRepository、IProxyValidator、IWriteQueue、IConfigManager、IPluginConfigRepository、 + GeoResolver(鸭子类型注入,仅调用 schedule)、DedupCache、validation_apply、validation_policy。 +已知限制: + 1. 每轮重读全局配置与数据库 plugin_configs 表;关闭时循环不退出而是每 60 秒复查,轮间隔取全局与插件周期的最小值。 + 2. 测试地址必须按插件用 resolve_test_url 解析;与入库判定口径不一致会把好代理计入自动清理。 + 3. 结论不可用(inconclusive)时一个字段都不写;基础设施故障不推进自动清理的删除条件。 + 4. 归属地入队必须用 applied.effective_real_ip 而非 result.real_ip,否则只缺地区的代理永远补不上。 + 5. 自动清理是唯一无人值守且不可逆的删除路径;only_valid_proxies 决定验谁、auto_cleanup.enabled 决定删不删,从未验证过的记录必须另行追加。 + 6. 单轮重验证完成后重建去重缓存,失败只记警告;单轮异常等 60 秒重试;未验证记录须走 find_without_checks,自查 SQL 会绕过行到领域对象映射。 + 7. 构造参数 health_scorer 仅被存储、不被读取,健康分由 validation_apply 自行构造 scorer 计算,传入该参数不影响行为。 +""" + +import asyncio +import logging +from datetime import datetime +from typing import Optional, TYPE_CHECKING + +from core.interfaces.repository import IProxyRepository, IPluginConfigRepository +from core.interfaces.services import IProxyValidator +from core.interfaces.infrastructure import IWriteQueue, IConfigManager +from core.domain.models import ProxyFilter +from core.services.health_scorer import HealthScorer +from core.services.validation_apply import ( + APPLY_APPLIED, + APPLY_INCONCLUSIVE, + build_delete_operation, + build_update_operation, + dispatch_validation_action, +) +from core.services.validation_policy import pick_validation_mode, resolve_test_url + +if TYPE_CHECKING: + from core.infrastructure.dedup_cache import DedupCache + +logger = logging.getLogger(__name__) + +_DISABLED_RECHECK_SECONDS = 60.0 + + +def cleanup_reason_for(config, proxy, result, update_data: dict) -> Optional[str]: + if proxy.is_favorite: + return None + + cleanup = getattr(config, "auto_cleanup", None) + if cleanup is None or not cleanup.enabled: + return None + if result.is_valid: + return None + + new_total_checks = update_data.get("total_checks", 0) + new_health_score = update_data.get("health_score", 0.0) + new_failure_count = update_data.get("failure_count", 0) + + if (new_health_score < cleanup.min_health_score + and new_total_checks >= cleanup.min_checks_before_cleanup): + return f"健康评分 {new_health_score:.1f} < {cleanup.min_health_score}" + + if new_failure_count >= cleanup.max_failures: + return f"连续失败 {new_failure_count} >= {cleanup.max_failures}" + + last_check = last_known_check(proxy) + if last_check is not None: + days = (datetime.now() - last_check).days + if days >= cleanup.max_age_days_if_invalid: + return f"失效后已 {cleanup.max_age_days_if_invalid} 天未被验证成功" + + return None + + +def needs_region(proxy, updates: dict) -> bool: + region = updates.get("region", proxy.region) + region_en = updates.get("region_en", proxy.region_en) + zh_known = bool(region) and region != "未知" + en_known = bool(region_en) and region_en != "未知" + return not (zh_known and en_known) + + +def last_known_check(proxy) -> Optional[datetime]: + return getattr(proxy, "validated_at", None) or getattr(proxy, "created_at", None) + + +class AutoRevalidator: + + def __init__( + self, + repository: IProxyRepository, + validator: IProxyValidator, + geo_resolver, + write_queue: IWriteQueue, + config_manager: IConfigManager, + health_scorer: Optional[HealthScorer] = None, + dedup_cache: Optional['DedupCache'] = None, + plugin_config_repo: Optional[IPluginConfigRepository] = None, + ): + self.repository = repository + self.validator = validator + self.geo_resolver = geo_resolver + self.write_queue = write_queue + self.config_manager = config_manager + self._scorer = health_scorer or HealthScorer() + self._dedup_cache = dedup_cache + self._plugin_config_repo = plugin_config_repo + self.running = False + self.task: Optional[asyncio.Task] = None + + async def _get_plugin_config_map(self) -> dict: + if self._plugin_config_repo is None: + return {} + + try: + configs = await self._plugin_config_repo.get_all() + except Exception as e: + logger.warning(f"读取插件配置失败,本次全部按全局配置处理: {e}") + return {} + + return {c.name: c for c in configs} + + async def start(self) -> None: + if self.running: + logger.warning("自动重新验证服务已经在运行") + return + + config = self.config_manager.get_config() + if not config.auto_revalidation.enabled: + logger.info("自动重新验证功能已禁用(可在面板中随时打开)") + + self.running = True + self.task = asyncio.create_task(self._revalidation_loop()) + logger.info("自动重新验证服务已启动") + + async def stop(self) -> None: + if not self.running: + return + + self.running = False + + if self.task: + self.task.cancel() + try: + await self.task + except asyncio.CancelledError: + pass + + logger.info("自动重新验证服务已停止") + + async def _revalidation_loop(self) -> None: + first_run = True + + while self.running: + try: + config = self.config_manager.get_config() + + if not config.auto_revalidation.enabled: + await asyncio.sleep(_DISABLED_RECHECK_SECONDS) + continue + + min_interval_minutes = config.auto_revalidation.interval_minutes + + plugin_configs = await self._get_plugin_config_map() + for plugin_config in plugin_configs.values(): + if plugin_config.reval_enabled and plugin_config.reval_interval_minutes > 0: + min_interval_minutes = min( + min_interval_minutes, plugin_config.reval_interval_minutes + ) + + interval_seconds = min_interval_minutes * 60 + + if first_run: + first_run = False + if config.auto_revalidation.run_on_start: + logger.info( + "run_on_start 已启用,立即执行首轮代理验证" + ) + else: + logger.info( + f"自动重新验证服务将在 {min_interval_minutes} 分钟后首次检查代理" + ) + await asyncio.sleep(interval_seconds) + if not self.running: + break + else: + logger.info( + f"自动重新验证服务将在 {min_interval_minutes} 分钟后检查代理" + ) + await asyncio.sleep(interval_seconds) + if not self.running: + break + + logger.info("开始自动重新验证代理...") + await self._revalidate_proxies() + logger.info("自动重新验证完成") + + if self._dedup_cache is not None: + try: + await self._dedup_cache.rebuild(self._get_connection_pool()) + logger.debug("验证后去重缓存已重建") + except Exception as e: + logger.warning(f"验证后重建去重缓存失败(非致命): {e}") + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"自动重新验证错误: {e}", exc_info=True) + await asyncio.sleep(60) + + def _get_connection_pool(self): + if hasattr(self.repository, '_pool'): + return self.repository._pool + return None + + async def _revalidate_proxies(self) -> None: + config = self.config_manager.get_config() + + endpoints = getattr(self.validator, "endpoints", None) + if endpoints is not None: + await endpoints.ensure_ready() + if not endpoints.network_available(): + logger.warning( + "本机网络不可用(所有检测端点直连失败),本轮重新验证已跳过" + ) + return + + all_proxies = [] + page = 1 + page_size = 5000 + + if config.auto_revalidation.only_valid_proxies: + filter_obj = ProxyFilter(is_valid=True) + logger.info("只重验证当前有效的代理") + else: + filter_obj = ProxyFilter(is_valid=None) + + while True: + batch = await self.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 + + unvalidated = await self._fetch_unvalidated_proxies() + if unvalidated: + existing_ids = {p.id for p in all_proxies} + appended = [p for p in unvalidated if p.id not in existing_ids] + for p in appended: + all_proxies.append(p) + if appended: + logger.info(f"追加 {len(appended)} 个从未验证过的历史记录") + + proxies = all_proxies + + if not proxies: + logger.info("没有需要重新验证的代理") + return + + logger.info(f"找到 {len(proxies)} 个代理需要重新验证") + + proxies_by_plugin = {} + for proxy in proxies: + plugin_name = proxy.source_plugin + if plugin_name not in proxies_by_plugin: + proxies_by_plugin[plugin_name] = [] + proxies_by_plugin[plugin_name].append(proxy) + + plugin_configs = await self._get_plugin_config_map() + + for plugin_name, plugin_proxies in proxies_by_plugin.items(): + plugin_config = plugin_configs.get(plugin_name) + reval_enabled = plugin_config.reval_enabled if plugin_config else True + reval_interval = plugin_config.reval_interval_minutes if plugin_config else 0 + + if not reval_enabled: + logger.info(f"插件 {plugin_name} 的自动验证已禁用,跳过") + continue + + plugin_interval_minutes = ( + reval_interval if reval_interval > 0 + else config.auto_revalidation.interval_minutes + ) + + test_url = resolve_test_url(plugin_config, config.validator.target_url) + + now = datetime.now() + proxies_to_validate = [] + + for proxy in plugin_proxies: + if proxy.validated_at is None: + proxies_to_validate.append(proxy) + else: + time_since_validation = now - proxy.validated_at + if time_since_validation.total_seconds() >= plugin_interval_minutes * 60: + proxies_to_validate.append(proxy) + + if not proxies_to_validate: + logger.info(f"插件 {plugin_name} 的代理暂时不需要验证(未到验证间隔)") + continue + + logger.info( + f"开始验证插件 {plugin_name} 的 {len(proxies_to_validate)} 个代理" + f"(间隔:{plugin_interval_minutes}分钟)" + ) + + tasks = [ + self._validate_and_update(proxy, test_url) + for proxy in proxies_to_validate + ] + await asyncio.gather(*tasks, return_exceptions=True) + + async def _fetch_unvalidated_proxies(self) -> list: + try: + return await self.repository.find_without_checks() + except Exception as e: + logger.warning(f"获取未验证代理失败(非致命): {e}") + return [] + + def _pick_mode(self, proxy): + return pick_validation_mode(proxy, self.config_manager.get_config()) + + async def _validate_and_update(self, proxy, test_url: str) -> None: + try: + proxy_url = proxy.proxy_url + mode = self._pick_mode(proxy) + + result = await self.validator.validate_proxy( + proxy_url, mode, test_url=test_url + ) + + action, applied = await dispatch_validation_action( + result, mode=mode, proxy=proxy, + cleanup=lambda res, fields: cleanup_reason_for( + self.config_manager.get_config(), proxy, res, fields), + sink=lambda applied: self.write_queue.enqueue( + build_update_operation(proxy, applied)), + cleanup_sink=lambda applied: self.write_queue.enqueue( + build_delete_operation(proxy)), + geo_predicate=lambda proxy, fields: needs_region(proxy, fields), + geo_sink=self.geo_resolver.schedule, + ) + if action == APPLY_INCONCLUSIVE: + logger.debug( + "代理 %s:%s 本轮结论不可用(%s),跳过更新", + proxy.ip, proxy.port, result.error_reason or result.outcome.value, + ) + return + + if action != APPLY_APPLIED: + logger.debug( + f"自动清理代理 {proxy.ip}:{proxy.port}: {action}" + ) + return + + except Exception as e: + logger.warning(f"验证代理失败 {proxy.ip}:{proxy.port}: {e}") diff --git a/modules/proxypool/core/services/geo_names.py b/modules/proxypool/core/services/geo_names.py new file mode 100644 index 0000000..cc5a8ba --- /dev/null +++ b/modules/proxypool/core/services/geo_names.py @@ -0,0 +1,236 @@ +""" +模块名称:modules.proxypool.core.services.geo_names +功能描述:归属地名称的翻译与回退:把规范国码 / 细分代码与多语言名字渲染成中英两套可展示名称,缺译名时回退为另一语言的原文。 +职责边界:负责:名称对照表、按语言渲染名称、标记未翻译的原文;不负责:查 IP(见 geodb)、缓存(见 geoip 服务)、写库(见 geo_resolver)。 +关键依赖:core.domain.models(GeoName / GeoRecord);标准库 typing。 +已知限制: + 1. 对照表为有限集,未收录的行政区回退为另一语言的原文并置对应的 untranslated 标记,译名覆盖不保证完整。 + 2. 一级行政区只按 ISO 3166-2 细分代码建键,subdivision_zh 查询前把国码与细分码转大写,代码缺失时返回 None。 + 3. 中文短名查英文表前会剥后缀:china_province_en 反复剥省 / 市 / 自治区 / 特别行政区与民族后缀,china_city_en 只剥一个「市」,长名可能查不到。 + 4. render_names 各字段独立回退,中英两侧都取不到译名时会显示同一个值,untranslated 的单个布尔无法区分哪一侧取到的是原文。 + 5. 国码字段没有对照表回退,仅一侧有名字时两侧显示同一文本,untranslated["country"] 表示恰好一侧为空。 + 6. 非中国记录的英文省与城市字段不做中文回退,可能保持 UNKNOWN 且不置对应标记;UNKNOWN(「未知」)是任何一段都可能返回的占位串。 +""" + +import logging +from typing import Mapping, Optional + +from core.domain.models import GeoName, GeoRecord + +logger = logging.getLogger(__name__) + +CHINA_PROVINCE_EN: Mapping[str, str] = { + "北京": "Beijing", "天津": "Tianjin", "河北": "Hebei", "山西": "Shanxi", + "内蒙古": "Inner Mongolia", "辽宁": "Liaoning", "吉林": "Jilin", + "黑龙江": "Heilongjiang", "上海": "Shanghai", "江苏": "Jiangsu", + "浙江": "Zhejiang", "安徽": "Anhui", "福建": "Fujian", "江西": "Jiangxi", + "山东": "Shandong", "河南": "Henan", "湖北": "Hubei", "湖南": "Hunan", + "广东": "Guangdong", "广西": "Guangxi", "海南": "Hainan", "重庆": "Chongqing", + "四川": "Sichuan", "贵州": "Guizhou", "云南": "Yunnan", "西藏": "Tibet", + "陕西": "Shaanxi", "甘肃": "Gansu", "青海": "Qinghai", "宁夏": "Ningxia", + "新疆": "Xinjiang", "香港": "Hong Kong", "澳门": "Macau", "台湾": "Taiwan", +} + +CHINA_CITY_EN: Mapping[str, str] = { + "北京": "Beijing", "上海": "Shanghai", "天津": "Tianjin", "重庆": "Chongqing", + "石家庄": "Shijiazhuang", "太原": "Taiyuan", "呼和浩特": "Hohhot", + "沈阳": "Shenyang", "长春": "Changchun", "哈尔滨": "Harbin", + "南京": "Nanjing", "杭州": "Hangzhou", "合肥": "Hefei", "福州": "Fuzhou", + "南昌": "Nanchang", "济南": "Jinan", "郑州": "Zhengzhou", "武汉": "Wuhan", + "长沙": "Changsha", "广州": "Guangzhou", "南宁": "Nanning", "海口": "Haikou", + "成都": "Chengdu", "贵阳": "Guiyang", "昆明": "Kunming", "拉萨": "Lhasa", + "西安": "Xi'an", "兰州": "Lanzhou", "西宁": "Xining", "银川": "Yinchuan", + "乌鲁木齐": "Urumqi", "台北": "Taipei", "香港": "Hong Kong", "澳门": "Macau", + "深圳": "Shenzhen", "厦门": "Xiamen", "宁波": "Ningbo", "青岛": "Qingdao", + "大连": "Dalian", "苏州": "Suzhou", "无锡": "Wuxi", "温州": "Wenzhou", + "佛山": "Foshan", "东莞": "Dongguan", "珠海": "Zhuhai", "汕头": "Shantou", + "泉州": "Quanzhou", "烟台": "Yantai", "潍坊": "Weifang", "徐州": "Xuzhou", + "常州": "Changzhou", "南通": "Nantong", "扬州": "Yangzhou", "盐城": "Yancheng", + "泰州": "Taizhou", "镇江": "Zhenjiang", "绍兴": "Shaoxing", "嘉兴": "Jiaxing", + "台州": "Taizhou", "金华": "Jinhua", "湖州": "Huzhou", "丽水": "Lishui", + "衢州": "Quzhou", "舟山": "Zhoushan", "芜湖": "Wuhu", "蚌埠": "Bengbu", + "洛阳": "Luoyang", "新乡": "Xinxiang", "开封": "Kaifeng", + "南阳": "Nanyang", "商丘": "Shangqiu", "周口": "Zhoukou", "信阳": "Xinyang", + "襄阳": "Xiangyang", "宜昌": "Yichang", "荆州": "Jingzhou", "黄石": "Huangshi", + "十堰": "Shiyan", "株洲": "Zhuzhou", "湘潭": "Xiangtan", "衡阳": "Hengyang", + "岳阳": "Yueyang", "常德": "Changde", "郴州": "Chenzhou", + "中山": "Zhongshan", "江门": "Jiangmen", "湛江": "Zhanjiang", "茂名": "Maoming", + "肇庆": "Zhaoqing", "揭阳": "Jieyang", "潮州": "Chaozhou", "梅州": "Meizhou", + "惠州": "Huizhou", "河源": "Heyuan", "清远": "Qingyuan", "韶关": "Shaoguan", + "阳江": "Yangjiang", "云浮": "Yunfu", "汕尾": "Shanwei", "桂林": "Guilin", + "柳州": "Liuzhou", "北海": "Beihai", "三亚": "Sanya", "绵阳": "Mianyang", + "德阳": "Deyang", "宜宾": "Yibin", "泸州": "Luzhou", "南充": "Nanchong", + "遵义": "Zunyi", "曲靖": "Qujing", "大理": "Dali", "咸阳": "Xianyang", + "宝鸡": "Baoji", "渭南": "Weinan", "榆林": "Yulin", "天水": "Tianshui", + "包头": "Baotou", "鄂尔多斯": "Ordos", "赤峰": "Chifeng", "大庆": "Daqing", + "齐齐哈尔": "Qiqihar", "吉林": "Jilin", "鞍山": "Anshan", "抚顺": "Fushun", + "锦州": "Jinzhou", "营口": "Yingkou", "唐山": "Tangshan", "保定": "Baoding", + "廊坊": "Langfang", "秦皇岛": "Qinhuangdao", "邯郸": "Handan", "沧州": "Cangzhou", + "邢台": "Xingtai", "张家口": "Zhangjiakou", "承德": "Chengde", "大同": "Datong", + "临汾": "Linfen", "运城": "Yuncheng", "长治": "Changzhi", "晋中": "Jinzhong", + "赣州": "Ganzhou", "九江": "Jiujiang", "上饶": "Shangrao", "宜春": "Yichun", + "吉安": "Ji'an", "抚州": "Fuzhou", "景德镇": "Jingdezhen", "萍乡": "Pingxiang", + "新余": "Xinyu", "鹰潭": "Yingtan", "莆田": "Putian", "三明": "Sanming", + "南平": "Nanping", "龙岩": "Longyan", "宁德": "Ningde", "漳州": "Zhangzhou", +} + +SUBDIVISION_ZH: Mapping[tuple, str] = { + ("US", "AL"): "亚拉巴马州", ("US", "AK"): "阿拉斯加州", ("US", "AZ"): "亚利桑那州", + ("US", "AR"): "阿肯色州", ("US", "CA"): "加利福尼亚州", ("US", "CO"): "科罗拉多州", + ("US", "CT"): "康涅狄格州", ("US", "DE"): "特拉华州", ("US", "DC"): "哥伦比亚特区", + ("US", "FL"): "佛罗里达州", ("US", "GA"): "佐治亚州", ("US", "HI"): "夏威夷州", + ("US", "ID"): "爱达荷州", ("US", "IL"): "伊利诺伊州", ("US", "IN"): "印第安纳州", + ("US", "IA"): "艾奥瓦州", ("US", "KS"): "堪萨斯州", ("US", "KY"): "肯塔基州", + ("US", "LA"): "路易斯安那州", ("US", "ME"): "缅因州", ("US", "MD"): "马里兰州", + ("US", "MA"): "马萨诸塞州", ("US", "MI"): "密歇根州", ("US", "MN"): "明尼苏达州", + ("US", "MS"): "密西西比州", ("US", "MO"): "密苏里州", ("US", "MT"): "蒙大拿州", + ("US", "NE"): "内布拉斯加州", ("US", "NV"): "内华达州", ("US", "NH"): "新罕布什尔州", + ("US", "NJ"): "新泽西州", ("US", "NM"): "新墨西哥州", ("US", "NY"): "纽约州", + ("US", "NC"): "北卡罗来纳州", ("US", "ND"): "北达科他州", ("US", "OH"): "俄亥俄州", + ("US", "OK"): "俄克拉何马州", ("US", "OR"): "俄勒冈州", ("US", "PA"): "宾夕法尼亚州", + ("US", "RI"): "罗得岛州", ("US", "SC"): "南卡罗来纳州", ("US", "SD"): "南达科他州", + ("US", "TN"): "田纳西州", ("US", "TX"): "得克萨斯州", ("US", "UT"): "犹他州", + ("US", "VT"): "佛蒙特州", ("US", "VA"): "弗吉尼亚州", ("US", "WA"): "华盛顿州", + ("US", "WV"): "西弗吉尼亚州", ("US", "WI"): "威斯康星州", ("US", "WY"): "怀俄明州", + ("JP", "01"): "北海道", ("JP", "02"): "青森县", ("JP", "03"): "岩手县", + ("JP", "04"): "宫城县", ("JP", "05"): "秋田县", ("JP", "06"): "山形县", + ("JP", "07"): "福岛县", ("JP", "08"): "茨城县", ("JP", "09"): "栃木县", + ("JP", "10"): "群马县", ("JP", "11"): "埼玉县", ("JP", "12"): "千叶县", + ("JP", "13"): "东京都", ("JP", "14"): "神奈川县", ("JP", "15"): "新潟县", + ("JP", "16"): "富山县", ("JP", "17"): "石川县", ("JP", "18"): "福井县", + ("JP", "19"): "山梨县", ("JP", "20"): "长野县", ("JP", "21"): "岐阜县", + ("JP", "22"): "静冈县", ("JP", "23"): "爱知县", ("JP", "24"): "三重县", + ("JP", "25"): "滋贺县", ("JP", "26"): "京都府", ("JP", "27"): "大阪府", + ("JP", "28"): "兵库县", ("JP", "29"): "奈良县", ("JP", "30"): "和歌山县", + ("JP", "31"): "鸟取县", ("JP", "32"): "岛根县", ("JP", "33"): "冈山县", + ("JP", "34"): "广岛县", ("JP", "35"): "山口县", ("JP", "36"): "德岛县", + ("JP", "37"): "香川县", ("JP", "38"): "爱媛县", ("JP", "39"): "高知县", + ("JP", "40"): "福冈县", ("JP", "41"): "佐贺县", ("JP", "42"): "长崎县", + ("JP", "43"): "熊本县", ("JP", "44"): "大分县", ("JP", "45"): "宫崎县", + ("JP", "46"): "鹿儿岛县", ("JP", "47"): "冲绳县", + ("KR", "11"): "首尔特别市", ("KR", "26"): "釜山广域市", ("KR", "27"): "大邱广域市", + ("KR", "28"): "仁川广域市", ("KR", "29"): "光州广域市", ("KR", "30"): "大田广域市", + ("KR", "31"): "蔚山广域市", ("KR", "41"): "京畿道", ("KR", "42"): "江原特别自治道", + ("KR", "43"): "忠清北道", ("KR", "44"): "忠清南道", ("KR", "45"): "全北特别自治道", + ("KR", "46"): "全罗南道", ("KR", "47"): "庆尚北道", ("KR", "48"): "庆尚南道", + ("KR", "49"): "济州特别自治道", ("KR", "50"): "世宗特别自治市", + ("CA", "AB"): "艾伯塔省", ("CA", "BC"): "不列颠哥伦比亚省", ("CA", "MB"): "曼尼托巴省", + ("CA", "NB"): "新不伦瑞克省", ("CA", "NL"): "纽芬兰与拉布拉多省", + ("CA", "NS"): "新斯科舍省", ("CA", "NT"): "西北地区", ("CA", "NU"): "努纳武特地区", + ("CA", "ON"): "安大略省", ("CA", "PE"): "爱德华王子岛省", ("CA", "QC"): "魁北克省", + ("CA", "SK"): "萨斯喀彻温省", ("CA", "YT"): "育空地区", + ("AU", "ACT"): "澳大利亚首都领地", ("AU", "NSW"): "新南威尔士州", + ("AU", "NT"): "北领地", ("AU", "QLD"): "昆士兰州", ("AU", "SA"): "南澳大利亚州", + ("AU", "TAS"): "塔斯马尼亚州", ("AU", "VIC"): "维多利亚州", + ("AU", "WA"): "西澳大利亚州", + ("GB", "ENG"): "英格兰", ("GB", "SCT"): "苏格兰", ("GB", "WLS"): "威尔士", + ("GB", "NIR"): "北爱尔兰", + ("DE", "BW"): "巴登-符腾堡州", ("DE", "BY"): "巴伐利亚州", ("DE", "BE"): "柏林州", + ("DE", "BB"): "勃兰登堡州", ("DE", "HB"): "不来梅州", ("DE", "HH"): "汉堡州", + ("DE", "HE"): "黑森州", ("DE", "MV"): "梅克伦堡-前波美拉尼亚州", + ("DE", "NI"): "下萨克森州", ("DE", "NW"): "北莱茵-威斯特法伦州", + ("DE", "RP"): "莱茵兰-普法尔茨州", ("DE", "SL"): "萨尔州", ("DE", "SN"): "萨克森州", + ("DE", "ST"): "萨克森-安哈尔特州", ("DE", "SH"): "石勒苏益格-荷尔斯泰因州", + ("DE", "TH"): "图林根州", + ("FR", "IDF"): "法兰西岛大区", ("FR", "ARA"): "奥弗涅-罗讷-阿尔卑斯大区", + ("FR", "BFC"): "勃艮第-弗朗什-孔泰大区", ("FR", "BRE"): "布列塔尼大区", + ("FR", "CVL"): "中央-卢瓦尔河谷大区", ("FR", "COR"): "科西嘉大区", + ("FR", "GES"): "大东部大区", ("FR", "HDF"): "上法兰西大区", + ("FR", "NOR"): "诺曼底大区", ("FR", "NAQ"): "新阿基坦大区", + ("FR", "OCC"): "奥克西塔尼大区", ("FR", "PDL"): "卢瓦尔河地区大区", + ("FR", "PAC"): "普罗旺斯-阿尔卑斯-蔚蓝海岸大区", + ("NL", "NH"): "北荷兰省", ("NL", "ZH"): "南荷兰省", ("NL", "UT"): "乌得勒支省", + ("NL", "NB"): "北布拉班特省", ("NL", "GE"): "海尔德兰省", ("NL", "OV"): "上艾瑟尔省", + ("NL", "FR"): "弗里斯兰省", ("NL", "GR"): "格罗宁根省", ("NL", "DR"): "德伦特省", + ("NL", "FL"): "弗莱福兰省", ("NL", "ZE"): "泽兰省", ("NL", "LI"): "林堡省", + ("SG", "01"): "中区", ("SG", "02"): "东北区", ("SG", "03"): "西北区", + ("SG", "04"): "东南区", ("SG", "05"): "西南区", + ("HK", "HK"): "香港", ("MO", "MO"): "澳门", + ("TW", "TPE"): "台北市", ("TW", "TPQ"): "新北市", ("TW", "TXG"): "台中市", + ("TW", "TNN"): "台南市", ("TW", "KHH"): "高雄市", ("TW", "HSZ"): "新竹市", + ("TW", "HSQ"): "新竹县", ("TW", "TAO"): "桃园市", ("TW", "ILA"): "宜兰县", + ("TW", "KEE"): "基隆市", ("TW", "CYI"): "嘉义市", ("TW", "CYQ"): "嘉义县", + ("TW", "CHA"): "彰化县", ("TW", "YUN"): "云林县", ("TW", "NAN"): "南投县", + ("TW", "PIF"): "屏东县", ("TW", "TTT"): "台东县", ("TW", "HUA"): "花莲县", + ("TW", "PEN"): "澎湖县", ("TW", "MIA"): "苗栗县", +} + +_CN_PROVINCE_SUFFIXES = ("省", "市", "自治区", "特别行政区", "壮族", "回族", "维吾尔") + +UNKNOWN = "未知" + + +def _strip_cn_suffix(name: str) -> str: + result = name + changed = True + while changed: + changed = False + for suffix in _CN_PROVINCE_SUFFIXES: + if result.endswith(suffix) and len(result) > len(suffix): + result = result[: -len(suffix)] + changed = True + return result + + +def subdivision_zh(country_code: str, subdivision_code: str) -> Optional[str]: + if not country_code or not subdivision_code: + return None + return SUBDIVISION_ZH.get((country_code.upper(), subdivision_code.upper())) + + +def china_province_en(name_zh: str) -> Optional[str]: + if not name_zh: + return None + return CHINA_PROVINCE_EN.get(name_zh) or CHINA_PROVINCE_EN.get(_strip_cn_suffix(name_zh)) + + +def china_city_en(name_zh: str) -> Optional[str]: + if not name_zh: + return None + short = name_zh[:-1] if name_zh.endswith("市") else name_zh + return CHINA_CITY_EN.get(name_zh) or CHINA_CITY_EN.get(short) + + +def render_names(record: GeoRecord) -> tuple: + country_code = record.country_code + sub_code = record.subdivision_code + + zh_country_text = record.country_names.get("zh-CN") or "" + en_country_text = record.country_names.get("en") or "" + + untranslated = {"country": False, "province": False, "city": False} + untranslated["country"] = bool(zh_country_text) != bool(en_country_text) + + zh_country = zh_country_text or en_country_text or UNKNOWN + en_country = en_country_text or zh_country_text or UNKNOWN + + zh_sub = record.subdivision_names.get("zh-CN") or "" + if not zh_sub: + zh_sub = subdivision_zh(country_code, sub_code) or "" + if not zh_sub: + zh_sub = record.subdivision_names.get("en") or "" + untranslated["province"] = bool(zh_sub) + + en_sub = record.subdivision_names.get("en") or "" + if record.is_china and not en_sub: + en_sub = china_province_en(zh_sub) or "" + if not en_sub: + en_sub = zh_sub + untranslated["province"] = bool(en_sub) + + zh_city = record.city_names.get("zh-CN") or "" + if not zh_city: + zh_city = record.city_names.get("en") or "" + if zh_city: + untranslated["city"] = True + + en_city = record.city_names.get("en") or "" + if record.is_china and not en_city: + en_city = china_city_en(zh_city) or "" + if not en_city: + en_city = zh_city + untranslated["city"] = bool(en_city) + + zh = GeoName(country=zh_country, province=zh_sub or UNKNOWN, city=zh_city or UNKNOWN) + en = GeoName(country=en_country, province=en_sub or UNKNOWN, city=en_city or UNKNOWN) + return zh, en, untranslated diff --git a/modules/proxypool/core/services/geo_resolver.py b/modules/proxypool/core/services/geo_resolver.py new file mode 100644 index 0000000..e86b5ef --- /dev/null +++ b/modules/proxypool/core/services/geo_resolver.py @@ -0,0 +1,288 @@ +""" +模块名称:modules.proxypool.core.services.geo_resolver +功能描述:出口 IP 归属地的异步解析流水线:验证阶段只上报出口 IP,查询与写库全部由后台消费者完成,不占验证的墙钟时间。 +职责边界:负责:出口 IP 去重入队、后台消费解析、结果合并写回 region 系列字段、随应用启停; + 不负责:查询与缓存(GeoIPService)、出口 IP 来源与验证时机(validator)。 +关键依赖:IGeoIPService、IWriteQueue、GeoName / ProxyFilter / DBOperation、标准库 asyncio。 +已知限制: + 1. 队列、去重集合与尝试计数为进程内内存态,重启后未处理请求丢失;出口 IP 不变的代理要等下一次完整评估或启动补漏(一次最多 5000 条)才重新入队。 + 2. 队列满时丢弃新请求且不阻塞调用方;每条任务无论成败都会从 _pending 移除并 task_done(),漏掉会让该 IP 不再被重新入队。 + 3. 同一 IP 在 1800 秒窗口内最多尝试 3 次,窗口过期自动重新计数,解析成功即清零。 + 4. 更新以 where={"real_ip": ip} 定位,同一出口 IP 的一批代理共享一次查询与一次 UPDATE;中英地名各自独立判断,两套都没拿到时不写库、也不清尝试计数。 + 5. 与库里旧值合并时,只有两侧都是真实国家名且不同才整条以本次为准,其余情况本次缺省的字段沿用旧值。 + 6. 所有方法必须在池的事件循环线程内调用;实例不加锁,队列、去重集合与尝试计数都不做跨线程同步。 +""" + +import asyncio +import logging +import time +from typing import Optional + +from core.domain.models import DBOperation, GeoName, ProxyFilter +from core.interfaces.infrastructure import IWriteQueue +from core.interfaces.services import IGeoIPService + +logger = logging.getLogger(__name__) + +_QUEUE_SIZE = 2000 + +_WORKER_COUNT = 4 + +_ATTEMPT_WINDOW_SECONDS = 1800.0 +_MAX_ATTEMPTS_PER_IP_IN_WINDOW = 3 + +_AUTO_RECOMPUTE_LIMIT = 5000 + + +_UNKNOWN_NAME = "未知" + + +def _known_name(value: Optional[str]) -> str: + text = (value or "").strip() + return text if text and text != _UNKNOWN_NAME else "" + + +def _merge_with_existing(name: GeoName, existing: Optional[dict], suffix: str) -> GeoName: + if not existing: + return name + + old_country = _known_name(existing.get("country" + suffix)) + new_country = _known_name(name.country) + if old_country and new_country and old_country != new_country: + return name + + country = name.country + if not new_country: + country = old_country or name.country + + province = name.province + if province == _UNKNOWN_NAME: + province = existing.get("province" + suffix) or _UNKNOWN_NAME + + city = name.city + if city == _UNKNOWN_NAME: + city = existing.get("city" + suffix) or _UNKNOWN_NAME + + return GeoName(country=country, province=province, city=city) + + +class GeoResolver: + + def __init__( + self, + geoip_service: IGeoIPService, + write_queue: IWriteQueue, + proxy_repository=None, + geodb=None, + ): + self._geoip = geoip_service + self._write_queue = write_queue + self._repository = proxy_repository + self._geodb = geodb + + self._queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_SIZE) + self._pending: set[str] = set() + self._attempts: dict[str, tuple[float, int]] = {} + self._workers: list[asyncio.Task] = [] + self._running = False + self._resolved_count = 0 + self._dropped_count = 0 + + async def start(self) -> bool: + if self._running: + return False + + self._running = True + self._workers = [ + asyncio.create_task(self._consume(), name=f"geo_resolver_{index}") + for index in range(_WORKER_COUNT) + ] + logger.info( + "归属地解析流水线已启动(队列容量 %d,并发 %d)", _QUEUE_SIZE, _WORKER_COUNT + ) + await self._backfill_missing() + return True + + async def _backfill_missing(self) -> None: + if self._repository is None: + return + + offline_ready = bool(self._geodb is not None and self._geodb.available) + try: + page = 1 + queued = 0 + skipped = 0 + while True: + proxies = await self._repository.find( + ProxyFilter(is_valid=True), page=page, page_size=5000 + ) + if not proxies: + break + for proxy in proxies: + if not proxy.real_ip: + continue + no_region = ( + not (proxy.region_en or "").strip() + and (proxy.region or "未知") == "未知" + ) + missing_codes = offline_ready and proxy.country_code is None + if not (no_region or missing_codes): + continue + if queued >= _AUTO_RECOMPUTE_LIMIT: + skipped += 1 + continue + if self.schedule(proxy.real_ip): + queued += 1 + if len(proxies) < 5000: + break + page += 1 + + if queued: + logger.info( + "归属地补漏:%d 个代理入队(没有地区 / 缺规范代码)", queued + ) + if skipped: + logger.info( + "归属地补漏:另有 %d 个超出本次上限(%d)未入队," + "可在面板上手动触发「重算归属地」", + skipped, _AUTO_RECOMPUTE_LIMIT, + ) + except Exception as e: + logger.warning("归属地补漏失败(非致命): %s", e) + + async def stop(self) -> None: + self._running = False + + workers, self._workers = self._workers, [] + for worker in workers: + if not worker.done(): + worker.cancel() + if workers: + await asyncio.gather(*workers, return_exceptions=True) + + abandoned = len(self._pending) + self._pending.clear() + self._attempts.clear() + while not self._queue.empty(): + try: + self._queue.get_nowait() + self._queue.task_done() + except asyncio.QueueEmpty: + break + + if abandoned or self._resolved_count or self._dropped_count: + logger.info( + "归属地解析流水线已停止(已解析 %d 条,放弃 %d 条,队列满丢弃 %d 条)", + self._resolved_count, abandoned, self._dropped_count, + ) + + def schedule(self, ip: str) -> bool: + if not self._running or not ip: + return False + + if ip in self._pending: + return False + + now = time.monotonic() + window_start, count = self._attempts.get(ip, (now, 0)) + if now - window_start >= _ATTEMPT_WINDOW_SECONDS: + window_start, count = now, 0 + if count >= _MAX_ATTEMPTS_PER_IP_IN_WINDOW: + logger.debug("归属地解析: %s 在本窗口内已尝试 %d 次,跳过", ip, count) + return False + + try: + self._queue.put_nowait(ip) + except asyncio.QueueFull: + self._dropped_count += 1 + logger.debug("归属地解析队列已满,丢弃: %s", ip) + return False + + self._pending.add(ip) + self._attempts[ip] = (window_start, count + 1) + return True + + async def _consume(self) -> None: + while self._running: + try: + ip = await self._queue.get() + except asyncio.CancelledError: + raise + + try: + await self._resolve(ip) + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug("归属地解析失败 %s: %s", ip, e) + finally: + self._pending.discard(ip) + self._queue.task_done() + + async def _load_existing_geo(self, ip: str) -> Optional[dict]: + if self._repository is None: + return None + try: + return await self._repository.get_geo_fields_by_real_ip(ip) + except Exception as e: + logger.debug("读取 %s 的现有归属地失败(按无旧值处理): %s", ip, e) + return None + + async def _resolve(self, ip: str) -> None: + location = await self._geoip.get_location(ip) + + existing = await self._load_existing_geo(ip) + zh = _merge_with_existing(location.zh, existing, suffix="") + en = _merge_with_existing(location.en, existing, suffix="_en") + + updates: dict = {} + if location.zh.is_known: + updates.update({ + "region": zh.to_string(), + "country": zh.country, + "province": zh.province, + "city": zh.city, + }) + if location.en.is_known: + updates.update({ + "region_en": en.to_string(), + "country_en": en.country, + "province_en": en.province, + "city_en": en.city, + }) + + if location.source: + updates["geo_source"] = location.source + + if location.country_code: + updates.update({ + "country_code": location.country_code, + "subdivision_code": location.subdivision_code, + }) + + if not updates: + logger.debug("归属地查询未拿到任何信息,不写库: %s", ip) + return + + self._attempts.pop(ip, None) + + await self._write_queue.enqueue( + DBOperation( + operation_type="update", + table="proxies", + data=updates, + where={"real_ip": ip}, + ) + ) + self._resolved_count += 1 + logger.debug("归属地已解析: %s -> %s", ip, location.to_string()) + + def stats(self) -> dict: + return { + "running": self._running, + "workers": len(self._workers), + "queued": self._queue.qsize(), + "pending": len(self._pending), + "resolved": self._resolved_count, + "dropped": self._dropped_count, + } diff --git a/modules/proxypool/core/services/geoip.py b/modules/proxypool/core/services/geoip.py new file mode 100644 index 0000000..6950f6d --- /dev/null +++ b/modules/proxypool/core/services/geoip.py @@ -0,0 +1,101 @@ +""" +模块名称:modules.proxypool.core.services.geoip +功能描述:IP 归属地查询服务:查本地离线库并按 IP 缓存,一次查询给出中英两套名称与规范代码。 +职责边界:负责:查离线库、按 IP 缓存、渲染中英名称与代码。 + 不负责:数据文件更新(core.infrastructure.geodb)、查询时机与结果写回(GeoResolver)。 +关键依赖:core.infrastructure.geodb、core.services.geo_names、core.domain.models、core.interfaces.services。 +已知限制: +1. 离线库缺失或损坏时不抛异常,一律返回全「未知」的 GeoLocation;load_database 返回 False 并记 WARNING。 +2. 缓存 TTL 分两档:中英齐全 24 小时,只拿到一套或未查到 1 小时;上限 10000 条,超出后按写入时间淘汰最旧的一半。 +3. 调用方须先看 is_known 再决定是否写库,否则「未知」会覆盖库里已有的归属地。 +4. 缓存是进程内内存态且不加锁,所有读写都必须在池的事件循环线程内进行。 +5. close() 只记日志、缓存保留,关闭不等同于失效。 +6. 查询是内存映射上的二分查找、无 await 点,async 签名仅为满足 IGeoIPService 契约。 +""" + +import logging +import time +from typing import Optional + +from core.interfaces.services import IGeoIPService +from core.domain.models import GeoLocation +from core.infrastructure.geodb import GeoDatabase +from core.services.geo_names import render_names + +logger = logging.getLogger(__name__) + +_CACHE_TTL_SECONDS = 24 * 3600 + +_PARTIAL_TTL_SECONDS = 3600 + +_MAX_CACHE_ENTRIES = 10000 + + +class GeoIPService(IGeoIPService): + + def __init__(self, geodb=None): + self._geodb = GeoDatabase() if geodb is None else geodb + + self._location_cache: dict[str, tuple[float, float, GeoLocation]] = {} + + def load_database(self) -> bool: + if self._geodb.available: + logger.info("离线归属地库已就绪(GeoLite2 + 纯真)") + return True + + logger.warning( + "离线归属地库不可用:数据文件缺失或损坏,所有代理的归属地都将是空的。" + "可在面板的「代理池 → 数据库 → 归属地库」点「更新归属地库」下载," + "或执行 python -m core.infrastructure.geodb" + ) + return False + + async def get_location(self, ip: str) -> GeoLocation: + now = time.time() + + cached = self._location_cache.get(ip) + if cached is not None: + cached_at, ttl, location = cached + if now - cached_at < ttl: + return location + del self._location_cache[ip] + + result = self._lookup_offline(ip) + if result is None: + self._location_cache[ip] = (now, _PARTIAL_TTL_SECONDS, GeoLocation()) + return GeoLocation() + + ttl = _CACHE_TTL_SECONDS if result.is_complete else _PARTIAL_TTL_SECONDS + self._location_cache[ip] = (now, ttl, result) + if len(self._location_cache) > _MAX_CACHE_ENTRIES: + self._trim_location_cache() + return result + + def _lookup_offline(self, ip: str) -> Optional[GeoLocation]: + if self._geodb is None or not self._geodb.available: + return None + try: + record = self._geodb.lookup(ip) + if record is None: + return None + zh, en, untranslated = render_names(record) + except Exception as e: + logger.warning(f"离线归属地查询失败 {ip}: {e}") + return None + + return GeoLocation( + zh=zh, + en=en, + country_code=record.country_code, + subdivision_code=record.subdivision_code, + source=record.source, + untranslated=untranslated, + ) + + def _trim_location_cache(self) -> None: + entries = sorted(self._location_cache.items(), key=lambda item: item[1][0]) + for ip, _ in entries[:len(entries) // 2]: + del self._location_cache[ip] + + def close(self) -> None: + logger.debug("GeoIP 服务已关闭") diff --git a/modules/proxypool/core/services/health_scorer.py b/modules/proxypool/core/services/health_scorer.py new file mode 100644 index 0000000..e99c2bb --- /dev/null +++ b/modules/proxypool/core/services/health_scorer.py @@ -0,0 +1,204 @@ +""" +模块名称:modules.proxypool.core.services.health_scorer +功能描述:把单条代理的延迟、成功率、稳定性、可用时长、匿名等级与存活时间折算为 0-100 健康分,供列表排序与自动清理的阈值判定使用;并提供延迟均值、方差与累计有效时长的增量更新。 +职责边界:负责:单条代理健康分计算与评分统计量的增量更新;不负责:读写代理记录、按分数做排序或删除决策、补测缺失数据。 +关键依赖:core.domain.models.Proxy(评分输入字段来源)。 +已知限制: +1. 权重之和未校验,和不为 1.0 时原始分仍会被裁剪到 0-100,各维度实际占比随之偏移。 +2. 成功率取 Wilson 置信下界(z=1.96)乘 100,小样本向低收缩,个位数样本全成功也非满分。 +3. 成功率样本先取请求级 probe_success_count / probe_total_count,其 total 为 0 时回退 success_count / total_checks。 +4. 均值与方差不加锁:同一代理的增量更新须由调用方串行执行;方差须先于均值更新并传入不含本次新值的旧均值,否则偏差被压小、方差被低估、稳定性分偏高;离群样本触及钳位时方差可能反向高估。 +5. 缺数据默认分:延迟缺失或非正、样本为 0 得 0;方差缺失得 50;uptime_ratio ≤ 0 与 age_hours ≤ 0 得 10;匿名等级不在表内(含 None)得 10。 +6. 累计有效时长按两次有效验证之间持续可用近似,时差非正(时钟回拨)或超过 30 天不计入;存活时间维度在 72 小时封顶。 +""" + +import logging +import math +from core.domain.models import Proxy + +logger = logging.getLogger(__name__) + + +class HealthScorer: + + DEFAULT_WEIGHT_DELAY = 0.25 + DEFAULT_WEIGHT_SUCCESS_RATE = 0.25 + DEFAULT_WEIGHT_STABILITY = 0.15 + DEFAULT_WEIGHT_UPTIME = 0.15 + DEFAULT_WEIGHT_ANONYMITY = 0.10 + DEFAULT_WEIGHT_AGE = 0.10 + + ANONYMITY_SCORES = { + 'elite': 100.0, + 'anonymous': 70.0, + 'transparent': 30.0, + 'unverified': 10.0, + } + + DELAY_EXCELLENT_THRESHOLD = 500.0 + DELAY_CUTOFF_THRESHOLD = 5000.0 + + AGE_MAX_HOURS = 72.0 + + VARIANCE_EXCELLENT_THRESHOLD = 100.0 + VARIANCE_CUTOFF_THRESHOLD = 10000.0 + + DELAY_OUTLIER_RATIO = 3.0 + DELAY_EMA_MIN_ALPHA = 0.2 + + def __init__( + self, + weight_delay: float | None = None, + weight_success_rate: float | None = None, + weight_age: float | None = None, + weight_anonymity: float | None = None, + weight_stability: float | None = None, + weight_uptime: float | None = None, + ): + self._w_delay = weight_delay if weight_delay is not None else self.DEFAULT_WEIGHT_DELAY + self._w_success = weight_success_rate if weight_success_rate is not None else self.DEFAULT_WEIGHT_SUCCESS_RATE + self._w_stability = weight_stability if weight_stability is not None else self.DEFAULT_WEIGHT_STABILITY + self._w_uptime = weight_uptime if weight_uptime is not None else self.DEFAULT_WEIGHT_UPTIME + self._w_age = weight_age if weight_age is not None else self.DEFAULT_WEIGHT_AGE + self._w_anonymity = weight_anonymity if weight_anonymity is not None else self.DEFAULT_WEIGHT_ANONYMITY + + def calculate(self, proxy: Proxy) -> float: + delay_score = self._score_delay(proxy.delay_ms) + success_score = self._score_success_rate(*self._success_samples(proxy)) + stability_score = self._score_stability(proxy.delay_variance) + uptime_score = self._score_uptime(proxy.uptime_ratio) + age_score = self._score_age(proxy.age_hours) + anonymity_score = self._score_anonymity(proxy.anonymity_level) + + raw_score = ( + self._w_delay * delay_score + + self._w_success * success_score + + self._w_stability * stability_score + + self._w_uptime * uptime_score + + self._w_age * age_score + + self._w_anonymity * anonymity_score + ) + + return round(min(100.0, max(0.0, raw_score)), 1) + + + def _score_delay(self, delay_ms: float | None) -> float: + if delay_ms is None or delay_ms <= 0: + return 0.0 + if delay_ms <= self.DELAY_EXCELLENT_THRESHOLD: + return 100.0 + if delay_ms >= self.DELAY_CUTOFF_THRESHOLD: + return 0.0 + range_width = self.DELAY_CUTOFF_THRESHOLD - self.DELAY_EXCELLENT_THRESHOLD + return 100.0 * (1.0 - (delay_ms - self.DELAY_EXCELLENT_THRESHOLD) / range_width) + + @staticmethod + def _success_samples(proxy: Proxy) -> tuple[int, int]: + if proxy.probe_total_count > 0: + return proxy.probe_success_count, proxy.probe_total_count + return proxy.success_count or 0, proxy.total_checks or 0 + + def _score_success_rate(self, success_count: int, total_checks: int) -> float: + return HealthScorer.wilson_lower_bound(success_count, total_checks) * 100.0 + + @staticmethod + def wilson_lower_bound( + success_count: int, total_checks: int, z: float = 1.96 + ) -> float: + if total_checks <= 0: + return 0.0 + + success_count = max(0, min(success_count, total_checks)) + n = float(total_checks) + p = success_count / n + z2 = z * z + denominator = 1.0 + z2 / n + centre = p + z2 / (2.0 * n) + margin = z * math.sqrt((p * (1.0 - p) / n) + (z2 / (4.0 * n * n))) + + return max(0.0, min(1.0, (centre - margin) / denominator)) + + def _score_stability(self, delay_variance: float | None) -> float: + if delay_variance is None: + return 50.0 + if delay_variance <= self.VARIANCE_EXCELLENT_THRESHOLD: + return 100.0 + if delay_variance >= self.VARIANCE_CUTOFF_THRESHOLD: + return 0.0 + range_width = self.VARIANCE_CUTOFF_THRESHOLD - self.VARIANCE_EXCELLENT_THRESHOLD + return 100.0 * (1.0 - (delay_variance - self.VARIANCE_EXCELLENT_THRESHOLD) / range_width) + + def _score_uptime(self, uptime_ratio: float) -> float: + if uptime_ratio <= 0: + return 10.0 + return max(0.0, min(100.0, uptime_ratio * 100.0)) + + def _score_age(self, age_hours: float) -> float: + if age_hours <= 0: + return 10.0 + if age_hours >= self.AGE_MAX_HOURS: + return 100.0 + return 10.0 + 90.0 * (age_hours / self.AGE_MAX_HOURS) + + def _score_anonymity(self, anonymity_level: str) -> float: + return self.ANONYMITY_SCORES.get(anonymity_level, 10.0) + + + @staticmethod + def clamp_delay_sample(current_avg: float | None, new_delay: float) -> float: + if current_avg is None or current_avg <= 0: + return new_delay + return min(new_delay, current_avg * HealthScorer.DELAY_OUTLIER_RATIO) + + @staticmethod + def calculate_avg_delay( + current_avg: float | None, + new_delay: float, + total_checks: int, + ) -> float: + if current_avg is None or current_avg <= 0 or total_checks <= 1: + return new_delay + + sample = HealthScorer.clamp_delay_sample(current_avg, new_delay) + alpha = max( + HealthScorer.DELAY_EMA_MIN_ALPHA, 1.0 / max(1, total_checks) + ) + return alpha * sample + (1.0 - alpha) * current_avg + + @staticmethod + def calculate_delay_variance( + current_variance: float | None, + current_avg: float | None, + new_delay: float, + total_checks: int, + ) -> float: + if total_checks <= 1 or current_avg is None or current_avg <= 0: + return 0.0 + + sample = HealthScorer.clamp_delay_sample(current_avg, new_delay) + alpha = max( + HealthScorer.DELAY_EMA_MIN_ALPHA, 1.0 / max(1, total_checks) + ) + delta = sample - current_avg + previous = current_variance or 0.0 + + return max(0.0, (1.0 - alpha) * (previous + alpha * delta * delta)) + + @staticmethod + def calculate_total_valid_seconds( + current_total: float, + last_valid_at: 'datetime | None', + is_valid: bool, + now: 'datetime | None' = None, + ) -> float: + if not is_valid or last_valid_at is None: + return current_total + + from datetime import datetime as dt + current_time = now or dt.now() + elapsed = (current_time - last_valid_at).total_seconds() + + if elapsed <= 0 or elapsed > 30 * 24 * 3600: + return current_total + + return current_total + elapsed diff --git a/modules/proxypool/core/services/plugin_manager.py b/modules/proxypool/core/services/plugin_manager.py new file mode 100644 index 0000000..c3df282 --- /dev/null +++ b/modules/proxypool/core/services/plugin_manager.py @@ -0,0 +1,717 @@ +""" +模块名称:modules.proxypool.core.services.plugin_manager +功能描述:抓取插件的发现、加载与周期调度:扫描 plugins/ 目录并热加载,按各插件配置的周期并行执行, + 抓取到的地址去重后交统一入库流水线入库;插件配置 skip_validation 时该流水线不经验证直接写库。 +职责边界:负责:插件扫描与热重载、周期调度与失败退避重试、并行限流、插件配置与验证策略的持久化、 + 错误记录;不负责:抓取结果的验证与写库(见 core.application.ingest)、代理质量判定。 +关键依赖:core.paths、modules.modules(sanitize_proxy)、core.services.validation_policy(resolve_test_url)、 + core.interfaces 契约、core.domain 模型与异常、core.application.ingest(函数内导入)、 + Config(仅类型标注)、注入的 DedupCache。 +已知限制: + 1. 插件模块以 plugin.<文件名> 注册进 sys.modules;若改用裸文件名,plugins/ 内的同名文件会顶掉同名标准库模块;模块加载执行抛错时立即撤回注册。 + 2. skip_validation 由本模块持久化并按插件生效,为真时 ingest_proxies 走直接入库路径,不做验证与初筛。 + 3. 去重判据是 ip:port 而非仅 ip,同一 IP 的不同端口是两个代理;DedupCache 未就绪时回落到全表扫描,整体去重失败时原样返回输入。 + 4. 执行返回值不是 list 时不推进 next_run,下一轮扫描会立即重试;已禁用的插件执行返回空列表而不抛异常。 + 5. 并发上限为 max(1, plugins.max_parallel_plugins),start() 不做幂等保护;stop() 等待在跑任务至多 10 秒。 + 6. stop() 等待配置落库的上限 5 秒必须保留:外层 ProxyPoolService.stop 的 35 秒预算按段累加。 + 7. 配置写库是异步的,方法返回时磁盘可能仍是旧值,stop() 靠持有任务引用等待落库;错误记录是进程内内存态,重启即清零。 + 8. 热重载先摘除旧实例再加载新版本,失败时放回旧实例,避免一次加载错误导致插件从面板上消失。 +""" + +import asyncio +import importlib.util +import inspect +import logging +import sys +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Optional, TYPE_CHECKING +from urllib.parse import urlparse + +from core.paths import resolve_pool_path +from modules.modules import sanitize_proxy +from core.interfaces.services import IPluginManager +from core.interfaces.repository import IPluginConfigRepository, IProxyRepository +from core.interfaces.infrastructure import IWriteQueue, IHttpClient + +if TYPE_CHECKING: + from core.config import Config +from core.interfaces.services import IProxyPlugin, PluginContext +from core.domain.models import PluginConfig, PluginStatus, Proxy +from core.services.validation_policy import resolve_test_url +from core.domain.exceptions import ( + InvalidParameterException, + PluginException, + PluginExecutionException +) + +logger = logging.getLogger(__name__) + + +def _is_http_url(url: str) -> bool: + parsed = urlparse(url) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + +_PLUGIN_MODULE_PREFIX = "plugin." + + +class LegacyPluginAdapter(IProxyPlugin): + def __init__(self, legacy_module: Any): + self._module = legacy_module + self._name = legacy_module.__name__ + + async def fetch_proxies(self, context: PluginContext) -> list[str]: + try: + fetch_func = getattr(self._module, 'fetch_proxies') + + if inspect.iscoroutinefunction(fetch_func): + return await fetch_func() + else: + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, fetch_func) + except Exception as e: + raise PluginExecutionException(f"旧插件执行失败: {str(e)}") from e + + @property + def name(self) -> str: + return self._name + + @property + def version(self) -> str: + return "1.0.0" + + +class PluginManager(IPluginManager): + def __init__( + self, + plugin_config_repo: IPluginConfigRepository, + proxy_repo: IProxyRepository, + http_client: IHttpClient, + config: 'Config', + dedup_cache: 'DedupCache' = None, + plugins_dir: Optional[str] = None, + ): + self._plugin_config_repo = plugin_config_repo + self._proxy_repo = proxy_repo + self._http_client = http_client + self._config = config + self._dedup_cache = dedup_cache + + self._plugins: dict[str, IProxyPlugin] = {} + self._plugin_configs: dict[str, PluginConfig] = {} + self._plugin_errors: dict[str, dict] = {} + self._plugins_dir = resolve_pool_path(plugins_dir or "plugins") + self._scheduler_task: Optional[asyncio.Task] = None + self._pending_save_tasks = set() + self._running = False + self._execution_semaphore: Optional[asyncio.Semaphore] = None + self._running_plugin_tasks: set[asyncio.Task] = set() + + def _schedule_save(self, coro): + task = asyncio.create_task(coro) + self._pending_save_tasks.add(task) + task.add_done_callback(self._pending_save_tasks.discard) + + async def start(self) -> None: + try: + await self._load_plugin_configs() + + await self.scan_plugins() + + max_parallel = max(1, getattr( + self._config.plugins, 'max_parallel_plugins', 3 + )) + self._execution_semaphore = asyncio.Semaphore(max_parallel) + logger.info(f"插件并行调度信号量已初始化: 最大并行数={max_parallel}") + + self._scheduler_task = asyncio.create_task(self._schedule_plugins()) + + logger.info("插件管理器已启动") + except Exception as e: + raise PluginException(f"插件管理器启动失败: {str(e)}") from e + + async def stop(self) -> None: + try: + self._running = False + + if self._scheduler_task: + self._scheduler_task.cancel() + try: + await self._scheduler_task + except asyncio.CancelledError: + pass + + if self._running_plugin_tasks: + logger.info( + f"等待 {len(self._running_plugin_tasks)} 个正在运行的插件任务完成..." + ) + try: + await asyncio.wait_for( + asyncio.gather( + *self._running_plugin_tasks, return_exceptions=True + ), + timeout=10.0 + ) + except asyncio.TimeoutError: + logger.warning( + "插件任务等待超时,取消剩余任务" + ) + for task in self._running_plugin_tasks: + task.cancel() + self._running_plugin_tasks.clear() + + if self._pending_save_tasks: + try: + await asyncio.wait_for( + asyncio.gather(*self._pending_save_tasks, return_exceptions=True), + timeout=5.0 + ) + except asyncio.TimeoutError: + logger.warning("等待插件配置落库超时(5 秒),放弃剩余写入") + self._pending_save_tasks.clear() + + logger.info("插件管理器已停止") + except Exception as e: + raise PluginException(f"插件管理器停止失败: {str(e)}") from e + + async def scan_plugins(self) -> None: + if not self._plugins_dir.exists(): + logger.error( + f"插件目录不存在: {self._plugins_dir} —— 将创建空目录," + f"不会加载任何抓取插件,请检查配置中的路径" + ) + self._plugins_dir.mkdir(parents=True, exist_ok=True) + return + + plugin_files = [ + f for f in self._plugins_dir.glob("*.py") + if f.name != "__init__.py" and not f.name.startswith("_") + ] + + for plugin_file in plugin_files: + plugin_name = plugin_file.stem + + if plugin_name in self._plugins: + continue + + await self._load_plugin(str(plugin_file)) + + async def execute_plugin(self, plugin_name: str) -> list[str]: + if plugin_name not in self._plugins: + raise PluginExecutionException(f"插件 {plugin_name} 未加载") + + config = self._plugin_configs.get(plugin_name) + if config and not config.enabled: + logger.warning(f"插件 {plugin_name} 已禁用,跳过执行") + return [] + + try: + context = PluginContext( + http_client=self._http_client, + logger=logging.getLogger(f"plugin.{plugin_name}"), + config=self._config + ) + + plugin = self._plugins[plugin_name] + proxies = await asyncio.wait_for( + plugin.fetch_proxies(context), + timeout=self._config.plugins.execution_timeout_seconds + ) + + if not isinstance(proxies, list): + error_msg = f"插件 {plugin_name} 返回值类型错误,期望 list,实际 {type(proxies)}" + logger.error(error_msg) + self._record_plugin_error( + plugin_name, "返回值类型错误", 'plugin_error_bad_return_type') + return [] + + await self._advance_schedule(plugin_name, config) + + if plugin_name in self._plugin_errors: + del self._plugin_errors[plugin_name] + + logger.info(f"插件 {plugin_name} 执行成功,获取 {len(proxies)} 个代理") + return proxies + + except asyncio.TimeoutError: + error_msg = f"插件 {plugin_name} 执行超时" + logger.error(error_msg) + self._record_plugin_error( + plugin_name, "执行超时", 'plugin_error_timeout') + await self._advance_schedule(plugin_name, config) + raise PluginExecutionException(error_msg) + + except Exception as e: + error_msg = f"插件 {plugin_name} 执行失败: {e}" + logger.error(error_msg, exc_info=True) + self._record_plugin_error(plugin_name, str(e)) + await self._advance_schedule(plugin_name, config) + raise PluginExecutionException(error_msg) from e + + async def _advance_schedule(self, plugin_name: str, config) -> None: + if not config: + return + try: + config.last_run = datetime.now() + config.next_run = datetime.now() + timedelta(minutes=config.interval_minutes) + await self._plugin_config_repo.save(config) + except Exception as e: + logger.warning("更新插件 %s 的调度时间失败: %s", plugin_name, e) + + def enable_plugin(self, plugin_name: str) -> None: + if plugin_name not in self._plugin_configs: + config = PluginConfig( + name=plugin_name, + enabled=True, + interval_minutes=self._config.plugins.default_interval_minutes + ) + self._plugin_configs[plugin_name] = config + else: + self._plugin_configs[plugin_name].enabled = True + + self._schedule_save( + self._plugin_config_repo.save(self._plugin_configs[plugin_name]) + ) + logger.info(f"插件 {plugin_name} 已启用") + + def disable_plugin(self, plugin_name: str) -> None: + if plugin_name not in self._plugin_configs: + config = PluginConfig( + name=plugin_name, + enabled=False, + interval_minutes=self._config.plugins.default_interval_minutes + ) + self._plugin_configs[plugin_name] = config + else: + self._plugin_configs[plugin_name].enabled = False + + self._schedule_save( + self._plugin_config_repo.save(self._plugin_configs[plugin_name]) + ) + logger.info(f"插件 {plugin_name} 已禁用") + + def set_plugin_interval(self, plugin_name: str, minutes: int) -> None: + if minutes < 1: + raise InvalidParameterException( + 'param_interval_must_be_positive', minutes) + + if plugin_name not in self._plugin_configs: + config = PluginConfig( + name=plugin_name, + enabled=True, + interval_minutes=minutes + ) + self._plugin_configs[plugin_name] = config + else: + self._plugin_configs[plugin_name].interval_minutes = minutes + + self._schedule_save( + self._plugin_config_repo.save(self._plugin_configs[plugin_name]) + ) + logger.info(f"插件 {plugin_name} 运行周期已设置为 {minutes} 分钟") + + def set_plugin_test_url(self, plugin_name: str, test_url: str) -> None: + url = (test_url or "").strip() + if url and not _is_http_url(url): + raise ValueError(f"测试地址必须是 http/https 绝对地址: {test_url}") + + if plugin_name not in self._plugin_configs: + config = PluginConfig( + name=plugin_name, + enabled=True, + interval_minutes=self._config.plugins.default_interval_minutes, + ) + self._plugin_configs[plugin_name] = config + self._plugin_configs[plugin_name].test_url = url + + self._schedule_save( + self._plugin_config_repo.save(self._plugin_configs[plugin_name]) + ) + logger.info( + "插件 %s 的测试地址已设置为 %s", plugin_name, url or "(继承全局)" + ) + + def set_plugin_validation( + self, plugin_name: str, reval_enabled: bool, reval_interval_minutes: int, + skip_validation: bool, + ) -> None: + if reval_interval_minutes < 0: + raise InvalidParameterException( + 'pool_field_reval_interval_invalid', reval_interval_minutes) + + if plugin_name not in self._plugin_configs: + self._plugin_configs[plugin_name] = PluginConfig( + name=plugin_name, + enabled=True, + interval_minutes=self._config.plugins.default_interval_minutes, + ) + + config = self._plugin_configs[plugin_name] + config.reval_enabled = bool(reval_enabled) + config.reval_interval_minutes = int(reval_interval_minutes) + config.skip_validation = bool(skip_validation) + + self._schedule_save(self._plugin_config_repo.save(config)) + logger.info( + "插件 %s 的验证策略已更新: 重验证 %s(间隔 %d 分钟),入库验证 %s", + plugin_name, + "开启" if config.reval_enabled else "关闭", + config.reval_interval_minutes, + "跳过" if config.skip_validation else "照常", + ) + + def should_skip_validation(self, plugin_name: str) -> bool: + config = self._plugin_configs.get(plugin_name) + return bool(config.skip_validation) if config else False + + def resolve_test_url(self, plugin_name: str) -> str: + return resolve_test_url( + self._plugin_configs.get(plugin_name), + self._config.validator.target_url, + ) + + def get_plugin_status(self) -> dict[str, PluginStatus]: + status_dict = {} + + for plugin_name in self._plugins.keys(): + config = self._plugin_configs.get(plugin_name) + error_record = self._plugin_errors.get(plugin_name) + last_error = error_record.get("message") if error_record else None + last_error_key = error_record.get("message_key") if error_record else None + + if config: + status = PluginStatus( + name=plugin_name, + enabled=config.enabled, + interval_minutes=config.interval_minutes, + last_run=config.last_run, + next_run=config.next_run, + last_error=last_error, + last_error_key=last_error_key, + is_loaded=True, + test_url=config.test_url, + reval_enabled=config.reval_enabled, + reval_interval_minutes=config.reval_interval_minutes, + skip_validation=config.skip_validation, + ) + else: + status = PluginStatus( + name=plugin_name, + enabled=True, + interval_minutes=self._config.plugins.default_interval_minutes, + last_run=None, + next_run=None, + last_error=last_error, + last_error_key=last_error_key, + is_loaded=True, + ) + + status_dict[plugin_name] = status + + return status_dict + + def apply_config(self, config: 'Config') -> None: + old_scan_interval = self._config.plugins.scan_interval_seconds + old_default_interval = self._config.plugins.default_interval_minutes + old_timeout = self._config.plugins.execution_timeout_seconds + old_max_parallel = self._config.plugins.max_parallel_plugins + + self._config = config + + if self._execution_semaphore is not None and \ + config.plugins.max_parallel_plugins != old_max_parallel: + self._execution_semaphore = asyncio.Semaphore( + max(1, config.plugins.max_parallel_plugins) + ) + + logger.info( + f"插件管理器配置已更新: " + f"scan_interval={old_scan_interval}->{config.plugins.scan_interval_seconds}s, " + f"default_interval={old_default_interval}->{config.plugins.default_interval_minutes}min, " + f"timeout={old_timeout}->{config.plugins.execution_timeout_seconds}s, " + f"max_parallel={old_max_parallel}->{config.plugins.max_parallel_plugins}" + ) + + async def _load_plugin_configs(self) -> None: + try: + configs = await self._plugin_config_repo.get_all() + for config in configs: + self._plugin_configs[config.name] = config + logger.info(f"加载了 {len(configs)} 个插件配置") + except Exception as e: + logger.error(f"加载插件配置失败: {e}", exc_info=True) + + async def _load_plugin(self, plugin_path: str) -> Optional[IProxyPlugin]: + plugin_file = Path(plugin_path) + plugin_name = plugin_file.stem + + if plugin_name in self._plugins: + return self._plugins[plugin_name] + + try: + module_name = f"{_PLUGIN_MODULE_PREFIX}{plugin_name}" + spec = importlib.util.spec_from_file_location(module_name, plugin_file) + if spec is None or spec.loader is None: + logger.error(f"无法加载插件 {plugin_name}: 无效的模块规范") + self._record_plugin_error( + plugin_name, "无效的模块规范", 'plugin_error_bad_spec') + return None + + module = importlib.util.module_from_spec(spec) + + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(module_name, None) + raise + + plugin_instance = None + + for item_name in dir(module): + item = getattr(module, item_name) + if (inspect.isclass(item) and + issubclass(item, IProxyPlugin) and + item is not IProxyPlugin): + plugin_instance = item() + logger.info(f"插件 {plugin_name} 实现了新接口 IProxyPlugin") + break + + if plugin_instance is None: + if hasattr(module, 'fetch_proxies'): + plugin_instance = LegacyPluginAdapter(module) + logger.info(f"插件 {plugin_name} 使用旧接口,已通过适配器加载") + else: + logger.error(f"插件 {plugin_name} 未实现 IProxyPlugin 接口或 fetch_proxies 函数") + self._record_plugin_error( + plugin_name, "未实现必需的接口", 'plugin_error_missing_interface') + return None + + self._plugins[plugin_name] = plugin_instance + + if plugin_name in self._plugin_errors: + del self._plugin_errors[plugin_name] + + if plugin_name not in self._plugin_configs: + config = PluginConfig( + name=plugin_name, + enabled=True, + interval_minutes=self._config.plugins.default_interval_minutes + ) + self._plugin_configs[plugin_name] = config + await self._plugin_config_repo.save(config) + + logger.info(f"插件 {plugin_name} 加载成功") + return plugin_instance + + except Exception as e: + logger.error(f"加载插件 {plugin_name} 失败: {e}", exc_info=True) + self._record_plugin_error(plugin_name, str(e)) + return None + + async def deduplicate_proxies(self, proxies: list[str]) -> list[str]: + if not proxies: + return [] + + try: + use_cache = self._dedup_cache and self._dedup_cache.is_initialized + if not use_cache: + existing_keys = await self._proxy_repo.get_existing_ip_ports() + + unique_proxies = [] + seen_hosts = set() + + for proxy_url in proxies: + try: + proxy = Proxy.from_url(proxy_url, "_dedup") + host_key = f"{proxy.ip}:{proxy.port}" + + if host_key in seen_hosts: + continue + + if use_cache: + if self._dedup_cache.contains(proxy.ip, proxy.port): + continue + else: + if host_key in existing_keys: + continue + + seen_hosts.add(host_key) + unique_proxies.append(proxy_url) + + except Exception as e: + logger.warning(f"解析代理 URL 失败 {sanitize_proxy(proxy_url)}: {e}") + continue + + logger.info(f"去重前: {len(proxies)} 个代理,去重后: {len(unique_proxies)} 个代理") + return unique_proxies + except Exception as e: + logger.error(f"代理去重失败: {e}", exc_info=True) + return proxies + + async def _schedule_plugins(self) -> None: + self._running = True + + while self._running: + try: + current_time = datetime.now() + + for plugin_name, config in self._plugin_configs.items(): + if not config.enabled: + continue + + if plugin_name not in self._plugins: + continue + + should_execute = False + + if config.next_run is None: + should_execute = True + elif current_time >= config.next_run: + should_execute = True + + if should_execute: + already_running = any( + getattr(t, '_plugin_name', None) == plugin_name + and not t.done() + for t in self._running_plugin_tasks + ) + if already_running: + logger.debug( + f"插件 {plugin_name} 仍在执行中,跳过本轮调度" + ) + continue + + logger.info(f"调度执行插件: {plugin_name}") + task = asyncio.create_task( + self._execute_plugin_task(plugin_name) + ) + task._plugin_name = plugin_name + self._running_plugin_tasks.add(task) + task.add_done_callback( + self._running_plugin_tasks.discard + ) + + await asyncio.sleep(self._config.plugins.scan_interval_seconds) + + except asyncio.CancelledError: + logger.info("插件调度器被取消") + break + except Exception as e: + logger.error(f"插件调度器错误: {e}", exc_info=True) + await asyncio.sleep(self._config.plugins.scan_interval_seconds) + + async def _execute_plugin_task(self, plugin_name: str) -> None: + try: + async with self._execution_semaphore: + logger.debug(f"插件 {plugin_name} 获取到执行信号量") + + max_retries = self._config.plugins.max_retries_on_failure + base_delay = self._config.plugins.retry_base_delay_seconds + proxies = None + + for attempt in range(max_retries + 1): + try: + proxies = await self.execute_plugin(plugin_name) + break + except Exception as e: + if attempt < max_retries: + delay = base_delay * (2 ** attempt) + logger.warning( + f"插件 {plugin_name} 执行失败," + f"第 {attempt + 1}/{max_retries} 次重试" + f"({delay:.0f}s 后): {e}" + ) + await asyncio.sleep(delay) + else: + logger.error( + f"插件 {plugin_name} 执行失败," + f"已达最大重试次数 {max_retries}: {e}" + ) + return + + if proxies: + unique_proxies = await self.deduplicate_proxies(proxies) + logger.info( + f"插件 {plugin_name} 去重后获得 {len(unique_proxies)} 个待验证代理" + ) + + from core.application.ingest import ingest_proxies + + stats = await ingest_proxies( + unique_proxies, source_plugin=plugin_name + ) + logger.info(f"插件 {plugin_name} 抓取入库完成: {stats.summary()}") + except asyncio.CancelledError: + logger.info(f"插件 {plugin_name} 执行任务被取消") + except Exception as e: + logger.error( + f"调度执行插件 {plugin_name} 失败: {e}", exc_info=True + ) + + async def reload_plugin(self, plugin_name: str) -> bool: + plugin_path = self._plugins_dir / f"{plugin_name}.py" + if not plugin_path.exists(): + logger.error(f"插件文件不存在: {plugin_path}") + return False + + is_running = any( + getattr(t, '_plugin_name', None) == plugin_name + and not t.done() + for t in self._running_plugin_tasks + ) + if is_running: + logger.warning( + f"插件 {plugin_name} 正在执行中,无法热重载。" + f"请等待执行完成后重试。" + ) + return False + + logger.info(f"开始热重载插件: {plugin_name}") + + module_name = f"{_PLUGIN_MODULE_PREFIX}{plugin_name}" + if module_name in sys.modules: + del sys.modules[module_name] + logger.debug(f"已从 sys.modules 移除旧模块: {module_name}") + + old_plugin = self._plugins.pop(plugin_name, None) + if old_plugin is not None: + logger.debug(f"已从插件注册表摘下旧实例: {plugin_name}") + + new_plugin = None + try: + new_plugin = await self._load_plugin(str(plugin_path)) + except Exception as e: + logger.error(f"插件 {plugin_name} 热重载异常: {e}", exc_info=True) + + if new_plugin is not None: + logger.info(f"插件 {plugin_name} 热重载成功") + if plugin_name in self._plugin_errors: + del self._plugin_errors[plugin_name] + return True + + logger.error(f"插件 {plugin_name} 热重载失败:新版本未加载成功") + if old_plugin is not None: + self._plugins[plugin_name] = old_plugin + logger.warning(f"插件 {plugin_name} 已保留重载前的版本继续运行") + return False + + def _record_plugin_error(self, plugin_name: str, message: str, + message_key: str = "") -> None: + self._plugin_errors[plugin_name] = { + "message": message, + "message_key": message_key, + "timestamp": datetime.now(), + } + + if len(self._plugin_errors) > 100: + sorted_by_time = sorted( + self._plugin_errors.items(), + key=lambda item: item[1].get("timestamp", datetime.min) + ) + oldest_keys = [key for key, _ in sorted_by_time[:50]] + for key in oldest_keys: + del self._plugin_errors[key] + logger.debug( + f"插件错误记录超限,已清理最旧的 {len(oldest_keys)} 条" + ) diff --git a/modules/proxypool/core/services/validation_apply.py b/modules/proxypool/core/services/validation_apply.py new file mode 100644 index 0000000..3844ec5 --- /dev/null +++ b/modules/proxypool/core/services/validation_apply.py @@ -0,0 +1,284 @@ +""" +模块名称:modules.proxypool.core.services.validation_apply +功能描述:把一次代理验证的结论翻译成代理记录的字段更新,并编排写库、删除与归属地入队的动作分发。 + 批量验证任务与自动重验证循环共用这里的判定口径,避免各处对失败认定、旧值保留与健康分的理解不一致。 +职责边界:负责:结论性判定、字段更新组装(计数器、延迟统计、方案能力、健康分)、身份类字段的测到才写策略,以及 + 动作分发编排(写库、删除、归属地通道由调用方以回调注入)与更新、删除操作的描述值。 + 不负责:数据库读写(由调用方经仓储或写队列完成)、验证流程本身(见 ProxyValidator)与归属地查询(由调用方补充)。 +关键依赖:core.domain.models、core.services.health_scorer。 +已知限制: + 1. result.is_conclusive 为假时返回 None,调用方必须一个字段都不写;误写 validated_at 会让该代理在自动重验证中跳过整个周期。 + 2. 身份类字段测到才写:real_ip、anonymity_level/anonymity_fallback、supports_https/http 无结论时留旧值,等级与兜底须成对写入。 + 归属地入队须用 effective_real_ip(本轮未测到出口 IP 时回退库里的旧值),改用 result.real_ip 会让只缺地区的代理永远补不上。 + 3. 请求级样本按(成功数、样本数)累加;本轮无可计样本时返回 None,调用方不得把这两列写 0,否则会清零已攒样本。 + 4. quality_assessed_at 只在 mode 为 FULL 且 result.fully_assessed 为真时推进,它是下一轮判断要不要补测的依据。 + 5. 延迟与方差测到才写,方差须用更新前的均值计算(先算方差再更新均值);failure_count 成功清零、失败加一。 + 6. health_score 用更新后的有效值(fields 优先于 proxy 旧值)计算;调用方要补的字段(如归属地)在返回的 fields 外自行追加。 +""" + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + +from core.domain.models import ( + DBOperation, + Proxy, + ProxyValidationResult, + ValidationMode, +) +from core.services.health_scorer import HealthScorer + +logger = logging.getLogger(__name__) + + +@dataclass +class ValidationUpdate: + + fields: dict = field(default_factory=dict) + new_total_checks: int = 0 + new_success_count: int = 0 + effective_real_ip: Optional[str] = None + effective_anonymity: str = "unverified" + + +APPLY_INCONCLUSIVE = "inconclusive" +APPLY_SKIPPED = "skipped" +APPLY_CLEANUP = "cleanup" +APPLY_APPLIED = "applied" + + +def decide_validation_action( + result: ProxyValidationResult, + *, + mode: ValidationMode = ValidationMode.FULL, + proxy: Optional[Proxy] = None, + scorer: Optional[HealthScorer] = None, + skip=None, + cleanup=None, +) -> tuple: + applied = build_validation_update(result, proxy, mode=mode, scorer=scorer) + if applied is None: + return APPLY_INCONCLUSIVE, None + + if skip is not None: + reason = skip(result) + if reason: + return reason, applied + + if cleanup is not None: + reason = cleanup(result, dict(applied.fields)) + if reason: + return reason, applied + + return APPLY_APPLIED, applied + + +async def dispatch_validation_action( + result: ProxyValidationResult, + *, + mode: ValidationMode = ValidationMode.FULL, + proxy: Optional[Proxy] = None, + scorer: Optional[HealthScorer] = None, + skip=None, + cleanup=None, + sink, + cleanup_sink=None, + geo_predicate=None, + geo_sink=None, +) -> tuple: + action, applied = decide_validation_action( + result, mode=mode, proxy=proxy, scorer=scorer, skip=skip, cleanup=cleanup, + ) + + if action == APPLY_APPLIED: + ip_for_geo = applied.effective_real_ip + if ip_for_geo and geo_sink is not None: + if geo_predicate is None or geo_predicate(proxy, applied.fields): + geo_sink(ip_for_geo) + await sink(applied) + elif action != APPLY_INCONCLUSIVE and cleanup_sink is not None: + await cleanup_sink(applied) + + return action, applied + + +def build_update_operation(proxy: Proxy, applied: "ValidationUpdate") -> DBOperation: + return DBOperation( + operation_type="update", + table="proxies", + data=dict(applied.fields), + where={"id": proxy.id}, + ) + + +def build_delete_operation(proxy: Proxy) -> DBOperation: + return DBOperation( + operation_type="delete", + table="proxies", + data={}, + where={"id": proxy.id, "ip": proxy.ip, "port": proxy.port}, + ) + + +def build_validation_update( + result: ProxyValidationResult, + proxy: Optional[Proxy] = None, + *, + mode: ValidationMode = ValidationMode.FULL, + now: Optional[datetime] = None, + scorer: Optional[HealthScorer] = None, +) -> Optional[ValidationUpdate]: + if not result.is_conclusive: + return None + + now = now or datetime.now() + scorer = scorer or HealthScorer() + + total_checks = ((proxy.total_checks or 0) if proxy else 0) + 1 + success_count = ((proxy.success_count or 0) if proxy else 0) + (1 if result.is_valid else 0) + + fields: dict = { + "is_valid": int(result.is_valid), + "validated_at": result.validated_at.isoformat(), + "total_checks": total_checks, + "success_count": success_count, + } + + if result.delay_ms is not None: + fields["delay_ms"] = result.delay_ms + + probe_samples = _accumulate_probe_samples(result, proxy) + if probe_samples is not None: + fields["probe_success_count"], fields["probe_total_count"] = probe_samples + + if mode is ValidationMode.FULL and result.fully_assessed: + fields["quality_assessed_at"] = result.validated_at.isoformat() + + effective_real_ip = result.real_ip if result.real_ip is not None else ( + proxy.real_ip if proxy else None + ) + effective_anonymity = result.anonymity_level if result.anonymity_level is not None else ( + (proxy.anonymity_level if proxy else None) or "unverified" + ) + + if result.real_ip is not None: + fields["real_ip"] = result.real_ip + if result.anonymity_level is not None: + fields["anonymity_level"] = result.anonymity_level + fields["anonymity_fallback"] = int(result.anonymity_fallback) + if result.supports_https is not None: + fields["supports_https"] = int(result.supports_https) + if result.supports_http is not None: + fields["supports_http"] = int(result.supports_http) + + if result.is_valid: + fields["failure_count"] = 0 + fields.update(_build_quality_fields(result, proxy, total_checks, now)) + else: + previous_failures = (proxy.failure_count or 0) if proxy else 0 + fields["failure_count"] = previous_failures + 1 + + fields["health_score"] = _score( + scorer, proxy, fields, total_checks, success_count, + effective_anonymity, + ) + + return ValidationUpdate( + fields=fields, + new_total_checks=total_checks, + new_success_count=success_count, + effective_real_ip=effective_real_ip, + effective_anonymity=effective_anonymity, + ) + + +def _accumulate_probe_samples( + result: ProxyValidationResult, proxy: Optional[Proxy] +) -> Optional[tuple[int, int]]: + probe_success, probe_total = result.probe_samples + if probe_total <= 0: + return None + + previous_success = (proxy.probe_success_count or 0) if proxy else 0 + previous_total = (proxy.probe_total_count or 0) if proxy else 0 + return previous_success + probe_success, previous_total + probe_total + + +def _build_quality_fields( + result: ProxyValidationResult, + proxy: Optional[Proxy], + total_checks: int, + now: datetime, +) -> dict: + fields: dict = {} + + previous_avg = proxy.avg_delay_ms if proxy else None + + if result.delay_ms is not None: + if proxy is not None: + variance = HealthScorer.calculate_delay_variance( + proxy.delay_variance, previous_avg, result.delay_ms, total_checks + ) + fields["delay_variance"] = variance + fields["avg_delay_ms"] = HealthScorer.calculate_avg_delay( + previous_avg, result.delay_ms, total_checks + ) + + if proxy is not None: + fields["total_valid_seconds"] = HealthScorer.calculate_total_valid_seconds( + proxy.total_valid_seconds or 0.0, proxy.last_valid_at, True, now + ) + else: + fields["total_valid_seconds"] = 0.0 + fields["last_valid_at"] = now.isoformat() + + return fields + + +def _score( + scorer: HealthScorer, + proxy: Optional[Proxy], + fields: dict, + total_checks: int, + success_count: int, + effective_anonymity: str, +) -> float: + effective_avg_delay = fields.get( + "avg_delay_ms", proxy.avg_delay_ms if proxy else None + ) + effective_variance = fields.get( + "delay_variance", proxy.delay_variance if proxy else None + ) + effective_valid_seconds = fields.get( + "total_valid_seconds", (proxy.total_valid_seconds or 0.0) if proxy else 0.0 + ) + + temp = Proxy( + delay_ms=fields.get("delay_ms", proxy.delay_ms if proxy else None), + success_count=success_count, + total_checks=total_checks, + probe_success_count=fields.get( + "probe_success_count", (proxy.probe_success_count or 0) if proxy else 0 + ), + probe_total_count=fields.get( + "probe_total_count", (proxy.probe_total_count or 0) if proxy else 0 + ), + created_at=proxy.created_at if proxy else None, + anonymity_level=effective_anonymity, + avg_delay_ms=effective_avg_delay, + delay_variance=effective_variance, + last_valid_at=proxy.last_valid_at if proxy else None, + total_valid_seconds=effective_valid_seconds, + ) + return scorer.calculate(temp) + + +__all__ = [ + "ValidationUpdate", + "build_delete_operation", + "build_update_operation", + "build_validation_update", + "decide_validation_action", + "dispatch_validation_action", +] diff --git a/modules/proxypool/core/services/validation_endpoints.py b/modules/proxypool/core/services/validation_endpoints.py new file mode 100644 index 0000000..49fc941 --- /dev/null +++ b/modules/proxypool/core/services/validation_endpoints.py @@ -0,0 +1,568 @@ +""" +模块名称:modules.proxypool.core.services.validation_endpoints +功能描述:代理验证所用外部端点的可用性管理与身份回显基线:按 TTL 直连预筛端点、剔除本网络不可达者, +采集端点自身响应特征(注入头部、origin 链、本机公网出口 IP)并解析端点文本中的出口 IP。 +职责边界:负责端点清单的持有与热更新、直连可达性预筛与单个地址按需补探、端点基线的采集与查询、整体可用性结论, +并提供身份回显与出口 IP 解析(parse_identity_echo、parse_exit_ip、pick_exit_ip);不负责单个代理验证流程,匿名度判定在 ProxyValidator。 +关键依赖:IHttpClient、IConfigManager、core.domain.models.canonical_ip。 +已知限制: +1. 状态为进程内内存态,不跨进程持久化;未预筛时查询按原清单放行、可用性判定为 True。 +2. is_reachable_directly、baseline_for、direct_latency_ms 未命中返回 None;调用方需区分 None 与 False。 +3. ensure_reachable 仅对空串返回 None,对未命中新鲜预筛结果的非空地址现探一次并记入状态后返回布尔值;跳过现探会把目标站不可达归因为代理失败。 +4. 预筛为不带代理的直连探测,只回答本机到端点是否可达,不构成对代理可用性的结论。 +5. 失败结论按 TTL 记账(预筛 300 秒、本机公网 IP 1800 秒,含查询失败),单次直连探测超时 5 秒。 +6. ip_check_apis、identity_echo_apis 必须 https,plain_identity_echo_apis 必须真 http,不合规条目被剔除并记 warning。 +7. 由 https 回显端点派生的明文端点只保留 netloc 与 path,query 与 fragment 会被丢弃。 +8. 出口 IP 优先返回 origin 链尾最近的 IPv4,无 IPv4 时返回链尾最近的合法地址;host_public_ip 每个地址族只保留第一个值。 +""" + +import asyncio +import ipaddress +import json +import logging +import socket +import time +from dataclasses import dataclass, field +from typing import Optional, Sequence +from urllib.parse import urlparse + +from core.domain.models import canonical_ip +from core.interfaces.infrastructure import IHttpClient, IConfigManager + +logger = logging.getLogger(__name__) + +_REQUIRED_ENDPOINT_SCHEME = "https" + +_PLAIN_ENDPOINT_SCHEME = "http" + +_DEFAULT_TTL_SECONDS = 300.0 + +_HOST_IP_TTL_SECONDS = 1800.0 + +_DIRECT_PROBE_TIMEOUT_SECONDS = 5.0 + +_HTTP_PROBE_URLS = ( + "http://connectivitycheck.platform.hicloud.com/generate_204", + "http://cp.cloudflare.com", + "http://detectportal.firefox.com/success.txt", +) + +_DEFAULT_IDENTITY_ENDPOINTS = ( + "https://httpbin.org/anything", + "https://httpbingo.org/anything", +) + + +def _as_plain_http(urls: Sequence[str]) -> list[str]: + plain: list[str] = [] + for url in urls: + parsed = urlparse(str(url)) + if not parsed.netloc: + continue + plain.append(f"http://{parsed.netloc}{parsed.path or '/'}") + return list(dict.fromkeys(plain)) + + +def https_only(urls: Sequence[str], *, label: str) -> list[str]: + kept: list[str] = [] + for url in urls: + if urlparse(str(url)).scheme.lower() == _REQUIRED_ENDPOINT_SCHEME: + kept.append(url) + else: + logger.warning("%s 中的端点不是 https,已剔除: %s", label, url) + return kept + + +def plain_http_only(urls: Sequence[str], *, label: str) -> list[str]: + kept: list[str] = [] + for url in urls: + text = str(url).strip() + if not text: + continue + if urlparse(text).scheme.lower() == _PLAIN_ENDPOINT_SCHEME: + kept.append(text) + else: + logger.warning("%s 中的端点不是明文 http,已剔除: %s", label, url) + return kept + + +@dataclass +class IdentityEcho: + + chain: list[str] + header_names: frozenset[str] + raw_headers: dict + + +def parse_identity_echo(text: str) -> Optional[IdentityEcho]: + raw = (text or "").strip() + if not raw.startswith("{"): + return None + + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return None + + if not isinstance(data, dict): + return None + + headers = data.get("headers") + header_names = frozenset() + if isinstance(headers, dict): + header_names = frozenset(str(k).lower() for k in headers) + + chain = _parse_origin_chain(data.get("origin")) + if not chain and not header_names: + return None + + return IdentityEcho( + chain=chain, + header_names=header_names, + raw_headers=headers if isinstance(headers, dict) else {}, + ) + + +def _parse_origin_chain(origin) -> list[str]: + if origin is None: + return [] + + if isinstance(origin, (list, tuple)): + candidates = [str(item) for item in origin] + else: + candidates = str(origin).split(",") + + chain: list[str] = [] + for candidate in candidates: + candidate = candidate.strip() + if not candidate: + continue + canonical = canonical_ip(candidate) + if canonical is not None: + chain.append(canonical) + return chain + + +@dataclass +class EndpointBaseline: + + url: str + injected_headers: frozenset[str] + direct_chain_length: int + injected_header_values: dict[str, str] = field(default_factory=dict) + + +@dataclass +class _EndpointState: + + url: str + reachable: bool = False + latency_ms: Optional[float] = None + baseline: Optional[EndpointBaseline] = None + + +class ValidationEndpoints: + + def __init__(self, http_client: IHttpClient, config_manager: IConfigManager): + self._http_client = http_client + self._config_manager = config_manager + + self._plain_identity_endpoints: list[str] = [] + self._ip_check_endpoints: list[str] = [] + self._host_ip_override: dict[int, str] = {} + self.check_anonymity = True + self._cached_host_ips: dict[int, str] = {} + self._host_ip_checked_at: float = 0.0 + self._host_ip_lock = asyncio.Lock() + config = self._config_manager.get_config() + self._apply_validator_config(config.validator) + + self._ttl_seconds = _DEFAULT_TTL_SECONDS + self._states: dict[str, _EndpointState] = {} + self._last_refresh_at: float = 0.0 + self._refresh_lock = asyncio.Lock() + + def _apply_validator_config(self, validator_config) -> None: + self._target_url = validator_config.target_url + + configured = str(getattr(validator_config, "host_public_ip", "") or "").strip() + overrides = parse_host_ip_list(configured) + if configured and not overrides: + logger.warning( + "validator.host_public_ip 里没有可用的 IP(%r),按未配置处理," + "将改为直连查询站点获取", configured + ) + self._host_ip_override = overrides + if not self._host_ip_override: + self._cached_host_ips = {} + self._host_ip_checked_at = 0.0 + + self.check_anonymity = bool( + getattr(validator_config, "check_anonymity", True) + ) + + self._ip_check_endpoints = https_only( + list(getattr(validator_config, "ip_check_apis", None) or []), + label="validator.ip_check_apis", + ) + + endpoints = list(getattr(validator_config, "identity_echo_apis", None) + or _DEFAULT_IDENTITY_ENDPOINTS) + self._identity_endpoints = https_only( + endpoints, label="validator.identity_echo_apis" + ) + extra_plain = plain_http_only( + getattr(validator_config, "plain_identity_echo_apis", None) or [], + label="validator.plain_identity_echo_apis", + ) + self._plain_identity_endpoints = list(dict.fromkeys( + _as_plain_http(self._identity_endpoints) + extra_plain + )) + + def apply_config(self, config) -> None: + old_endpoints = list(self._identity_endpoints) + old_plain_endpoints = list(self._plain_identity_endpoints) + old_target = self._target_url + + self._apply_validator_config(config.validator) + + if (old_endpoints != list(self._identity_endpoints) + or old_plain_endpoints != list(self._plain_identity_endpoints) + or old_target != self._target_url): + logger.info( + "验证端点配置已更新: target=%s->%s, identity_endpoints=%d->%d," + " plain_identity_endpoints=%d->%d", + old_target, self._target_url, + len(old_endpoints), len(self._identity_endpoints), + len(old_plain_endpoints), len(self._plain_identity_endpoints), + ) + self._invalidate() + + def _invalidate(self) -> None: + self._states.clear() + self._last_refresh_at = 0.0 + + async def ensure_ready(self) -> None: + if self._is_fresh(): + return + + async with self._refresh_lock: + if self._is_fresh(): + return + await self._refresh() + + def _is_fresh(self) -> bool: + if not self._states: + return False + return (time.monotonic() - self._last_refresh_at) < self._ttl_seconds + + async def ensure_reachable(self, url: str) -> Optional[bool]: + if not url: + return None + + if self._is_fresh(): + state = self._states.get(url) + if state is not None: + return state.reachable + + async with self._refresh_lock: + if self._is_fresh(): + state = self._states.get(url) + if state is not None: + return state.reachable + probed = await self._probe_direct(url) + self._states[url] = probed + return probed.reachable + + async def _refresh(self) -> None: + urls = self._candidate_urls() + + results = await asyncio.gather( + *(self._probe_direct(url) for url in urls), + return_exceptions=True, + ) + + states: dict[str, _EndpointState] = {} + for url, result in zip(urls, results): + if isinstance(result, _EndpointState): + states[url] = result + else: + logger.debug("端点直连探测异常 %s: %s", url, result) + states[url] = _EndpointState(url=url, reachable=False) + + self._states = states + self._last_refresh_at = time.monotonic() + + reachable = [u for u, s in states.items() if s.reachable] + logger.info( + "验证端点直连预筛完成: 可达 %d/%d, 目标站可达=%s, 本机公网IP=%s", + len(reachable), len(states), + self.is_reachable_directly(self._target_url), + self.host_public_ip() or "未知", + ) + for url in states: + if url not in reachable: + logger.debug("端点直连不可达,本轮剔除: %s", url) + + def _candidate_urls(self) -> list[str]: + urls = [self._target_url] + urls.extend(self._identity_endpoints) + if self.check_anonymity: + urls.extend(self._plain_identity_endpoints) + urls.extend(_HTTP_PROBE_URLS) + return list(dict.fromkeys(urls)) + + async def _probe_direct(self, url: str) -> _EndpointState: + state = _EndpointState(url=url) + start = time.monotonic() + try: + response = await self._http_client.get( + url, timeout=_DIRECT_PROBE_TIMEOUT_SECONDS + ) + except Exception as exc: + logger.debug("端点直连失败 %s: %s", url, exc) + return state + + state.latency_ms = (time.monotonic() - start) * 1000.0 + + if response.status >= 400: + logger.debug("端点直连返回错误状态 %s: %s", url, response.status) + return state + + state.reachable = True + + identity = parse_identity_echo(response.text) + if identity is not None: + state.baseline = EndpointBaseline( + url=url, + injected_headers=frozenset(identity.header_names), + injected_header_values={ + str(name).lower(): str(value) + for name, value in identity.raw_headers.items() + }, + direct_chain_length=len(identity.chain), + ) + + return state + + @property + def target_url(self) -> str: + return self._target_url + + def identity_endpoints(self) -> list[str]: + return self._healthy_or_all(self._identity_endpoints) + + def plain_identity_endpoints(self) -> list[str]: + return self._healthy_or_all(self._plain_identity_endpoints) + + def http_probe_endpoints(self) -> list[str]: + return self._healthy_or_all(list(_HTTP_PROBE_URLS)) + + def _healthy_or_all(self, urls: list[str]) -> list[str]: + if not self._states: + return list(urls) + + healthy = [url for url in urls + if self._states.get(url) and self._states[url].reachable] + return healthy + + def baseline_for(self, url: str) -> Optional[EndpointBaseline]: + state = self._states.get(url) + return state.baseline if state else None + + def host_public_ips(self) -> tuple[str, ...]: + source = self._host_ip_override or self._cached_host_ips + return tuple(source[version] for version in (4, 6) if source.get(version)) + + def host_public_ip(self) -> Optional[str]: + ips = self.host_public_ips() + return ips[0] if ips else None + + async def ensure_host_ip(self) -> Optional[str]: + if self._host_ip_override: + return self.host_public_ip() + + if self._host_ip_fresh(): + return self.host_public_ip() + + async with self._host_ip_lock: + if self._host_ip_fresh(): + return self.host_public_ip() + await self._refresh_host_ip() + return self.host_public_ip() + + def _host_ip_fresh(self) -> bool: + if not self._host_ip_checked_at: + return False + return (time.monotonic() - self._host_ip_checked_at) < _HOST_IP_TTL_SECONDS + + async def _probe_family(self, version: int, af: int) -> Optional[str]: + for url in self._ip_check_endpoints: + try: + response = await self._http_client.get( + url, timeout=_DIRECT_PROBE_TIMEOUT_SECONDS, family=af + ) + except Exception as exc: + logger.debug("按 IPv%d 查询本机出口 IP 失败 %s: %s", version, url, exc) + continue + + if getattr(response, "status", 0) != 200: + continue + + ip = parse_exit_ip(getattr(response, "text", "")) + if ip and ipaddress.ip_address(ip).version == version: + return ip + return None + + async def _refresh_host_ip(self) -> None: + results = await asyncio.gather( + self._probe_family(4, socket.AF_INET), + self._probe_family(6, socket.AF_INET6), + return_exceptions=True, + ) + + found: dict[int, str] = {} + for version, result in zip((4, 6), results): + if isinstance(result, str) and result: + found[version] = result + self._cached_host_ips = found + self._host_ip_checked_at = time.monotonic() + + if found: + logger.info( + "本机公网出口 IP: %s", + ",".join(f"IPv{v} {found[v]}" for v in sorted(found)), + ) + return + + logger.warning( + "本机公网出口 IP 查询失败(%d 个端点、两族各试一遍),匿名度将无法判定;" + "可在池设置的 validator.host_public_ip 里直接填写", + len(self._ip_check_endpoints), + ) + + def host_ip_source(self) -> str: + if self._host_ip_override: + return "configured" + return "detected" if self._cached_host_ips else "" + + def is_reachable_directly(self, url: str) -> Optional[bool]: + state = self._states.get(url) + return state.reachable if state else None + + def direct_latency_ms(self, url: str) -> Optional[float]: + state = self._states.get(url) + return state.latency_ms if state else None + + def ordered_by_latency(self, urls: Sequence[str]) -> list[str]: + probed: list[tuple[float, int, str]] = [] + unknown: list[tuple[int, str]] = [] + + for index, url in enumerate(urls): + latency = self.direct_latency_ms(url) + if latency is None: + unknown.append((index, url)) + else: + probed.append((latency, index, url)) + + probed.sort(key=lambda item: (item[0], item[1])) + return [url for _, _, url in probed] + [url for _, url in unknown] + + def network_available(self) -> bool: + if not self._states: + return True + return any(state.reachable for state in self._states.values()) + + def identity_available(self) -> bool: + if not self._states: + return True + return any( + self._states.get(url) and self._states[url].reachable + for url in self._identity_endpoints + ) + + +__all__ = [ + "ValidationEndpoints", + "EndpointBaseline", + "IdentityEcho", + "parse_identity_echo", +] + +_JSON_IP_FIELDS = ("ip", "origin", "query", "address") + + +def _parse_exit_ip_from_key_value_lines(raw: str) -> Optional[str]: + for line in raw.splitlines(): + key, separator, value = line.partition("=") + if separator and key.strip().lower() == "ip": + chain = [part.strip() for part in value.split(",")] + exit_ip = pick_exit_ip(chain) + if exit_ip: + return exit_ip + return None + + +def _parse_exit_ip_from_json(raw: str) -> Optional[str]: + parsed = parse_identity_echo(raw) + if parsed is not None and parsed.chain: + return pick_exit_ip(parsed.chain) + + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return None + + if not isinstance(data, dict): + return None + + for field_name in _JSON_IP_FIELDS: + value = data.get(field_name) + if not isinstance(value, str) or not value.strip(): + continue + chain = [part.strip() for part in value.split(",")] + exit_ip = pick_exit_ip(chain) + if exit_ip: + return exit_ip + return None + + +def parse_exit_ip(text: str) -> Optional[str]: + raw = (text or "").strip() + if not raw: + return None + + if raw.startswith("{"): + return _parse_exit_ip_from_json(raw) + + from_lines = _parse_exit_ip_from_key_value_lines(raw) + if from_lines: + return from_lines + + return pick_exit_ip([part.strip() for part in raw.split(",")]) + + +def pick_exit_ip(chain: Sequence[str]) -> Optional[str]: + if not chain: + return None + + for candidate in reversed(chain): + canonical = canonical_ip(candidate) + if canonical is not None and ipaddress.ip_address(canonical).version == 4: + return canonical + + for candidate in reversed(chain): + canonical = canonical_ip(candidate) + if canonical is not None: + return canonical + return None + + +def parse_host_ip_list(text: str) -> dict[int, str]: + resolved: dict[int, str] = {} + for part in str(text or "").replace(",", ",").split(","): + canonical = canonical_ip(part.strip()) + if canonical is None: + continue + resolved.setdefault(ipaddress.ip_address(canonical).version, canonical) + return resolved diff --git a/modules/proxypool/core/services/validation_policy.py b/modules/proxypool/core/services/validation_policy.py new file mode 100644 index 0000000..1e21eab --- /dev/null +++ b/modules/proxypool/core/services/validation_policy.py @@ -0,0 +1,72 @@ +""" +模块名称:modules.proxypool.core.services.validation_policy +功能描述:验证强度策略:按代理上次完整评估时间、配置间隔与已有身份数据,在完整、身份补测、轻量存活三档里选一档。 + 身份量几乎不随时间变化、不必每轮重测;自动重验证循环与批量任务共用本判断以保证强度一致。 +职责边界:负责:选出本轮验证强度、解析测试地址;不负责:验证本身(ProxyValidator)与结论落地(validation_apply)。 +关键依赖:标准库 datetime;core.domain.models。 +已知限制: +1. 三档优先级 FULL > IDENTITY > LIVENESS;full_recheck_interval_minutes 缺失、非法或 ≤ 0 时按 0 处理,语义为每轮都做完整评估。 +2. 判据只看 quality_assessed_at、real_ip 与 anonymity_level;始终测不到出口 IP 的代理在完整重测周期前为 IDENTITY,到周期为 FULL。 +3. anonymity_level 缺失、为空或等于 unverified 时触发匿名度补测,返回 IDENTITY;其余取值不触发。 +4. config.validator.check_anonymity 为 False 时匿名度缺口不补测,IDENTITY 仅由缺失 real_ip 触发。 +5. proxy 为 None(尚无记录的新代理)按 FULL 处理。 +6. resolve_test_url 在插件未配置或 test_url 为空串时回退全局 [Server] test_url,各调用方必须认同同一地址。 +""" + +import logging +from datetime import datetime +from typing import Optional + +from core.domain.models import Proxy, ValidationMode + +logger = logging.getLogger(__name__) + +_INTERVAL_ALWAYS_FULL = 0 + + +def full_recheck_interval_minutes(config) -> int: + section = getattr(config, "auto_revalidation", None) + value = getattr(section, "full_recheck_interval_minutes", 0) or 0 + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def pick_validation_mode( + proxy: Optional[Proxy], + config, + *, + now: Optional[datetime] = None, +) -> ValidationMode: + interval_minutes = full_recheck_interval_minutes(config) + if interval_minutes <= _INTERVAL_ALWAYS_FULL: + return ValidationMode.FULL + + assessed_at = getattr(proxy, "quality_assessed_at", None) if proxy else None + if assessed_at is None: + return ValidationMode.FULL + + elapsed_minutes = ((now or datetime.now()) - assessed_at).total_seconds() / 60.0 + if elapsed_minutes >= interval_minutes: + return ValidationMode.FULL + + if not getattr(proxy, "real_ip", None): + return ValidationMode.IDENTITY + if getattr(config.validator, "check_anonymity", True) and ( + (getattr(proxy, "anonymity_level", "unverified") or "unverified") == "unverified"): + return ValidationMode.IDENTITY + + return ValidationMode.LIVENESS + + +def resolve_test_url(plugin_config, global_target_url: str) -> str: + configured = getattr(plugin_config, "test_url", "") or "" + return configured.strip() or global_target_url + + +__all__ = [ + "pick_validation_mode", + "full_recheck_interval_minutes", + "resolve_test_url", +] diff --git a/modules/proxypool/core/services/validator.py b/modules/proxypool/core/services/validator.py new file mode 100644 index 0000000..56c12de --- /dev/null +++ b/modules/proxypool/core/services/validator.py @@ -0,0 +1,840 @@ +""" +模块名称:modules.proxypool.core.services.validator +功能描述:代理池的单代理质量验证器:存活探测先行把关(不过即止),通过后并发发出身份与明文探测, + 产出带失败归因的 ProxyValidationResult;本轮做哪几项由 ValidationMode 决定。 +职责边界:负责:单代理验证流水线的编排(存活闸门、探测对冲竞速)、失败归因(代理/我方/目标站)、 + 身份回显解读与匿名度判定、方案能力正负判定;不负责:端点预筛与直连基线(ValidationEndpoints)、 + 健康评分、HTTP 异常翻译、结论落库、策略(见 HealthScorer、AioHttpClient、validation_apply、validation_policy)。 +关键依赖:IHttpClient、IConfigManager、IProxyValidator、ValidationEndpoints、core.domain.models。 +已知限制: +1. 「没测成」与「代理失败」不可互换:ok=None、INCONCLUSIVE 或身份字段为 None 均表示本轮无结论,写入层须跳过以保留旧值;ok=False 才是归因于代理的失败。 +2. 实例由 DI 容器单例持有;并发由内部信号量统一把持;validate_proxy 以结果对象表达失败(验证过程异常兜底为 INCONCLUSIVE),批量验证由调用方自行 gather。 +3. 失败原因字符串与 http_client 的 reason 同源,改其一须同步核对四张归因表;pool_queue_timeout 属本机连接池排队,必须最先判为无结论。 +4. 竞速按直连延迟逐个补发候选,总窗口不超过 _RACE_LAUNCH_WINDOW_SECONDS;max_concurrent 热更新会换新信号量,在飞验证归还旧信号量、不被打断。 +5. 匿名度靠明文回显路径判定;回显端点整体不可用、本机公网 IP 未知、探针未发出或开关关闭时不下结论;其余判不出等级时兜底 transparent 并标记 anonymity_fallback。 +6. 方案能力的否定结论:https 须代理明确拒绝 CONNECT 且回显端点可用,超时不下结论;http 由明文探测端点直连可达且竞速全败(含超时)时给出。 +7. can_attribute_failure 比存活归因更严:须本机网络可用且 test_url 未被判为直连不可达,弱证据宁可漏记不可误杀。 +8. test_url 的直连可达性须在归因前由 ensure_reachable 探明并记入端点状态:未知会被当作可达,目标站故障会被误记为代理失败。 +""" + +import asyncio +import ipaddress +import json +import logging +import re +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Callable, Optional, Sequence +from urllib.parse import urlparse + +from core.interfaces.services import IProxyValidator +from modules.modules import sanitize_proxy +from core.interfaces.infrastructure import IHttpClient, IConfigManager +from core.domain.models import ( + canonical_ip, + ProbeKind, + ProbeOutcome, + ProxyValidationResult, + ValidationMode, + ValidationOutcome, +) +from core.services.validation_endpoints import ( + IdentityEcho, + ValidationEndpoints, + https_only, + parse_exit_ip, + parse_identity_echo, + pick_exit_ip, +) + +logger = logging.getLogger(__name__) + +_IP_FRAGMENT = re.compile(r"[0-9a-fA-F:.]+") + +_PROXY_REVEAL_HEADERS = frozenset({ + "x-forwarded-for", + "x-real-ip", + "via", + "proxy-connection", + "x-proxy-id", + "forwarded", + "x-forwarded-host", + "x-forwarded-proto", + "x-originating-ip", + "x-remote-ip", + "x-remote-addr", + "x-client-ip", +}) + +_FALLBACK_ANONYMITY = "transparent" + + +def _extract_ips(value: str) -> list[str]: + found: list[str] = [] + for fragment in _IP_FRAGMENT.findall(str(value)): + candidate = fragment + if candidate.count(".") == 3 and ":" in candidate: + head, _, tail = candidate.rpartition(":") + if not tail or tail.isdigit(): + candidate = head + if candidate.count(".") != 3 and candidate.count(":") < 2: + continue + canonical = canonical_ip(candidate) + if canonical is not None: + found.append(canonical) + return found + + +def _scan_header_ips(echo: IdentityEcho, baseline, host_public_ips: Sequence[str] + ) -> tuple[bool, bool]: + baseline_values = baseline.injected_header_values if baseline else {} + + leaked = False + added = False + for name, value in (echo.raw_headers or {}).items(): + key = str(name).lower() + text = str(value) + if baseline_values.get(key) == text: + continue + + is_new = key not in baseline_values + if is_new and key in _PROXY_REVEAL_HEADERS: + added = True + + baseline_ips = _extract_ips(baseline_values.get(key, "")) + for ip in _extract_ips(text): + if ip in host_public_ips: + leaked = True + elif is_new or not baseline_ips: + added = True + return leaked, added + + +_PROXY_UNREACHABLE_REASONS = frozenset({ + "connect_timeout", + "proxy_connect", + "dns", +}) + +_OUR_FAULT_REASONS = frozenset({ + "socks_unsupported", +}) + +_TUNNEL_REFUSED_REASONS = frozenset({"proxy_refused"}) + +_LOCAL_CONGESTION_REASONS = frozenset({"pool_queue_timeout"}) + +_HEDGE_MIN_SECONDS = 0.2 +_HEDGE_MAX_SECONDS = 1.5 + +_PREFER_IDENTITY_GRACE_SECONDS = 1.5 + +_RACE_LAUNCH_WINDOW_SECONDS = 1.5 + + +@dataclass(frozen=True) +class _Attempt: + + value: object = None + reason: Optional[str] = None + proven_scheme: Optional[str] = None + + +@dataclass(frozen=True) +class _RaceOutcome: + + value: object = None + failure_reasons: frozenset[str] = frozenset() + proven_schemes: frozenset[str] = frozenset() + + +@dataclass(frozen=True) +class _IdentitySample: + + url: str + exit_ip: str + identity: Optional[IdentityEcho] = None + + +@dataclass(frozen=True) +class _AnonymityVerdict: + + level: Optional[str] = None + fallback: bool = False + + +@dataclass +class _LivenessProbe: + + ok: bool + latency_ms: Optional[float] = None + reason: Optional[str] = None + + @property + def outcome(self) -> ProbeOutcome: + return ProbeOutcome( + kind=ProbeKind.LIVENESS, + ok=self.ok, + latency_ms=self.latency_ms, + reason=self.reason, + ) + + +@dataclass +class _IdentityProbe: + + sample: Optional[_IdentitySample] = None + tunnel_refused: bool = False + proven_https: bool = False + attributable: bool = False + reason: Optional[str] = None + + @property + def real_ip(self) -> Optional[str]: + return self.sample.exit_ip if self.sample else None + + @property + def outcome(self) -> ProbeOutcome: + if self.sample is not None: + return ProbeOutcome(kind=ProbeKind.TUNNEL, ok=True) + return ProbeOutcome( + kind=ProbeKind.TUNNEL, + ok=False if self.attributable else None, + reason=self.reason or "no_identity", + ) + + +class ProxyValidator(IProxyValidator): + + def __init__( + self, + http_client: IHttpClient, + config_manager: IConfigManager, + endpoints: Optional[ValidationEndpoints] = None, + ): + self._http_client = http_client + self._config_manager = config_manager + self._endpoints = endpoints or ValidationEndpoints(http_client, config_manager) + + config = self._config_manager.get_config() + self._apply_validator_config(config.validator) + + self._semaphore = asyncio.Semaphore(self._max_concurrent) + + logger.info( + f"代理验证器已初始化: target={self._target_url}, " + f"timeout={self._timeout}s, connect_timeout={self._connect_timeout}s, " + f"max_concurrent={self._max_concurrent}" + ) + + def _apply_validator_config(self, validator_config) -> None: + self._target_url = validator_config.target_url + self._timeout = validator_config.timeout_seconds + self._max_concurrent = max(1, validator_config.max_concurrent) + self._connect_timeout = validator_config.connect_timeout_seconds + self._ip_apis = https_only( + validator_config.ip_check_apis, label="validator.ip_check_apis" + ) + + @property + def max_concurrent(self) -> int: + return self._max_concurrent + + @property + def endpoints(self) -> ValidationEndpoints: + return self._endpoints + + def can_attribute_failure(self, test_url: str) -> bool: + if not self._endpoints.network_available(): + return False + return self._endpoints.is_reachable_directly(test_url) is not False + + def apply_config(self, config) -> None: + validator_config = config.validator + + old_target = self._target_url + old_timeout = self._timeout + old_concurrent = self._max_concurrent + old_connect_timeout = self._connect_timeout + + self._apply_validator_config(validator_config) + self._endpoints.apply_config(config) + + if self._max_concurrent != old_concurrent: + self._semaphore = asyncio.Semaphore(self._max_concurrent) + + logger.info( + f"验证器配置已更新: target={old_target}->{self._target_url}, " + f"timeout={old_timeout}->{self._timeout}s, " + f"connect_timeout={old_connect_timeout}->{self._connect_timeout}s, " + f"max_concurrent={old_concurrent}->{self._max_concurrent}" + ) + + async def validate_proxy( + self, + proxy_url: str, + mode: ValidationMode = ValidationMode.FULL, + test_url: Optional[str] = None, + ) -> ProxyValidationResult: + if not isinstance(mode, ValidationMode): + try: + mode = ValidationMode(mode) + except ValueError as exc: + raise ValueError(f"未知的验证模式: {mode!r}") from exc + + semaphore = self._semaphore + async with semaphore: + try: + return await self._validate( + proxy_url, mode, test_url or self._target_url + ) + except Exception as e: + logger.debug(f"验证代理异常 {sanitize_proxy(proxy_url)}: {e}") + return self._build_result( + proxy_url, + ValidationOutcome.INCONCLUSIVE, + error_message=f"验证过程异常: {e}", + error_reason="validator_error", + ) + + async def _validate( + self, proxy_url: str, mode: ValidationMode, test_url: str + ) -> ProxyValidationResult: + await self._endpoints.ensure_ready() + await self._endpoints.ensure_reachable(test_url) + if self._anonymity_check_enabled(): + await self._endpoints.ensure_host_ip() + + if not self._endpoints.network_available(): + return self._build_result( + proxy_url, + ValidationOutcome.INCONCLUSIVE, + error_message="本机网络不可用(所有检测端点直连均失败)", + error_reason="network_down", + ) + + liveness = await self._probe_liveness(proxy_url, test_url) + probes: list[ProbeOutcome] = [liveness.outcome] + + if not liveness.ok: + return self._attribute_target_failure( + proxy_url, liveness.reason, probes, test_url + ) + + identity_task: Optional[asyncio.Task] = None + plain_identity_task: Optional[asyncio.Task] = None + plain_task: Optional[asyncio.Task] = None + if mode in (ValidationMode.IDENTITY, ValidationMode.FULL): + identity_task = asyncio.create_task(self._probe_identity(proxy_url)) + if self._anonymity_check_enabled(): + plain_identity_task = asyncio.create_task( + self._probe_plain_identity(proxy_url) + ) + if mode is ValidationMode.FULL and self._scheme_of(test_url) != "http": + plain_task = asyncio.create_task(self._probe_plain_http(proxy_url)) + + identity = await self._settle(identity_task) + plain_identity = await self._settle(plain_identity_task) + plain_outcome = await self._settle(plain_task) + + if identity is not None: + probes.append(identity.outcome) + if plain_outcome is not None: + probes.append(plain_outcome) + + capabilities = self._resolve_capabilities( + liveness, identity, plain_outcome, test_url + ) + + verdict = self._judge_identity_anonymity(identity, plain_identity) + + return self._build_result( + proxy_url, + ValidationOutcome.OK, + delay_ms=liveness.latency_ms, + real_ip=identity.real_ip if identity else None, + anonymity_level=verdict.level, + anonymity_fallback=verdict.fallback, + supports_http=capabilities["http"], + supports_https=capabilities["https"], + probes=probes, + ) + + async def _probe_liveness( + self, proxy_url: str, test_url: str + ) -> _LivenessProbe: + url = test_url + start = time.monotonic() + try: + response = await self._http_client.get( + url, + proxy=proxy_url, + timeout=self._timeout, + connect_timeout=self._connect_timeout or None, + allow_redirects=True, + ) + except Exception as e: + return _LivenessProbe( + ok=False, reason=getattr(e, "reason", None) or "unknown" + ) + + if response.status < 400: + return _LivenessProbe( + ok=True, latency_ms=(time.monotonic() - start) * 1000.0 + ) + + logger.debug(f"代理 {sanitize_proxy(proxy_url)} 访问测试地址返回状态码 {response.status}") + return _LivenessProbe(ok=False, reason=f"status_{response.status}") + + async def _probe_identity(self, proxy_url: str) -> _IdentityProbe: + candidates = self._identity_candidates() + if not candidates: + return _IdentityProbe(reason="no_identity_endpoints") + + outcome = await self._race( + candidates, + lambda url: self._fetch_identity(proxy_url, url), + prefer=lambda sample: sample.identity is not None, + prefer_grace_seconds=_PREFER_IDENTITY_GRACE_SECONDS, + ) + + sample = outcome.value if isinstance(outcome.value, _IdentitySample) else None + if sample is not None: + return _IdentityProbe( + sample=sample, proven_https="https" in outcome.proven_schemes + ) + + attributable = self._endpoints.identity_available() + if attributable: + logger.warning( + "代理 %s 可达但全部 %d 个出口 IP 检测端点都失败," + "本轮拿不到出口 IP 与匿名度", + proxy_url, len(candidates), + ) + + return _IdentityProbe( + tunnel_refused=bool(outcome.failure_reasons & _TUNNEL_REFUSED_REASONS), + proven_https="https" in outcome.proven_schemes, + attributable=attributable, + reason="no_identity" if attributable else "identity_endpoints_down", + ) + + async def _probe_plain_identity(self, proxy_url: str) -> Optional[_IdentityProbe]: + candidates = self._endpoints.ordered_by_latency( + self._endpoints.plain_identity_endpoints() + ) + if not candidates: + return None + + outcome = await self._race( + candidates, + lambda url: self._fetch_identity(proxy_url, url), + prefer=lambda sample: sample.identity is not None, + prefer_grace_seconds=_PREFER_IDENTITY_GRACE_SECONDS, + ) + sample = outcome.value if isinstance(outcome.value, _IdentitySample) else None + if sample is None or sample.identity is None: + return None + return _IdentityProbe( + sample=sample, proven_https="https" in outcome.proven_schemes + ) + + async def _probe_plain_http(self, proxy_url: str) -> ProbeOutcome: + urls = self._endpoints.ordered_by_latency( + self._endpoints.http_probe_endpoints() + ) + if not urls: + return ProbeOutcome( + kind=ProbeKind.PLAIN_HTTP, ok=None, reason="no_probe_endpoints" + ) + + outcome = await self._race( + urls, + lambda url: self._fetch_plain_http(proxy_url, url), + ) + if isinstance(outcome.value, float): + return ProbeOutcome( + kind=ProbeKind.PLAIN_HTTP, ok=True, latency_ms=outcome.value + ) + + if any(self._endpoints.is_reachable_directly(url) for url in urls): + reason = sorted(outcome.failure_reasons)[0] if outcome.failure_reasons else "unreachable" + return ProbeOutcome( + kind=ProbeKind.PLAIN_HTTP, ok=False, reason=reason + ) + return ProbeOutcome( + kind=ProbeKind.PLAIN_HTTP, ok=None, reason="probe_endpoints_down" + ) + + async def _fetch_identity(self, proxy_url: str, url: str) -> _Attempt: + scheme = self._scheme_of(url) + try: + response = await self._http_client.get( + url, + proxy=proxy_url, + timeout=self._timeout, + connect_timeout=self._connect_timeout or None, + allow_redirects=True, + ssl=True, + ) + except Exception as e: + reason = getattr(e, "reason", None) or "unknown" + logger.debug(f"身份回显请求失败 {url}(代理 {sanitize_proxy(proxy_url)}): {e}") + return _Attempt(reason=reason) + + if response.status != 200: + return _Attempt(reason=f"status_{response.status}", proven_scheme=scheme) + + identity = parse_identity_echo(response.text) + exit_ip = pick_exit_ip(identity.chain) if identity else None + if exit_ip: + return _Attempt( + value=_IdentitySample(url=url, exit_ip=exit_ip, identity=identity), + proven_scheme=scheme, + ) + + plain_ip = parse_exit_ip(response.text) + if plain_ip: + return _Attempt( + value=_IdentitySample(url=url, exit_ip=plain_ip), + proven_scheme=scheme, + ) + + logger.debug(f"身份回显响应无法解析 {url}(代理 {sanitize_proxy(proxy_url)})") + return _Attempt(reason="unparsable", proven_scheme=scheme) + + async def _fetch_plain_http(self, proxy_url: str, url: str) -> _Attempt: + start = time.monotonic() + try: + response = await self._http_client.get( + url, + proxy=proxy_url, + timeout=self._timeout, + connect_timeout=self._connect_timeout or None, + allow_redirects=False, + ) + except Exception as e: + reason = getattr(e, "reason", None) or "unknown" + logger.debug(f"明文探测失败 {url}(代理 {sanitize_proxy(proxy_url)}): {e}") + return _Attempt(reason=reason) + + if response.status >= 400: + return _Attempt(reason=f"status_{response.status}") + return _Attempt(value=(time.monotonic() - start) * 1000.0) + + def _attribute_target_failure( + self, + proxy_url: str, + reason: Optional[str], + probes: Sequence[ProbeOutcome], + test_url: str, + ) -> ProxyValidationResult: + if reason in _LOCAL_CONGESTION_REASONS: + return self._build_result( + proxy_url, + ValidationOutcome.INCONCLUSIVE, + error_message="本机连接池排队超时,本次不下结论", + error_reason=reason, + probes=probes, + ) + + if reason in _OUR_FAULT_REASONS: + return self._build_result( + proxy_url, + ValidationOutcome.INCONCLUSIVE, + error_message="本地缺少 SOCKS 支持,无法验证该代理", + error_reason=reason, + probes=probes, + ) + + if reason in _PROXY_UNREACHABLE_REASONS: + outcome = ValidationOutcome.PROXY_UNREACHABLE + message = "无法连接代理" + error_reason = reason + else: + reachable = self._endpoints.is_reachable_directly(test_url) + if reachable is False: + outcome = ValidationOutcome.INCONCLUSIVE + message = "测试地址当前不可达,无法判断代理是否可用" + error_reason = "target_unreachable" + else: + outcome = ValidationOutcome.PROXY_FAILED + message = "代理无法转发请求" + error_reason = reason + + logger.debug(f"代理 {sanitize_proxy(proxy_url)} 连通性失败: {message}({error_reason})") + return self._build_result( + proxy_url, + outcome, + error_message=message, + error_reason=error_reason, + probes=probes, + ) + + def _resolve_capabilities( + self, + liveness: _LivenessProbe, + identity: Optional[_IdentityProbe], + plain: Optional[ProbeOutcome], + test_url: str, + ) -> dict[str, Optional[bool]]: + capabilities: dict[str, Optional[bool]] = {"http": None, "https": None} + + if liveness.ok: + self._mark_capability(capabilities, test_url) + + if identity is not None: + if identity.proven_https: + capabilities["https"] = True + elif identity.tunnel_refused and self._endpoints.identity_available(): + capabilities["https"] = False + + if plain is not None and plain.ok is not None and capabilities["http"] is None: + capabilities["http"] = plain.ok + + return capabilities + + def _anonymity_check_enabled(self) -> bool: + config = self._config_manager.get_config() + return bool(getattr(getattr(config, "validator", None), + "check_anonymity", True)) + + def _judge_identity_anonymity( + self, + identity: Optional[_IdentityProbe], + plain_identity: Optional[_IdentityProbe] = None, + ) -> "_AnonymityVerdict": + if not self._anonymity_check_enabled(): + return _AnonymityVerdict() + + if identity is None and plain_identity is None: + return _AnonymityVerdict() + + host_public_ips = self._endpoints.host_public_ips() + if not host_public_ips: + return _AnonymityVerdict() + + for probe in (plain_identity, identity): + if probe is None or probe.sample is None or probe.sample.identity is None: + continue + level = self._judge_anonymity( + probe.sample.identity, + self._endpoints.baseline_for(probe.sample.url), + host_public_ips, + ) + if level is not None: + return _AnonymityVerdict(level=level) + + exit_ip = identity.real_ip if identity else None + if exit_ip in host_public_ips: + return _AnonymityVerdict(level="transparent") + + if identity is not None and identity.sample is None and not identity.attributable: + return _AnonymityVerdict() + + return _AnonymityVerdict(level=_FALLBACK_ANONYMITY, fallback=True) + + def _hedge_seconds(self, urls: Sequence[str]) -> float: + best_ms = min( + ( + latency + for latency in ( + self._endpoints.direct_latency_ms(url) for url in urls + ) + if latency is not None + ), + default=None, + ) + if best_ms is None: + hedge = _HEDGE_MIN_SECONDS + else: + hedge = min(_HEDGE_MAX_SECONDS, max(_HEDGE_MIN_SECONDS, best_ms * 2 / 1000.0)) + + if len(urls) > 1: + hedge = min(hedge, _RACE_LAUNCH_WINDOW_SECONDS / (len(urls) - 1)) + return hedge + + async def _race( + self, + urls: Sequence[str], + fetch: Callable[[str], object], + *, + prefer: Optional[Callable[[object], bool]] = None, + prefer_grace_seconds: float = 0.0, + ) -> _RaceOutcome: + candidates = list(urls) + if not candidates: + return _RaceOutcome() + + hedge = self._hedge_seconds(candidates) + pending: dict[asyncio.Task, str] = {} + launched = 0 + next_launch_at = time.monotonic() + reasons: set[str] = set() + schemes: set[str] = set() + fallback_value = None + grace_until: Optional[float] = None + + try: + while True: + if launched < len(candidates) and time.monotonic() >= next_launch_at: + url = candidates[launched] + launched += 1 + task = asyncio.create_task(fetch(url), name=f"race_{url}") + pending[task] = url + next_launch_at = time.monotonic() + hedge + + if not pending: + if launched >= len(candidates): + break + await asyncio.sleep(max(0.0, next_launch_at - time.monotonic())) + continue + + wait_for = None + if launched < len(candidates): + wait_for = max(0.0, next_launch_at - time.monotonic()) + if grace_until is not None: + grace_left = max(0.0, grace_until - time.monotonic()) + wait_for = grace_left if wait_for is None else min(wait_for, grace_left) + + done, _ = await asyncio.wait( + set(pending), timeout=wait_for, return_when=asyncio.FIRST_COMPLETED + ) + if not done: + if grace_until is not None and time.monotonic() >= grace_until: + break + continue + + for task in done: + pending.pop(task, None) + if task.cancelled() or task.exception() is not None: + continue + attempt = task.result() + if not isinstance(attempt, _Attempt): + continue + if attempt.reason: + reasons.add(attempt.reason) + if attempt.proven_scheme: + schemes.add(attempt.proven_scheme) + if attempt.value is None: + continue + + if prefer is None or prefer(attempt.value): + return _RaceOutcome( + value=attempt.value, + failure_reasons=frozenset(reasons), + proven_schemes=frozenset(schemes), + ) + + if fallback_value is None: + fallback_value = attempt.value + grace_until = time.monotonic() + prefer_grace_seconds + + if grace_until is not None and time.monotonic() >= grace_until: + break + finally: + await self._cancel_tasks(*pending) + + return _RaceOutcome( + value=fallback_value, + failure_reasons=frozenset(reasons), + proven_schemes=frozenset(schemes), + ) + + @staticmethod + async def _cancel_tasks(*tasks: Optional[asyncio.Task]) -> None: + pending = [task for task in tasks if task is not None and not task.done()] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + @staticmethod + async def _settle(task: Optional[asyncio.Task]): + if task is None: + return None + try: + return await task + except asyncio.CancelledError: + raise + except Exception as exc: + logger.debug(f"探测任务异常: {exc}") + return None + + def _identity_candidates(self) -> list[str]: + identity = self._endpoints.ordered_by_latency( + self._endpoints.identity_endpoints() + ) + ip_echo = [ + url + for url in self._endpoints.ordered_by_latency(self._ip_apis) + if self._endpoints.is_reachable_directly(url) is not False + ] + return list(dict.fromkeys(identity + ip_echo)) + + def _scheme_of(self, url: str) -> Optional[str]: + scheme = urlparse(url).scheme.lower() + return scheme if scheme in ("http", "https") else None + + @staticmethod + def _mark_capability(capabilities: dict, url: str) -> None: + scheme = urlparse(url).scheme.lower() + if scheme in capabilities: + capabilities[scheme] = True + + def _build_result( + self, + proxy_url: str, + outcome: ValidationOutcome, + delay_ms: Optional[float] = None, + error_message: Optional[str] = None, + error_reason: Optional[str] = None, + real_ip: Optional[str] = None, + anonymity_level: Optional[str] = None, + anonymity_fallback: bool = False, + supports_http: Optional[bool] = None, + supports_https: Optional[bool] = None, + probes: Sequence[ProbeOutcome] = (), + ) -> ProxyValidationResult: + return ProxyValidationResult( + proxy_url=proxy_url, + outcome=outcome, + delay_ms=delay_ms if outcome is ValidationOutcome.OK else None, + error_message=error_message, + error_reason=error_reason, + real_ip=real_ip, + validated_at=datetime.now(), + anonymity_level=anonymity_level, + anonymity_fallback=anonymity_fallback, + supports_http=supports_http, + supports_https=supports_https, + probes=tuple(probes), + ) + + @staticmethod + def _judge_anonymity( + echo: IdentityEcho, + baseline, + host_public_ips: Sequence[str], + ) -> Optional[str]: + if not echo.header_names: + return None + + if any(ip in echo.chain for ip in host_public_ips): + return "transparent" + + leaked, proxy_signal = _scan_header_ips(echo, baseline, host_public_ips) + if leaked: + return "transparent" + + if proxy_signal: + return "anonymous" + + direct_chain_length = baseline.direct_chain_length if baseline else 1 + if len(echo.chain) > direct_chain_length: + return "anonymous" + + return "elite" + + diff --git a/modules/proxypool/data/geodb/GeoLite2-City.mmdb b/modules/proxypool/data/geodb/GeoLite2-City.mmdb new file mode 100644 index 0000000..6b0d63e Binary files /dev/null and b/modules/proxypool/data/geodb/GeoLite2-City.mmdb differ diff --git a/modules/proxypool/data/geodb/qqwry.ipdb b/modules/proxypool/data/geodb/qqwry.ipdb new file mode 100644 index 0000000..078eab9 Binary files /dev/null and b/modules/proxypool/data/geodb/qqwry.ipdb differ diff --git a/modules/proxypool/logs/proxy_pool.log b/modules/proxypool/logs/proxy_pool.log new file mode 100644 index 0000000..e69de29 diff --git a/modules/proxypool/plugins/geonode_plugin.py b/modules/proxypool/plugins/geonode_plugin.py new file mode 100644 index 0000000..cea8519 --- /dev/null +++ b/modules/proxypool/plugins/geonode_plugin.py @@ -0,0 +1,160 @@ +""" +模块名称:modules.proxypool.plugins.geonode_plugin +功能描述:经 GeoNode 公开 REST API 分页抓取代理:逐页请求 JSON,把记录的 ip / port / protocols 转为代理 URL,批次内去重后交插件管理器验证入库。 +职责边界:负责:分页遍历、单页响应解析、协议挑选、记录合法性校验、批次内去重;不负责:与池内 + 已有代理的去重(PluginManager.deduplicate_proxies)、验证与入库(application.ingest)、 + HTTP 客户端构造与超时(由 context.http_client 承担)。 +关键依赖:core.interfaces.services(IProxyPlugin / PluginContext)、core.domain.models + (Proxy、SUPPORTED_PROTOCOLS)、core.domain.exceptions(PluginExecutionException); + 请求经 context 注入的 http_client 发出,数据源自 GeoNode 公开 REST API。 +已知限制:1. 页数上限 _MAX_PAGES=20、每页 500 条,最多约 1 万条原始记录即停止,不保证抓全整个列表。 +2. 单页请求异常、非 200、JSON 解析失败或缺 data 数组均按「本页无数据」返回 None 并终止分页,不重试;空列表同样是终止信号。 +3. 首页失败向上抛 PluginExecutionException,整次抓取按失败上报,调度器据此退避重试;后续页失败保留已取得部分并记 warning。 +4. 协议仅在 http / https / socks5 中按 _PROTOCOL_PRIORITY(socks5 > https > http)取最高优先级,全不匹配时按 http 兜底。 +5. ip / port 为空、port 非整数或越界、URL 通不过 Proxy.from_url 的记录按单条丢弃,基本无日志(仅部分记 debug)。 +6. ip / port 非字符串(含 null)、protocols 为 null 或含非字符串、条目非对象时,异常不受单条防护、冒泡到页级 except,整页作废。 +7. 去重只覆盖本批次;跨批次与池内重复由 PluginManager.deduplicate_proxies 处理;注册键取文件名 stem geonode_plugin,而非 name。 +""" + +import json +from typing import List, Optional + +from core.interfaces.services import IProxyPlugin, PluginContext +from core.domain.exceptions import PluginExecutionException +from core.domain.models import SUPPORTED_PROTOCOLS, Proxy + + +class GeoNodePlugin(IProxyPlugin): + + _API_TEMPLATE = ( + "https://proxylist.geonode.com/api/proxy-list" + "?limit=500&page={page}&sort_by=lastChecked&sort_type=desc" + ) + + _MAX_PAGES = 20 + + _PROTOCOL_PRIORITY = {"socks5": 0, "https": 1, "http": 2} + + @property + def name(self) -> str: + return "geonode" + + @property + def version(self) -> str: + return "2.0.0" + + async def fetch_proxies(self, context: PluginContext) -> List[str]: + logger = context.logger + logger.info("正在从 GeoNode API 分页抓取代理...") + + seen_hosts: set = set() + proxy_list: List[str] = [] + + for page in range(1, self._MAX_PAGES + 1): + page_proxies = await self._fetch_page(context, page) + + if page_proxies is None: + if page == 1: + raise PluginExecutionException( + "GeoNode 首页抓取失败,本次未取得任何代理" + ) + logger.warning( + f"GeoNode 第 {page} 页抓取失败,本次提前结束," + f"已取得 {len(proxy_list)} 个代理" + ) + break + + if len(page_proxies) == 0: + logger.info(f"GeoNode 第 {page} 页返回空列表,终止分页") + break + + page_new_count = 0 + for proxy_url in page_proxies: + try: + proxy_obj = Proxy.from_url(proxy_url, source_plugin="geonode") + host_key = f"{proxy_obj.ip}:{proxy_obj.port}" + if host_key not in seen_hosts: + seen_hosts.add(host_key) + proxy_list.append(proxy_url) + page_new_count += 1 + except ValueError: + continue + + logger.debug(f"GeoNode 第 {page} 页贡献 {page_new_count} 个新代理") + + logger.info(f"GeoNode 插件共获取 {len(proxy_list)} 个去重代理") + return proxy_list + + async def _fetch_page( + self, context: PluginContext, page: int + ) -> Optional[List[str]]: + logger = context.logger + url = self._API_TEMPLATE.format(page=page) + + try: + response = await context.http_client.get(url) + + if response.status != 200: + logger.warning(f"GeoNode 第 {page} 页返回 HTTP {response.status}") + return None + + try: + payload = json.loads(response.text) + except json.JSONDecodeError as e: + logger.error(f"GeoNode 第 {page} 页 JSON 解析失败: {e}") + return None + + data_list = payload.get("data") + if not isinstance(data_list, list): + logger.warning(f"GeoNode 第 {page} 页响应中缺少 data 数组") + return None + + proxies: List[str] = [] + for item in data_list: + proxy_url = self._parse_item(item, logger) + if proxy_url: + proxies.append(proxy_url) + + logger.info(f"GeoNode 第 {page} 页解析出 {len(proxies)} 个代理") + return proxies + + except Exception as e: + logger.error(f"请求 GeoNode 第 {page} 页异常: {e}", exc_info=True) + return None + + def _parse_item(self, item: dict, logger) -> Optional[str]: + ip = item.get("ip", "").strip() + port_str = item.get("port", "").strip() + protocols_raw = item.get("protocols", []) + + if not ip or not port_str: + return None + + try: + port = int(port_str) + except (ValueError, TypeError): + logger.debug(f"GeoNode 数据中端口非法: ip={ip}, port={port_str}") + return None + + if not (1 <= port <= 65535): + return None + + supported_protocols = set(SUPPORTED_PROTOCOLS) + valid_protocols = [ + p.lower() for p in protocols_raw if p.lower() in supported_protocols + ] + + if not valid_protocols: + valid_protocols = ["http"] + + valid_protocols.sort(key=lambda p: self._PROTOCOL_PRIORITY.get(p, 99)) + protocol = valid_protocols[0] + + raw_url = f"{protocol}://{ip}:{port}" + + try: + proxy_obj = Proxy.from_url(raw_url, source_plugin="geonode") + return proxy_obj.proxy_url + except ValueError as e: + logger.debug(f"GeoNode 代理格式校验失败: {raw_url}, 原因: {e}") + return None diff --git a/modules/proxypool/plugins/github_proxy_plugin.py b/modules/proxypool/plugins/github_proxy_plugin.py new file mode 100644 index 0000000..e8b00e8 --- /dev/null +++ b/modules/proxypool/plugins/github_proxy_plugin.py @@ -0,0 +1,176 @@ +""" +模块名称:modules.proxypool.plugins.github_proxy_plugin +功能描述:对 SOURCES 中硬编码的 GitHub 代理清单源并发抓取纯文本清单,逐行解析为代理 URL,按 ip:port 在批次内去重后返回;单源失败只记日志,不影响其余源。 +职责边界:负责:各源的并发抓取、文本行到代理 URL 的解析、批次内去重;不负责:代理可用性验证与入库 + (见 application.ingest)、抓取间隔与测试地址(由 PluginManager 的插件配置控制)、网络会话与超时(由 + PluginContext 注入的 http_client 承担)。 +关键依赖:core.interfaces.services(IProxyPlugin、PluginContext)、core.domain.models.Proxy; + core.domain.exceptions(PluginExecutionException)。 +已知限制: + 1. 源清单硬编码在 SOURCES:12 个源(6 个 http、5 个 socks5、1 个 https),增删源必须改代码。 + 2. 单源失败只记日志并跳过,非 200 也按失败抛出;返回非空只代表至少一个源有产出,全部源失败才抛 PluginExecutionException。 + 3. 空行与 # 开头行跳过,行内无 :// 时补该源默认协议,单行解析失败只记 debug 并丢弃。 + 4. 去重只覆盖本批次(按 Proxy.from_url 解析出的 ip:port),跨批次与池内重复由 PluginManager 处理。 + 5. 只解析每行一个 ip:port 或完整 URL;带多余列的行解析失败被丢弃,清单格式变化只会让产出静默减少。 + 6. 调度键、配置键与入库来源取文件名 stem(github_proxy_plugin);文件内 source_plugin 字面量不决定入库来源,name/version 无调用方读取。 +""" + +import asyncio +from typing import List, Tuple + +from core.interfaces.services import IProxyPlugin, PluginContext +from core.domain.exceptions import PluginExecutionException +from core.domain.models import Proxy + + +class GitHubProxyPlugin(IProxyPlugin): + + SOURCES: List[Tuple[str, str]] = [ + ( + "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt", + "http", + ), + ( + "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt", + "socks5", + ), + ( + "https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt", + "http", + ), + ( + "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt", + "http", + ), + ( + "https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/socks5.txt", + "socks5", + ), + ( + "https://raw.githubusercontent.com/hookzof/socks5_list/master/proxy.txt", + "socks5", + ), + ( + "https://raw.githubusercontent.com/ShiftyTR/Proxy-List/master/http.txt", + "http", + ), + ( + "https://raw.githubusercontent.com/ShiftyTR/Proxy-List/master/socks5.txt", + "socks5", + ), + ( + "https://raw.githubusercontent.com/jetkai/proxy-list/main/online-proxies/txt/proxies-http.txt", + "http", + ), + ( + "https://raw.githubusercontent.com/jetkai/proxy-list/main/online-proxies/txt/proxies-socks5.txt", + "socks5", + ), + ( + "https://raw.githubusercontent.com/roosterkid/openproxylist/main/HTTPS_RAW.txt", + "https", + ), + ( + "https://raw.githubusercontent.com/MuRongPIG/Proxy-Master/main/http.txt", + "http", + ), + ] + + @property + def name(self) -> str: + return "github_proxy" + + @property + def version(self) -> str: + return "2.0.0" + + async def fetch_proxies(self, context: PluginContext) -> List[str]: + logger = context.logger + logger.info(f"正在从 {len(self.SOURCES)} 个 GitHub 代理源并发抓取...") + + tasks = [ + self._fetch_single_source(context, url, default_protocol) + for url, default_protocol in self.SOURCES + ] + + results = await asyncio.gather(*tasks, return_exceptions=True) + + seen_hosts: set = set() + proxy_list: List[str] = [] + failed_sources = [] + + for idx, result in enumerate(results): + source_url = self.SOURCES[idx][0] + + if isinstance(result, Exception): + logger.error(f"源 {source_url} 抓取失败: {result}") + failed_sources.append(source_url) + continue + + if not isinstance(result, list): + logger.warning(f"源 {source_url} 返回非列表类型: {type(result)}") + continue + + source_count = 0 + for proxy_url in result: + try: + proxy_obj = Proxy.from_url(proxy_url, source_plugin="github_proxy") + host_key = f"{proxy_obj.ip}:{proxy_obj.port}" + if host_key not in seen_hosts: + seen_hosts.add(host_key) + proxy_list.append(proxy_url) + source_count += 1 + except ValueError: + continue + + logger.debug(f"源 {source_url} 贡献 {source_count} 个新代理") + + if failed_sources and len(failed_sources) == len(self.SOURCES): + raise PluginExecutionException( + f"GitHub 代理插件全部 {len(self.SOURCES)} 个源都抓取失败" + ) + + logger.info(f"GitHub 代理插件共获取 {len(proxy_list)} 个去重代理") + return proxy_list + + async def _fetch_single_source( + self, + context: PluginContext, + url: str, + default_protocol: str, + ) -> List[str]: + logger = context.logger + proxies: List[str] = [] + + try: + response = await context.http_client.get(url) + + if response.status != 200: + raise PluginExecutionException( + f"请求源 {url} 失败,HTTP 状态码: {response.status}" + ) + + lines = response.text.strip().split("\n") + + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + + if "://" not in line: + line = f"{default_protocol}://{line}" + + try: + proxy_obj = Proxy.from_url(line, source_plugin="github_proxy") + proxies.append(proxy_obj.proxy_url) + except ValueError as e: + logger.debug(f"解析代理行失败 '{line}': {e}") + continue + + logger.info(f"从 {url} 获取了 {len(proxies)} 个代理") + + except Exception as e: + logger.warning(f"抓取源 {url} 发生异常: {e}") + raise + + return proxies diff --git a/modules/proxypool_api.py b/modules/proxypool_api.py new file mode 100644 index 0000000..f2ae17e --- /dev/null +++ b/modules/proxypool_api.py @@ -0,0 +1,471 @@ +""" +模块名称:modules.proxypool_api +功能描述:代理池的 Web 适配层:把 ProxyPoolService 门面包装成 /api/pool 前缀的 Flask 蓝图,与其余接口共用同一端口。 +职责边界:负责:查询参数与 JSON 体的解析及类型强制、把处理函数结果序列化为响应、把异常映射成统一的错误响应、 + 池启停与状态查询端点、蓝图内各条 URL 规则的注册;不负责:业务逻辑(core.application.api.routes)、 + 跨线程调用与超时控制(ProxyPoolService)、token 鉴权(由注册方统一添加)。 +关键依赖:modules.proxypool_service、modules.modules、modules.pool_config_ini(延迟导入)、 + core.application.api.routes、core.application.api.dependencies、 + core.application.api.http_types、core.domain.exceptions、Flask。 +已知限制: + 1. 查询参数按处理函数签名作白名单解析:签名里没有的参数被静默忽略,非字符串参数传空串按未传处理;只有 bool / int / float 注解会被强制转换。 + 2. 每个请求占用一个 Web 工作线程直到池返回结果,超时三档:QUERY_TIMEOUT 10 秒、MUTATION_TIMEOUT 20 秒、HEAVY_TIMEOUT 60 秒。 + 3. 异常映射顺序:先按 _EXCEPTION_MAP 匹配;再由 ApiError 带出状态码;ValueError 映射 400;其余落 500 兜底。 + 4. 数据层与适配层的参数异常 InvalidParameterException 继承 ValueError 映射 400;RepositoryException 族漏列入表才落 500。 + 5. /api/pool 前缀与成功响应的数据结构是对外契约,改动会破坏既有对接方。 + 6. 备份恢复要求池已停止(判据是 has_live_thread 而不是 is_running),且经 asyncio.run 在池事件循环之外执行。 + 7. 蓝图自身不带鉴权,单独挂载将没有任何 token 校验;鉴权由注册方(app.py 的 before_request)提供。 + 8. /status 的统计查询失败只记 warning 并把 stats 置为 null,状态端点仍返回 success;不得因此让状态端点整体失败。 +""" + +import asyncio +import inspect +import logging +from functools import wraps +from typing import Any, Callable, Union, get_args, get_origin + +from flask import Blueprint, Response, jsonify, request + +from core.application.api.dependencies import get_language +from core.application.api.http_types import ApiError, RawResponse +from core.domain.exceptions import ( + DataValidationException, + InvalidParameterException, + ProxyValidationException, + QueryBuildException, + WriteQueueFullException, +) +from core.application.api.routes import plugins as plugin_routes +from core.application.api.routes import proxies as proxy_routes +from core.application.api.routes import validation as validation_routes +from core.application.api.routes import database as database_routes +from core.application.api.routes import geo as geo_routes +from modules.proxypool_service import ( + PoolTimeoutError, + PoolUnavailableError, + ProxyPoolService, +) +from modules.modules import FALSE_WORDS, TRUE_WORDS, get_message + +logger = logging.getLogger(__name__) + +QUERY_TIMEOUT = 10.0 + +MUTATION_TIMEOUT = 20.0 + +HEAVY_TIMEOUT = 60.0 + +_TRUE_VALUES = TRUE_WORDS +_FALSE_VALUES = FALSE_WORDS + +_EXCEPTION_MAP: tuple[tuple[type, int, str], ...] = ( + (PoolUnavailableError, 503, "pool_unavailable"), + (PoolTimeoutError, 504, "pool_timeout"), + (DataValidationException, 400, "invalid_parameter"), + (QueryBuildException, 400, "invalid_parameter"), + (ProxyValidationException, 400, "invalid_parameter"), + (WriteQueueFullException, 503, "queue_full"), +) + +_ERROR_CODE_MESSAGE_KEYS: dict[str, str] = { + "pool_unavailable": 'pool_unavailable', + "pool_timeout": 'pool_error_timeout', + "invalid_parameter": 'pool_invalid_parameters', + "queue_full": 'pool_request_failed', + "api_error": 'pool_request_failed', + "internal_error": 'pool_internal_error', +} + +_LIFECYCLE_ACTIONS = ("start", "stop", "restart") + + +def _unwrap_optional(annotation: Any) -> Any: + if get_origin(annotation) is Union: + inner = [a for a in get_args(annotation) if a is not type(None)] + if len(inner) == 1: + return inner[0] + return annotation + + +def _coerce(raw: str, target: Any) -> Any: + if target is bool: + lowered = raw.strip().lower() + if lowered in _TRUE_VALUES: + return True + if lowered in _FALSE_VALUES: + return False + raise ValueError(f"expected a boolean: {raw!r}") + if target is int: + return int(raw) + if target is float: + return float(raw) + return raw + + +def _signature_params(handler: Callable) -> dict[str, inspect.Parameter]: + return inspect.signature(handler).parameters + + +def _query_params(handler: Callable) -> dict: + params: dict[str, Any] = {} + + for name, parameter in _signature_params(handler).items(): + if name not in request.args: + continue + + raw = request.args.get(name) + target = _unwrap_optional(parameter.annotation) + if target is inspect.Parameter.empty: + target = str + + if raw == "" and target is not str: + continue + + params[name] = _coerce(raw, target) + + return params + + +def _body_params(handler: Callable, path_params: dict) -> dict: + body = request.get_json(silent=True) + if body is None: + body = {} + if not isinstance(body, dict): + raise InvalidParameterException('param_body_must_be_json') + + params: dict[str, Any] = {} + missing: list[str] = [] + + for name, parameter in _signature_params(handler).items(): + if name in path_params: + params[name] = path_params[name] + elif name in body: + params[name] = body[name] + elif parameter.default is inspect.Parameter.empty: + missing.append(name) + + if missing: + raise InvalidParameterException('param_missing_fields', ', '.join(missing)) + + return params + + +def _to_response(result: Any): + if isinstance(result, RawResponse): + response = Response(result.content, content_type=result.mimetype) + if result.filename: + response.headers["Content-Disposition"] = ( + f"attachment; filename={result.filename}" + ) + return response + return jsonify(result) + + +def _render_exception(exc: Exception, language: str) -> str: + key = getattr(exc, 'message_key', '') + if not key: + return str(exc) + return get_message(key, language, *getattr(exc, 'message_args', ())) + + +def _error_response(exc: Exception, language: str = 'cn'): + for exc_type, status, error_code in _EXCEPTION_MAP: + if isinstance(exc, exc_type): + message_key = _ERROR_CODE_MESSAGE_KEYS.get(error_code, 'pool_request_failed') + logger.warning(get_message(message_key, language, exc)) + return jsonify({ + "status": "error", + "message": _render_exception(exc, language), + "error_code": error_code, + }), status + + if isinstance(exc, ApiError): + return jsonify({ + "status": "error", "message": exc.message, "error_code": "api_error", + }), exc.status_code + + if isinstance(exc, ValueError): + logger.warning(get_message('pool_invalid_parameters', language, exc)) + return jsonify({ + "status": "error", "message": _render_exception(exc, language), + "error_code": "invalid_parameter", + }), 400 + + logger.error(get_message('pool_request_failed', language, exc), exc_info=True) + return jsonify({ + "status": "error", "message": get_message('pool_internal_error', language), + "error_code": "internal_error", + }), 500 + + +def _run(service: ProxyPoolService, coro, timeout: float): + try: + return _to_response(service.call(coro, timeout=timeout)), None + except Exception as exc: # noqa: BLE001 + return None, _error_response(exc, service.language) + + +def _bind_query(service: ProxyPoolService, handler: Callable, + timeout: float = QUERY_TIMEOUT) -> Callable: + @wraps(handler) + def view(**path_params: Any): + try: + params = {**_query_params(handler), **path_params} + except Exception as exc: # noqa: BLE001 + return _error_response(exc, service.language) + + response, error = _run(service, handler(**params), timeout) + return error or response + + return view + + +def _bind_body(service: ProxyPoolService, handler: Callable, + timeout: float = MUTATION_TIMEOUT) -> Callable: + @wraps(handler) + def view(**path_params: Any): + try: + params = _body_params(handler, path_params) + except Exception as exc: # noqa: BLE001 + return _error_response(exc, service.language) + + response, error = _run(service, handler(**params), timeout) + return error or response + + return view + + +def _bind_no_args(service: ProxyPoolService, coro_factory: Callable, + timeout: float = MUTATION_TIMEOUT) -> Callable: + def view(): + response, error = _run(service, coro_factory(), timeout) + return error or response + + return view + + +def create_pool_blueprint(service: ProxyPoolService) -> Blueprint: + blueprint = Blueprint("pool_api", __name__, url_prefix="/api/pool") + + _register_lifecycle_routes(blueprint, service) + _register_proxy_routes(blueprint, service) + _register_plugin_routes(blueprint, service) + _register_batch_routes(blueprint, service) + _register_database_routes(blueprint, service) + _register_geo_routes(blueprint, service) + + return blueprint + + +def _register_geo_routes(blueprint: Blueprint, service: ProxyPoolService) -> None: + blueprint.add_url_rule( + "/geo/status", "pool_geo_status", + _bind_query(service, geo_routes.get_geo_status), methods=["GET"], + ) + blueprint.add_url_rule( + "/geo/update", "pool_geo_update", + _bind_no_args(service, geo_routes.start_geo_update), methods=["POST"], + ) + blueprint.add_url_rule( + "/geo/recompute", "pool_geo_recompute", + _bind_no_args(service, geo_routes.start_geo_recompute), methods=["POST"], + ) + + +def _register_database_routes(blueprint: Blueprint, service: ProxyPoolService) -> None: + def backups_view(): + try: + return _to_response(database_routes.list_backups()) + except Exception as exc: # noqa: BLE001 + return _error_response(exc, service.language) + + def restore_view(): + try: + if service.has_live_thread: + return jsonify({ + "status": "error", + "message": get_message('pool_restore_requires_stop', service.language), + "error_code": "pool_running", + }), 409 + body = request.get_json(silent=True) or {} + if not isinstance(body, dict) or not str(body.get("filename", "")).strip(): + return jsonify({ + "status": "error", + "message": get_message('pool_backup_filename_required', service.language), + "error_code": "invalid_parameter", + }), 400 + + result = asyncio.run( + database_routes.restore_backup(str(body["filename"]).strip()) + ) + return _to_response(result) + except Exception as exc: # noqa: BLE001 + return _error_response(exc, service.language) + + blueprint.add_url_rule("/backups", "pool_backups", backups_view, methods=["GET"]) + blueprint.add_url_rule( + "/backups/restore", "pool_backup_restore", restore_view, methods=["POST"] + ) + blueprint.add_url_rule( + "/database/stats", "pool_db_stats", + _bind_query(service, database_routes.get_database_stats), methods=["GET"], + ) + blueprint.add_url_rule( + "/database/optimize", "pool_db_optimize", + _bind_no_args(service, database_routes.optimize_database, HEAVY_TIMEOUT), + methods=["POST"], + ) + + +def _register_lifecycle_routes(blueprint: Blueprint, service: ProxyPoolService) -> None: + def status(): + stats = None + if service.is_running: + try: + stats = service.call(proxy_routes.get_stats(), timeout=QUERY_TIMEOUT) + except Exception as exc: # noqa: BLE001 + logger.warning(get_message('pool_status_error', service.language, exc)) + + return jsonify({ + "status": "success", + "is_running": service.is_running, + "last_error": service.last_error, + "stats": stats, + }) + + blueprint.add_url_rule("/status", "pool_status", status, methods=["GET"]) + blueprint.add_url_rule("/schema", "pool_schema", _make_schema_view(), methods=["GET"]) + + for action in _LIFECYCLE_ACTIONS: + blueprint.add_url_rule( + f"/{action}", f"pool_{action}", _make_lifecycle_view(service, action), + methods=["POST"], + ) + + +def _make_schema_view() -> Callable: + from modules import pool_config_ini + + def view(): + return jsonify({ + "status": "success", + "groups": pool_config_ini.describe_pool_schema(get_language()), + }) + + return view + + +def _make_lifecycle_view(service: ProxyPoolService, action: str) -> Callable: + def view(): + language = service.language + ok = getattr(service, action)() + if not ok: + detail = service.last_error or get_message('pool_unknown_error', language) + return jsonify({ + "status": "error", + "message": get_message(f"pool_{action}_failed", language, detail), + "is_running": service.is_running, + }), 500 + + return jsonify({ + "status": "success", + "message": get_message(f"pool_{action}_success", language), + "is_running": service.is_running, + }) + + return view + + +def _register_proxy_routes(blueprint: Blueprint, service: ProxyPoolService) -> None: + blueprint.add_url_rule("/get", "pool_get", + _bind_query(service, proxy_routes.get_proxies), methods=["GET"]) + blueprint.add_url_rule("/random", "pool_random", + _bind_query(service, proxy_routes.get_random_proxy), methods=["GET"]) + blueprint.add_url_rule("/count", "pool_count", + _bind_query(service, proxy_routes.get_proxies_count), methods=["GET"]) + blueprint.add_url_rule("/stats", "pool_stats", + _bind_query(service, proxy_routes.get_stats), methods=["GET"]) + blueprint.add_url_rule("/sources", "pool_sources", + _bind_query(service, proxy_routes.get_sources), methods=["GET"]) + blueprint.add_url_rule("/export", "pool_export", + _bind_query(service, proxy_routes.export_proxies, HEAVY_TIMEOUT), + methods=["GET"]) + blueprint.add_url_rule("/import", "pool_import", + _bind_body(service, proxy_routes.import_proxies, HEAVY_TIMEOUT), + methods=["POST"]) + blueprint.add_url_rule("/proxies//favorite", "pool_favorite", + _bind_query(service, proxy_routes.toggle_favorite), methods=["POST"]) + + +def _register_plugin_routes(blueprint: Blueprint, service: ProxyPoolService) -> None: + blueprint.add_url_rule("/plugins", "pool_plugins", + _bind_query(service, plugin_routes.list_plugins), methods=["GET"]) + blueprint.add_url_rule("/plugins//interval", "pool_plugin_interval", + _bind_body(service, plugin_routes.set_plugin_interval), methods=["PUT"]) + blueprint.add_url_rule("/plugins//test-url", "pool_plugin_test_url", + _bind_body(service, plugin_routes.set_plugin_test_url), methods=["PUT"]) + blueprint.add_url_rule("/plugins//validation", "pool_plugin_validation", + _bind_body(service, plugin_routes.set_plugin_validation), + methods=["PUT"]) + blueprint.add_url_rule("/plugins//run", "pool_plugin_run", + _bind_query(service, plugin_routes.run_plugin, HEAVY_TIMEOUT), + methods=["POST"]) + blueprint.add_url_rule("/plugins//reload", "pool_plugin_reload", + _bind_query(service, plugin_routes.reload_plugin, HEAVY_TIMEOUT), + methods=["POST"]) + + for enabled, suffix, endpoint in ((True, "enable", "pool_plugin_enable"), + (False, "disable", "pool_plugin_disable")): + blueprint.add_url_rule( + f"/plugins//{suffix}", endpoint, + _make_plugin_toggle_view(service, enabled), methods=["POST"], + ) + + +def _make_plugin_toggle_view(service: ProxyPoolService, enabled: bool) -> Callable: + def view(plugin_name: str): + response, error = _run( + service, + plugin_routes.set_plugin_enabled(plugin_name, enabled), + MUTATION_TIMEOUT, + ) + return error or response + + return view + + +def _register_batch_routes(blueprint: Blueprint, service: ProxyPoolService) -> None: + blueprint.add_url_rule("/host-ip", "pool_host_ip", + _bind_query(service, validation_routes.get_host_ip), + methods=["GET"]) + blueprint.add_url_rule("/validate/all-valid", "pool_validate_valid", + _bind_no_args(service, lambda: validation_routes.validate_all_proxies(True)), + methods=["POST"]) + blueprint.add_url_rule("/validate/all-invalid", "pool_validate_invalid", + _bind_no_args(service, lambda: validation_routes.validate_all_proxies(False)), + methods=["POST"]) + blueprint.add_url_rule("/update-geo/all", "pool_update_geo", + _bind_no_args(service, validation_routes.update_all_geo), + methods=["POST"]) + blueprint.add_url_rule("/delete/invalid", "pool_delete_invalid", + _bind_no_args(service, validation_routes.delete_invalid_proxies, + HEAVY_TIMEOUT), + methods=["DELETE"]) + blueprint.add_url_rule("/proxies/batch/validate", "pool_batch_validate", + _bind_body(service, validation_routes.validate_batch_proxies, + HEAVY_TIMEOUT), + methods=["POST"]) + blueprint.add_url_rule("/proxies/batch/delete", "pool_batch_delete", + _bind_body(service, validation_routes.delete_batch_proxies, + HEAVY_TIMEOUT), + methods=["DELETE"]) + blueprint.add_url_rule("/tasks", "pool_tasks", + _bind_query(service, validation_routes.list_all_tasks), methods=["GET"]) + blueprint.add_url_rule("/tasks/", "pool_task", + _bind_query(service, validation_routes.get_task_status), methods=["GET"]) + blueprint.add_url_rule("/tasks//cancel", "pool_task_cancel", + _bind_query(service, validation_routes.cancel_task), + methods=["POST"]) + + +__all__ = ["create_pool_blueprint", "QUERY_TIMEOUT", "MUTATION_TIMEOUT", "HEAVY_TIMEOUT"] diff --git a/modules/proxypool_service.py b/modules/proxypool_service.py new file mode 100644 index 0000000..7d52a3d --- /dev/null +++ b/modules/proxypool_service.py @@ -0,0 +1,406 @@ +""" +模块名称:modules.proxypool_service +功能描述:代理池在 ProxyCat 进程内的宿主:用专用线程承载池的 asyncio 事件循环,对外只暴露同步门面方法, + 供 Flask 同步路由与 CLI 调用,另提供进程内事件循环使用的异步取用入口(取一个 / 取一批出口代理)。 +职责边界:负责:事件循环线程的创建与销毁、Application 初始化与关闭、跨线程调用的桥接与超时/异常转换、配置推送; + 不负责:HTTP 路由与请求解析(见 proxypool_api)、配置文件读写(见 ProxyCat 配置接口)、池业务逻辑(见 core.services)。 +关键依赖:core.application(app / api.dependencies)、core.config.Config、core.logger、 + modules.pool_config_ini、modules.logging_setup、modules.loop_noise_filter、modules.modules。 +已知限制: + 1. from modules.proxypool import POOL_DIR 仅靠导入副作用注入 sys.path,必须早于 core.* 导入且不可删除。 + 2. 在池事件循环线程内调用 call() 会死锁;call() 有线程守卫,池线程内调用直接抛 RuntimeError。 + 3. 调用超时必须大于池内部最长超时(aiohttp 30 秒);DEFAULT_CALL_TIMEOUT 取 35 秒,超时后调用方放弃而池仍在跑。 + 4. stop() 默认 35 秒预算等于池内各段最坏耗时之和(插件停止 10 秒 + 配置落库 5 秒 + 写队列排空 15 秒 + 余量),改动任一段都要重算。 + 5. 上一次 stop() 超时未让线程退出时 start() 拒绝启动,避免两个 Application 并存并同写 proxies.db。 + 6. stop 超时后 _stopping 常置:is_running 为假而线程可能仍在写库,退出与恢复路径须按 has_live_thread 判断。 + 7. 语言提供者须在构造期注册(池未启动时面板仍按当前语言渲染池设置文案),不可因初始化阶段会重复注册而删除。 +""" + +import asyncio +import concurrent.futures +import logging +import os +import random +import threading +from pathlib import Path +from typing import Any, Callable, Coroutine, Optional + +from modules.proxypool import POOL_DIR # noqa: F401 +from core.application.app import Application +from core.application.api.dependencies import ( + set_container, set_dedup_cache, set_language_provider, +) +from core.config import Config +from core.logger import attach_pool_log_handlers + +from modules import pool_config_ini +from modules.logging_setup import LogCategory, classify_record +from modules.loop_noise_filter import install_loop_noise_filter +from modules.modules import get_message + +logger = logging.getLogger(__name__) + +DEFAULT_CALL_TIMEOUT = 35.0 + +CONFIG_WATCH_INTERVAL = 3.0 + + +class _PoolLogFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return classify_record(record) == LogCategory.POOL + + +class PoolUnavailableError(RuntimeError): + pass + + +class PoolTimeoutError(RuntimeError): + pass + + +class ProxyPoolService: + def __init__(self, config: Config, config_ini_path: str | Path, + language_provider: Optional[Callable[[], str]] = None): + self._config = config + self._config_ini_path = Path(config_ini_path) + self._language_provider = language_provider + set_language_provider(language_provider) + + self._thread: Optional[threading.Thread] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._application: Optional[Application] = None + + self._ready = threading.Event() + self._stopping = threading.Event() + self._startup_ok = False + self._lifecycle_lock = threading.RLock() + + self._last_error: Optional[str] = None + self._last_mtime: float = 0.0 + self._watch_task: Optional[asyncio.Task] = None + + @property + def is_running(self) -> bool: + thread = self._thread + return bool( + thread is not None + and thread.is_alive() + and self._startup_ok + and self._ready.is_set() + and not self._stopping.is_set() + ) + + @property + def has_live_thread(self) -> bool: + thread = self._thread + return thread is not None and thread.is_alive() + + @property + def last_error(self) -> Optional[str]: + return self._last_error + + @property + def language(self) -> str: + if self._language_provider is None: + return 'cn' + return self._language_provider() or 'cn' + + def start(self, timeout: float = 30.0) -> bool: + with self._lifecycle_lock: + if self.is_running: + return True + + previous = self._thread + if previous is not None and previous.is_alive(): + self._last_error = get_message('pool_previous_stop_incomplete', self.language) + logger.error(self._last_error) + return False + + self._ready.clear() + self._stopping.clear() + self._startup_ok = False + self._last_error = None + self._remember_config_mtime() + + self._thread = threading.Thread( + target=self._run_loop, name="proxypool-loop", daemon=True + ) + self._thread.start() + + if not self._ready.wait(timeout): + self._last_error = get_message('pool_start_timeout', self.language, f"{timeout:.0f}") + logger.error(self._last_error) + return False + + return self.is_running + + def stop(self, timeout: float = 35.0) -> bool: + with self._lifecycle_lock: + thread = self._thread + if thread is None or not thread.is_alive(): + self._thread = None + return True + + logger.info(get_message('pool_stopping', self.language)) + self._stopping.set() + + loop = self._loop + if loop is not None: + try: + loop.call_soon_threadsafe(loop.stop) + except RuntimeError: + pass + + thread.join(timeout) + + if thread.is_alive(): + self._last_error = get_message('pool_stop_timeout', self.language, f"{timeout:.0f}") + logger.error(self._last_error) + return False + + with self._lifecycle_lock: + self._thread = None + self._loop = None + self._stopping.clear() + + logger.info(get_message('pool_stop_success', self.language)) + return True + + def restart(self, timeout: float = 30.0) -> bool: + with self._lifecycle_lock: + self.stop(timeout=timeout) + return self.start(timeout=timeout) + + def _run_loop(self) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + install_loop_noise_filter(loop) + + initialized = False + try: + loop.run_until_complete(self._initialize_application()) + initialized = True + self._startup_ok = True + logger.info(get_message('pool_start_success', self.language)) + except Exception as e: + self._startup_ok = False + self._last_error = f"{type(e).__name__}: {e}" + logger.error(get_message('pool_start_failed', self.language, e), exc_info=True) + finally: + self._ready.set() + + if initialized: + try: + loop.run_forever() + except Exception as e: + self._last_error = f"{type(e).__name__}: {e}" + logger.error(get_message('pool_loop_crashed', self.language, e), exc_info=True) + + self._shutdown_application(loop) + + async def _initialize_application(self) -> None: + application = Application(self._config) + try: + await application.initialize() + except BaseException: + try: + await application.shutdown() + except Exception as e: + logger.warning("初始化失败后的收尾也失败了(不影响报错): %s", e) + raise + + self._application = application + set_container(application.get_container()) + set_dedup_cache(application.dedup_cache) + set_language_provider(self._language_provider) + + attach_pool_log_handlers( + self._config.logging, + logging.getLogger().level or logging.INFO, + record_filter=_PoolLogFilter(), + ) + + self._watch_task = asyncio.create_task(self._watch_config_file()) + + def _shutdown_application(self, loop: asyncio.AbstractEventLoop) -> None: + application = self._application + self._application = None + set_container(None) + set_dedup_cache(None) + + if application is None: + self._close_loop(loop) + return + + try: + loop.run_until_complete(application.shutdown()) + except Exception as e: + logger.error("关闭代理池时出错: %s", e, exc_info=True) + + self._close_loop(loop) + + @staticmethod + def _close_loop(loop: asyncio.AbstractEventLoop) -> None: + try: + pending = [t for t in asyncio.all_tasks(loop) if not t.done()] + for task in pending: + task.cancel() + if pending: + loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + except Exception as e: + logger.debug("清理残留任务时出错(忽略): %s", e) + finally: + try: + loop.close() + except Exception as e: + logger.debug("关闭事件循环时出错(忽略): %s", e) + + def call(self, coro: Coroutine, timeout: float = DEFAULT_CALL_TIMEOUT) -> Any: + if threading.current_thread() is self._thread: + coro.close() + raise RuntimeError("禁止在代理池事件循环线程内同步等待池调用(会死锁)") + + if not self.is_running or self._loop is None: + coro.close() + raise PoolUnavailableError( + self._last_error or get_message('pool_not_running', self.language)) + + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + try: + return future.result(timeout) + except concurrent.futures.TimeoutError: + future.cancel() + raise PoolTimeoutError( + get_message('pool_call_timeout', self.language, f"{timeout:.0f}")) from None + except concurrent.futures.CancelledError: + raise PoolUnavailableError( + get_message('pool_stopped', self.language)) from None + + def submit(self, coro: Coroutine) -> concurrent.futures.Future: + if not self.is_running or self._loop is None: + coro.close() + raise PoolUnavailableError( + self._last_error or get_message('pool_not_running', self.language)) + + return asyncio.run_coroutine_threadsafe(coro, self._loop) + + async def _run_route(self, coro, timeout: float): + future = self.submit(coro) + return await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout) + + @staticmethod + def _text_content(result) -> str: + from core.application.api.http_types import RawResponse + + return result.content if isinstance(result, RawResponse) else str(result) + + async def fetch_random_proxy_url(self, timeout: float = 10.0) -> Optional[str]: + from core.application.api.routes import proxies as proxy_routes + + if not self.is_running: + return None + + result = await self._run_route( + proxy_routes.get_random_proxy(status='valid', format='text'), timeout + ) + return self._text_content(result).strip() or None + + async def fetch_proxy_entries(self, limit: int, timeout: float = 10.0) -> list: + from core.application.api.routes import proxies as proxy_routes + + limit = int(limit) + if not self.is_running or limit <= 0: + return [] + + counted = await self._run_route( + proxy_routes.get_proxies_count(status='valid'), timeout + ) + total = int((counted or {}).get('count', 0)) + if total <= 0: + return [] + + pages = max(1, (total + limit - 1) // limit) + page = random.randint(1, pages) + result = await self._run_route( + proxy_routes.get_proxies( + status='valid', page=page, page_size=limit, format='json' + ), + timeout, + ) + return [entry for entry in (result or []) + if isinstance(entry, dict)][:limit] + + async def fetch_proxy_urls(self, limit: int, timeout: float = 10.0) -> list: + entries = await self.fetch_proxy_entries(limit, timeout) + urls = [(entry.get('proxy_url') or '').strip() for entry in entries] + return [url for url in urls if url] + + async def report_proxy_use(self, ip: str, port: int, ok: bool, + test_url: str = "", timeout: float = 5.0) -> None: + from core.application.api.routes import validation as validation_routes + + if not self.is_running: + return + + try: + future = self.submit( + validation_routes.report_proxy_use(ip, port, ok, test_url) + ) + await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout) + except Exception as e: + logger.debug("使用期反馈上报失败(已忽略): %s: %s", type(e).__name__, e) + + def apply_config(self, config: Config, timeout: float = 15.0) -> None: + if not self.is_running or self._application is None: + raise PoolUnavailableError( + self._last_error or get_message('pool_not_running', self.language)) + + self.call(self._application.apply_config(config), timeout=timeout) + attach_pool_log_handlers( + config.logging, + logging.getLogger().level or logging.INFO, + record_filter=_PoolLogFilter(), + ) + + def reload_config_from_file(self, timeout: float = 15.0) -> None: + config = pool_config_ini.load_pool_config(self._config_ini_path) + self._config = config + self.apply_config(config, timeout=timeout) + + def note_config_saved(self) -> None: + self._remember_config_mtime() + + def _remember_config_mtime(self) -> None: + try: + self._last_mtime = os.path.getmtime(self._config_ini_path) + except OSError: + self._last_mtime = 0.0 + + async def _watch_config_file(self) -> None: + while True: + try: + await asyncio.sleep(CONFIG_WATCH_INTERVAL) + mtime = os.path.getmtime(self._config_ini_path) + if mtime <= self._last_mtime: + continue + + config = pool_config_ini.reload_pool_config(self._config_ini_path) + if config is None: + logger.warning(get_message('pool_config_unreadable', self.language)) + continue + + logger.info(get_message('config_file_changed', self.language)) + self._config = config + if self._application is not None: + await self._application.apply_config(config) + self._last_mtime = mtime + except asyncio.CancelledError: + break + except Exception as e: + logger.warning("配置文件兜底轮询出错(不影响运行): %s", e) + + +__all__ = ["ProxyPoolService", "PoolUnavailableError", "PoolTimeoutError"] diff --git a/modules/proxyserver.py b/modules/proxyserver.py index 07d8d2c..c2c52c4 100644 --- a/modules/proxyserver.py +++ b/modules/proxyserver.py @@ -1,322 +1,1924 @@ -import asyncio, httpx, logging, re, socket, struct, time, base64, random, os -from modules.modules import get_message, load_ip_list -from asyncio import TimeoutError -from itertools import cycle -from config import getip -from configparser import ConfigParser +""" +模块名称:modules.proxyserver +功能描述:本地代理转发服务器:在配置端口上同时接受 HTTP 与 SOCKS5 入站请求,维持一个有目标容量的上游出口池(按来源取候选、逐条校验填充、按寿命单独替换)。 +职责边界:负责:HTTP / SOCKS5 入站处理与隧道中继、出口池维持(取候选、校验填充、按寿命替换)、出口分配与失败归因、 + IP 名单与代理认证、连接池与并发上限、出口真实 IP 的获取与缓存。不负责:代理抓取与验证评分(见 modules.proxypool 与 modules.modules 的 + check_proxy)、记录持久化(modules.access_log 等)、服务器线程的创建与重启(app.py、ProxyCat.py)。 +关键依赖:modules.modules、modules.proxy_workers、modules.access_log、 + modules.domain_stats、modules.access_records、modules.logging_setup、 + modules.loop_noise_filter、modules.pool_config_ini、config.getip、httpx。 +已知限制: +1. 上游协议只支持 http / https / socks5;https 上游会真做 TLS,且一律不校验证书。 +2. 重试上限固定 2 次,带请求体的明文 HTTP 请求不重试;归因口径分路径:HTTP 转发只记真正的上游传输错误, + 其余失败也照常换出口重试;CONNECT / SOCKS5 隧道把握手失败等非传输错误也计入出口失败。 +3. CONNECT 隧道与 SOCKS5 的写回只代表隧道已建立,要等上游回了数据才算成功;_check_worker 返回三态,None 表示这次没有结论。 +4. 出口名额绑在连接任务上,_pipe 跑在 asyncio.gather 的子任务里,不能用 asyncio.current_task() 反查,必须显式传参。 +5. 一个可用出口都没有时请求在 _wait_for_exit 排队等 exit_wait_timeout 秒,超时才回 503;空闲隧道不占每出口活跃额度。 +6. 双栈监听下 IPv4 客户端的对端是 ::ffff:x.x.x.x,必须经 _peer_ip 归一化,否则 IP 名单静默失效;Windows 的 listen backlog 不生效。 +7. 上游一字未回即断的 CONNECT 隧道不关客户端连接,由调用方换出口重放已缓冲请求(重放缓冲上限 64 KiB)。 +""" + +import asyncio, httpx, ipaddress, json, logging, socket, struct, time, base64, os, threading, errno, http.cookiejar, ssl +from collections import OrderedDict +from contextlib import AsyncExitStack, asynccontextmanager +from typing import NamedTuple +from modules.modules import ( + get_message, load_ip_list, load_bypass_whitelist, check_bypass_match, port_in_use, + parse_bool_lenient, parse_proxy_url, split_host_port, build_socks5_auth_packet, + normalize_rotation_mode, +) +from modules.access_log import ( + KIND_CONNECT, KIND_HTTP, KIND_SOCKS5, AccessRecord, AccessTracker, ClientIdentity, + sanitize_proxy, +) +from modules.access_records import RequestRecordStore +from modules.domain_stats import DomainStatsStore +from modules.logging_setup import LogCategory, ThrottledLogger, get_category_logger +from modules.loop_noise_filter import install_loop_noise_filter +from modules.proxy_workers import ( + BLAME_IGNORED, BLAME_RETIRED, ProxyWorkerPool, StandbyQueue, +) +from config.getip import newip_list as getip_newip_list + +logger = get_category_logger(LogCategory.PROXY) + +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("hpack").setLevel(logging.WARNING) +logging.getLogger("h2").setLevel(logging.WARNING) + +_SERVER_DIR = os.path.dirname(os.path.abspath(__file__)) +_BASE_DIR = os.path.dirname(_SERVER_DIR) -def load_proxies(file_path='ip.txt'): - with open(file_path, 'r') as file: - return [line.strip() for line in file if '://' in line] +def _as_bool(value) -> bool: + return parse_bool_lenient(value) + + +def _v4_mapped_literal(address: str) -> str | None: + if address[:7].lower() != '::ffff:': + return None + parts = address[7:].split('.') + if len(parts) != 4: + return None + for part in parts: + if not (part.isascii() and part.isdigit()): + return None + if len(part) > 1 and part[0] == '0': + return None + if int(part) > 255: + return None + return '.'.join(parts) + + +def _remaining_time(deadline: float) -> float: + return max(0.0, deadline - asyncio.get_running_loop().time()) + + +_BODY_CHUNK_SIZE = 64 * 1024 + +_BODY_READ_TIMEOUT = 30.0 + +_BODY_TOTAL_TIMEOUT = 300.0 + +_CLIENT_HEADER_TIMEOUT = 10.0 + +_IDLE_TUNNEL_TIMEOUT = 300.0 + +_LISTEN_BACKLOG = 2048 + +_STREAM_LIMIT = 32768 + +_RETRY_BACKOFF_SECONDS = 0.2 + +_TUNNEL_TOTAL_BUDGET_SECONDS = 20.0 + +_TUNNEL_REPLAY_LIMIT = 64 * 1024 + +_HAPPY_EYEBALLS_DELAY = 0.25 + +_RELAY_CHUNK = 64 * 1024 + +_REQUEST_MAINTENANCE_BUDGET = 5.0 + +_FILL_PROBE_FANOUT = 4 + +_FILL_BACKOFF_MAX = 60.0 + +_ROTATE_RETRY_SECONDS = 2.0 + +_DEMAND_RECENT_SECONDS = 10.0 + +_POOL_FETCH_MIN = 3 + +_FETCH_OVERSUBSCRIBE = 8 +_FETCH_MAX_BATCH = 24 + +_EXIT_REAL_IP_CACHE_LIMIT = 4096 + +_REAL_IP_PROBE_TIMEOUT = 5.0 +_REAL_IP_PROBE_RETRY_SECONDS = 600.0 +_REAL_IP_PROBE_FANOUT = 3 + +_EXPAND_SATURATION_RATIO = 0.8 +_EXPAND_RECOVER_UTILIZATION = 0.5 +_EXPAND_TRIGGER_SECONDS = 1.0 +_EXPAND_RECOVER_SECONDS = 30.0 + +_EXPAND_MAX_CAPACITY_BOOST = 2 + +_BURST_EXPAND_COOLDOWN = 5.0 + +_KEEPALIVE_IDLE_SECONDS = 60 +_KEEPALIVE_INTERVAL_SECONDS = 10 + + +def _tune_keepalive(sock) -> None: + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + + if not hasattr(socket, 'SIO_KEEPALIVE_VALS'): + if hasattr(socket, 'TCP_KEEPIDLE'): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, _KEEPALIVE_IDLE_SECONDS) + if hasattr(socket, 'TCP_KEEPINTVL'): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, _KEEPALIVE_INTERVAL_SECONDS) + if hasattr(socket, 'TCP_KEEPCNT'): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) + return + + ioctl = getattr(sock, 'ioctl', None) + if ioctl is None: + return + ioctl(socket.SIO_KEEPALIVE_VALS, ( + 1, _KEEPALIVE_IDLE_SECONDS * 1000, _KEEPALIVE_INTERVAL_SECONDS * 1000, + )) + except Exception: + pass + + +_SSL_PROTOCOL_MAX_SIZE = 64 * 1024 + + +def _tune_asyncio_ssl_buffers() -> None: + try: + from asyncio import sslproto + if hasattr(sslproto.SSLProtocol, 'max_size'): + sslproto.SSLProtocol.max_size = _SSL_PROTOCOL_MAX_SIZE + except Exception: + pass + + +_tune_asyncio_ssl_buffers() + +_TLS_CONTEXT_LIMIT = 64 +_tls_contexts = OrderedDict() +_tls_contexts_lock = threading.Lock() + + +class _SessionInjectingTLSContext(ssl.SSLContext): + def __new__(cls): + return super().__new__(cls, ssl.PROTOCOL_TLS_CLIENT) + + def __init__(self): + self.check_hostname = False + self.verify_mode = ssl.CERT_NONE + self._session = None + + def remember_session(self, session) -> None: + self._session = session + + def wrap_bio(self, *args, **kwargs): + session = self._session + if session is None or 'session' in kwargs or len(args) >= 5: + return super().wrap_bio(*args, **kwargs) + try: + return super().wrap_bio(*args, session=session, **kwargs) + except Exception: + return super().wrap_bio(*args, **kwargs) + + +def _upstream_tls_context(host, port) -> ssl.SSLContext: + key = (host, int(port)) + with _tls_contexts_lock: + context = _tls_contexts.get(key) + if context is None: + context = _SessionInjectingTLSContext() + _tls_contexts[key] = context + while len(_tls_contexts) > _TLS_CONTEXT_LIMIT: + _tls_contexts.popitem(last=False) + else: + _tls_contexts.move_to_end(key) + return context + + +def _remember_upstream_tls_session(host, port, writer) -> None: + try: + ssl_object = writer.get_extra_info('ssl_object') + if ssl_object is None: + return + session = ssl_object.session + if session is None or not session.has_ticket: + return + with _tls_contexts_lock: + context = _tls_contexts.get((host, int(port))) + if context is not None: + context.remember_session(session) + except Exception: + pass + + +class _NullCookieJar(http.cookiejar.CookieJar): + def extract_cookies(self, response, request): + pass + + def add_cookie_header(self, request): + pass + +_MAX_HEADER_LINES = 100 +_MAX_HEADER_BYTES = 64 * 1024 + +_TUNNEL_SWEEP_INTERVAL = 0.25 + +_REASON_SILENT_TUNNEL = 'silent-tunnel' + + +async def _iter_body(reader, length: int, deadline: float): + remaining = length + while remaining > 0: + chunk = await asyncio.wait_for( + reader.read(min(remaining, _BODY_CHUNK_SIZE)), + min(_BODY_READ_TIMEOUT, _remaining_time(deadline)), + ) + if not chunk: + raise asyncio.IncompleteReadError(partial=b'', expected=remaining) + remaining -= len(chunk) + yield chunk + + +async def _iter_chunked_body(reader, deadline: float): + while True: + size_line = await asyncio.wait_for( + reader.readline(), min(_BODY_READ_TIMEOUT, _remaining_time(deadline))) + if not size_line: + raise asyncio.IncompleteReadError(partial=b'', expected=1) + try: + size = int(size_line.split(b';', 1)[0].strip() or b'0', 16) + except ValueError: + raise ValueError("malformed chunk size line") from None + if size == 0: + while True: + line = await asyncio.wait_for( + reader.readline(), min(_BODY_READ_TIMEOUT, _remaining_time(deadline))) + if line in (b'\r\n', b'\n', b''): + break + return + + remaining = size + while remaining > 0: + chunk = await asyncio.wait_for( + reader.read(remaining), min(_BODY_READ_TIMEOUT, _remaining_time(deadline))) + if not chunk: + raise asyncio.IncompleteReadError(partial=b'', expected=remaining) + remaining -= len(chunk) + yield chunk + await asyncio.wait_for( + reader.readexactly(2), min(_BODY_READ_TIMEOUT, _remaining_time(deadline))) + + + +def _client_request_body(headers, reader, deadline: float): + if 'chunked' in headers.get('transfer-encoding', '').lower(): + return _iter_chunked_body(reader, deadline) + + declared = headers.get('content-length') + if declared is None: + return None + try: + size = int(str(declared).strip()) + except ValueError: + return None + return _iter_body(reader, size, deadline) if size > 0 else None + + +_REASON_PHRASES = { + 400: 'Bad Request', + 407: 'Proxy Authentication Required', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', +} + +_HOP_BY_HOP_HEADERS = frozenset({ + 'connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade', +}) + +_UPSTREAM_TRANSPORT_ERRORS = ( + httpx.ConnectError, httpx.ConnectTimeout, + httpx.ReadTimeout, httpx.WriteTimeout, + httpx.ReadError, httpx.WriteError, httpx.CloseError, + httpx.RemoteProtocolError, httpx.ProxyError, +) + +_UPSTREAM_REQUEST_TIMEOUT = httpx.Timeout(30.0) +_UPSTREAM_TIMEOUT_EXTENSIONS = _UPSTREAM_REQUEST_TIMEOUT.as_dict() + + +def _merge_default_headers(client, headers): + if not isinstance(headers, dict): + merged = httpx.Headers(client.headers) + merged.update(headers) + return merged + pairs = [] + overridden = set() + for key, value in headers.items(): + key_bytes = key if isinstance(key, bytes) else key.encode('ascii') + value_bytes = value if isinstance(value, bytes) else value.encode('ascii') + overridden.add(key_bytes.lower()) + pairs.append((key_bytes, value_bytes)) + merged = [pair for pair in client.headers.raw + if pair[0].lower() not in overridden] + merged.extend(pairs) + return merged + + +def _build_upstream_request(client, method, url, headers, content): + return httpx.Request( + method, url, + headers=_merge_default_headers(client, headers), + content=content, + extensions={'timeout': _UPSTREAM_TIMEOUT_EXTENSIONS}, + ) + + +@asynccontextmanager +async def _stream_upstream(client, request): + response = await client.send(request, stream=True) + try: + yield response + finally: + await response.aclose() + + +class UnsupportedUpstreamProxyError(Exception): + pass + +async def run_server(server): + try: + await server.start() + except asyncio.CancelledError: + logger.info(get_message('server_closing', server.language)) + except Exception as e: + if not server.stop_server: + logger.error(f"Server error: {e}") + finally: + await server.stop() + + +_SUPPORTED_PROXY_SCHEMES = frozenset({'http', 'https', 'socks5'}) + def validate_proxy(proxy): - pattern = re.compile(r'^(?Psocks5|http|https)://(?:(?P[^@]+)@)?(?P[^:]+):(?P\d+)$') - match = pattern.match(proxy) - if not match: + try: + scheme, _auth, host, port = parse_proxy_url(proxy) + except ValueError: return False - - port = int(match.group('port')) - return 0 < port < 65536 + + return bool(host) and scheme in _SUPPORTED_PROXY_SCHEMES and 0 < port < 65536 + + +def _is_ipv4_literal(token: str) -> bool: + parts = token.split('.') + if len(parts) != 4: + return False + for part in parts: + if not (part.isascii() and part.isdigit()) or len(part) > 3: + return False + if int(part) > 255: + return False + return True + + +def _real_ip_token(token: str): + candidate = token.strip().strip('"').strip() + if not candidate: + return None + + if candidate.startswith('[') and ']' in candidate: + candidate = candidate[1:candidate.index(']')] + + try: + parsed = ipaddress.ip_address(candidate) + except ValueError: + head, sep, tail = candidate.rpartition(':') + if sep and tail.isdigit() and _is_ipv4_literal(head): + return head + return None + + mapped = getattr(parsed, 'ipv4_mapped', None) + return str(mapped) if mapped is not None else str(parsed) + + +def _parse_real_ip_echo(text: str): + raw = (text or '').strip()[:2048] + if not raw: + return None + candidates = [] + if raw.startswith('{'): + try: + data = json.loads(raw) + except ValueError: + return None + for field in ('origin', 'ip', 'query'): + value = data.get(field) + if isinstance(value, str): + candidates.extend(value.replace(',', ' ').split()) + else: + candidates = raw.replace('=', ' ').replace(',', ' ').split() + + parsed = [ip for ip in (_real_ip_token(t) for t in reversed(candidates)) if ip] + for ip in parsed: + if ':' not in ip: + return ip + return parsed[0] if parsed else None + + +class MaintenanceOutcome(NamedTuple): + changed: bool + fetched: bool + gained: int + + def __bool__(self): + return self.changed + + +_NO_MAINTENANCE = MaintenanceOutcome(False, False, 0) + + +class _ReplayBuffer: + __slots__ = ('data', 'overflowed', '_limit') + + def __init__(self, limit): + self.data = bytearray() + self._limit = limit + self.overflowed = False + + def add(self, chunk): + if self.overflowed: + return + if len(self.data) + len(chunk) > self._limit: + self.overflowed = True + self.data = bytearray() + return + self.data += chunk + + def __len__(self): + return len(self.data) + + @property + def replayable(self): + return not self.overflowed + class AsyncProxyServer: - def __init__(self, config): + + def __init__(self, config, pool_service=None): self.config = config + self._pool_service = pool_service + self._workers = ProxyWorkerPool() self._init_config_values(config) self._init_server_state() self._init_connection_settings() - self.proxy_failure_lock = asyncio.Lock() + self._switch_lock = threading.Lock() + + self.domain_stats = DomainStatsStore(config, _BASE_DIR) + self.access_records = RequestRecordStore(config, _BASE_DIR) + self.access_tracker = AccessTracker( + language=self.language, + stats=self.domain_stats, + emit_log=_as_bool(config.get('log_access_enabled', 'true')), + records=self.access_records, + ) + + self._last_source_error = None + + @property + def proxies(self): + return [worker.url for worker in self._workers.roster] + + @proxies.setter + def proxies(self, urls): + self._workers.set_workers(list(urls)) + self._sync_current_proxy() + + def _sync_current_proxy(self): + urls = self.proxies + if self.current_proxy not in urls: + self.current_proxy = urls[0] if urls else None + + def active_workers(self): + now = time.monotonic() + limit = self.request_interval + snapshot = [] + for entry in self._workers.snapshot(): + expires_at = entry.get('expires_at') or 0.0 + entry['expires_in'] = max(0.0, expires_at - now) if expires_at else None + entry['requests_left'] = ( + max(0, limit - entry['requests_served']) if limit > 0 else None + ) + snapshot.append(entry) + return snapshot + + def source_proxies(self): + if self.proxy_source_mode == 'local': + return self._load_file_proxies() + return self.proxies + + def roster_revision(self): + return self._workers.revision def _init_config_values(self, config): self.port = int(config.get('port', '1080')) self.mode = config.get('mode', 'cycle') + previous_interval = getattr(self, 'interval', None) self.interval = int(config.get('interval', '300')) self.language = config.get('language', 'cn') - self.use_getip = config.get('use_getip', 'False').lower() == 'true' - self.check_proxies = config.get('check_proxies', 'True').lower() == 'true' - - self.username = config.get('username', '') - self.password = config.get('password', '') - self.proxy_username = config.get('proxy_username', '') - self.proxy_password = config.get('proxy_password', '') - + self.proxy_source_mode = config.get('proxy_source_mode', 'local').lower() + self.mode = normalize_rotation_mode(self.mode, self.proxy_source_mode) + self.api_proxy_url = config.get('api_proxy_url', '') + self.pool_remote_url = config.get('pool_remote_url', '') + self.check_proxies_on_startup = _as_bool( + config.get('check_proxies_on_startup', 'True') + ) + self.check_proxies_on_use = _as_bool( + config.get('check_proxies_on_use', 'True') + ) + self.real_ip_probe = parse_bool_lenient( + config.get('access_records_real_ip_probe'), False + ) + self._real_ip_probe_endpoints = self._load_real_ip_probe_endpoints() + self.check_concurrency = int(config.get('check_concurrency', '50')) + self.max_concurrent_per_proxy = int(config.get('max_concurrent_per_proxy', '0')) + previous_exit_count = getattr(self, 'exit_count', None) + pending_trim = getattr(self, '_trim_now', False) + self.exit_count = max(1, int( + config.get('exit_count') or config.get('pool_proxy_count') or '5' + )) + self.exit_wait_timeout = max(0, int(config.get('exit_wait_timeout', '15'))) + self.auto_expand_enabled = parse_bool_lenient( + config.get('auto_expand_enabled'), True) + self._reset_elastic_capacity() + shrunk = previous_exit_count is not None and self.exit_count < previous_exit_count + self._trim_now = bool(pending_trim or shrunk) + + self.client_max_keepalive = int(config.get('client_max_keepalive', '8')) + self.client_keepalive_expiry = int(config.get('client_keepalive_expiry', '30')) + self.client_idle_timeout = int(config.get('client_idle_timeout', '300')) + self.users = {} if 'Users' in config: self.users = dict(config['Users'].items()) self.auth_required = bool(self.users) - - self.proxy_file = os.path.join('config', os.path.basename(config.get('proxy_file', 'ip.txt'))) - self.whitelist_file = os.path.join('config', os.path.basename(config.get('whitelist_file', 'whitelist.txt'))) - self.blacklist_file = os.path.join('config', os.path.basename(config.get('blacklist_file', 'blacklist.txt'))) + + self.proxy_file = os.path.join(_BASE_DIR, 'config', os.path.basename(config.get('proxy_file', 'ip.txt'))) + self.whitelist_file = os.path.join(_BASE_DIR, 'config', os.path.basename(config.get('whitelist_file', 'whitelist.txt'))) + self.blacklist_file = os.path.join(_BASE_DIR, 'config', os.path.basename(config.get('blacklist_file', 'blacklist.txt'))) self.ip_auth_priority = config.get('ip_auth_priority', 'whitelist') - + self.test_url = config.get('test_url', 'https://www.baidu.com') self.whitelist = load_ip_list(self.whitelist_file) self.blacklist = load_ip_list(self.blacklist_file) - - if self.use_getip: - self.getip_url = config.get('getip_url', '') - - self.switching_proxy = False + + self.request_interval = int(config.get('request_interval', '0')) + + self.bypass_whitelist_file = os.path.join(_BASE_DIR, 'config', os.path.basename( + config.get('bypass_whitelist_file', 'bypass_whitelist.txt'))) + self.bypass_whitelist = load_bypass_whitelist(self.bypass_whitelist_file) + self.last_switch_attempt = 0 - self.switch_cooldown = 5 - self.proxy_check_cache = {} - self.last_check_time = {} - self.proxy_check_ttl = 60 - self.check_cooldown = 10 - self.connected_clients = set() - self.last_proxy_failure_time = 0 - self.proxy_failure_cooldown = 3 + self.switch_cooldown = int(config.get('switch_cooldown', '2')) + if getattr(self, 'proxy_check_cache', None) is None: + self.proxy_check_cache = {} + if getattr(self, 'last_check_time', None) is None: + self.last_check_time = {} + self.proxy_check_ttl = int(config.get('proxy_check_ttl', '60')) + self.check_cooldown = int(config.get('check_cooldown', '10')) + self.proxy_failure_cooldown = int(config.get('proxy_failure_cooldown', '3')) + self._retry_log = self._throttled_log( + '_retry_log', lambda: self.language, self.proxy_failure_cooldown) + self._silent_log = self._throttled_log( + '_silent_log', lambda: self.language, max(5, self.proxy_failure_cooldown * 5)) + self._ceiling_log = self._throttled_log( + '_ceiling_log', lambda: self.language, 60) + self.tunnel_idle_timeout = int(config.get('tunnel_idle_timeout', '10')) + + self._apply_connection_settings(config) + + workers = getattr(self, '_workers', None) + if workers is not None: + workers.apply_capacity(self.max_concurrent_per_proxy) + workers.set_selection(self._selection_mode()) + if previous_interval != self.interval: + self._replan_expiries() + + tracker = getattr(self, 'access_tracker', None) + if tracker is not None: + tracker.language = self.language + tracker.emit_log = _as_bool(config.get('log_access_enabled', 'true')) + + for store in (getattr(self, 'domain_stats', None), + getattr(self, 'access_records', None)): + if store is None: + continue + was_enabled = store.apply_config(config) + if getattr(self, 'running', False) and was_enabled != store.enabled: + if store.enabled: + store.start() + else: + store.stop() def _init_server_state(self): self.running = False self.stop_server = False - self.server_instance = None + self._listen_sock = None + self._acceptor = None + self._acceptor_stop = None + self.proxy_thread = None self.tasks = set() self.last_switch_time = time.time() - self.proxy_cycle = None self.current_proxy = None self.proxies = [] - self.known_clients = set() - - if not self.use_getip: - self.proxies = self._load_file_proxies() - if self.proxies: - self.proxy_cycle = cycle(self.proxies) - self.current_proxy = next(self.proxy_cycle) + self._standby = StandbyQueue() + self._exit_real_ips = {} + self._exit_real_ip_probing = set() + self._idle_watch = {} + self._sweeper_task = None + self._last_demand_at = 0.0 + self._fill_backoff = 0.0 + self._next_fill_attempt = 0.0 + self._last_rotate_attempt = 0.0 + self._live_connections = 0 + self._last_burst_expand = 0.0 + self._expand_task = None + self.known_clients = OrderedDict() + self.last_start_error = None + + if self.proxy_source_mode == 'local': + self._apply_local_proxy_list(f"从 {self.proxy_file} 加载本地代理列表") + + def _apply_connection_settings(self, config): + previous_limit = getattr(self, 'max_concurrent_requests', None) + self.buffer_size = int(config.get('buffer_size', '8192')) + self.max_pool_size = int(config.get('max_pool_size', '500')) + self.max_concurrent_requests = int(config.get('max_concurrent_requests', '1000')) + + self.client_max_connections = max( + int(config.get('client_max_connections', '1000')), + self.max_concurrent_per_proxy * 2 if self.max_concurrent_per_proxy + else self.max_concurrent_requests, + ) + + if (getattr(self, 'request_semaphore', None) is not None + and previous_limit != self.max_concurrent_requests): + self.request_semaphore = asyncio.Semaphore(self.max_concurrent_requests) def _init_connection_settings(self): - self.buffer_size = 8192 - self.connection_timeout = 30 - self.read_timeout = 60 - self.max_concurrent_requests = 1000 self.request_semaphore = asyncio.Semaphore(self.max_concurrent_requests) - self.connection_pool = {} - self.max_pool_size = 500 self.client_pool = {} - self.client_pool_lock = asyncio.Lock() - self.proxy_pool = {} - self.active_connections = set() + self.client_pool_lock = threading.Lock() + self._client_inflight = {} + self._clients_pending_close = {} + self._refill_lock = asyncio.Lock() - def _update_config_values(self, new_config): - self._init_config_values(new_config) + @property + def switching_proxy(self): + lock = getattr(self, '_refill_lock', None) + return bool(lock is not None and lock.locked()) + + def _reset_switch_timers(self): self.last_switch_time = time.time() - self.last_switch_attempt = 0 + def _apply_local_proxy_list(self, load_notice: str) -> bool: + logger.info(load_notice) + urls = self._load_file_proxies() + + if not urls: + logger.error(f"从文件 {self.proxy_file} 加载代理失败,请检查文件是否存在且包含有效代理") + return False + + previous = self.proxies + self.proxies = urls[:self._exit_target] + self._replan_expiries() + self._standby.clear() + self._standby.put(urls) + self._log_roster_reload(previous) + logger.info(get_message('proxy_workers_ready', self.language, len(self.proxies))) + return True + + def _maybe_check_local_proxies(self) -> None: + if not self.check_proxies_on_startup: + return + try: + self._run_proxy_check_wherever() + except Exception as e: + logger.error(f"检查代理时出错: {str(e)}") + + def _handle_source_mode_switch(self) -> None: + self.last_switch_attempt = 0 + self._standby.clear() + + if self.proxy_source_mode == 'api': + self.proxies = [] + self.current_proxy = None + logger.info(get_message('api_mode_notice', self.language)) + elif self.proxy_source_mode == 'pool': + self.proxies = [] + self.current_proxy = None + logger.info(get_message('pool_mode_notice', self.language)) + else: + if self._apply_local_proxy_list( + f"切换到{'负载均衡' if self.mode == 'loadbalance' else '循环模式'}模式," + f"从 {self.proxy_file} 加载代理列表" + ): + self._maybe_check_local_proxies() + def _handle_mode_change(self): - - self.last_switch_attempt = 0 - - if self.use_getip: - self.proxies = [] - self.proxy_cycle = None - self.current_proxy = None - logging.info(get_message('api_mode_notice', self.language)) - else: - logging.info(f"切换到{'负载均衡' if self.mode == 'loadbalance' else '循环模式'}模式,从 {self.proxy_file} 加载代理列表") - self.proxies = self._load_file_proxies() - logging.info(f"加载到 {len(self.proxies)} 个代理") - - if self.proxies: - self.proxy_cycle = cycle(self.proxies) - self.current_proxy = next(self.proxy_cycle) - logging.info(f"当前使用代理: {self.current_proxy}") - - if self.check_proxies and self.mode != 'loadbalance': - try: - - loop = asyncio.get_event_loop() - if loop.is_running(): - asyncio.create_task(self._check_proxies_wrapper()) - else: - loop.run_until_complete(self._check_proxies()) - except Exception as e: - logging.error(f"检查代理时出错: {str(e)}") - else: - logging.error(f"从文件 {self.proxy_file} 加载代理失败,请检查文件是否存在且包含有效代理") + self._handle_source_mode_switch() async def _check_proxies_wrapper(self): - """包装 _check_proxies 方法,用于在已运行的事件循环中调用""" await self._check_proxies() - def _reload_proxies(self): - - self.last_switch_attempt = 0 - - logging.info(f"重新加载代理列表文件 {self.proxy_file}") - self.proxies = self._load_file_proxies() - logging.info(f"加载到 {len(self.proxies)} 个代理") - - if self.proxies: - self.proxy_cycle = cycle(self.proxies) - self.current_proxy = next(self.proxy_cycle) - logging.info(f"当前使用代理: {self.current_proxy}") - - if self.check_proxies: - try: - - loop = asyncio.get_event_loop() - if loop.is_running(): - asyncio.create_task(self._check_proxies_wrapper()) - else: - loop.run_until_complete(self._check_proxies()) - except Exception as e: - logging.error(f"检查代理时出错: {str(e)}") + def _schedule_async_task(self, coro): + try: + loop = asyncio.get_running_loop() + loop.create_task(self._guard_task(coro)) + except RuntimeError: + loop = self._event_loop if hasattr(self, '_event_loop') and self._event_loop else None + if loop and loop.is_running(): + asyncio.run_coroutine_threadsafe(self._guard_task(coro), loop) + else: + coro.close() + logger.warning( + "无法调度异步任务:事件循环不可用,任务已被丢弃。事件循环状态: %s", + "不存在" if loop is None else "未运行" + ) + + @staticmethod + async def _guard_task(coro): + try: + await coro + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"后台任务异常: {e}") + + def _run_proxy_check_wherever(self): + loop = getattr(self, '_event_loop', None) + if loop is not None and loop.is_running(): + self._schedule_async_task(self._check_proxies_wrapper()) else: - logging.error(f"从文件 {self.proxy_file} 加载代理失败,请检查文件是否存在且包含有效代理") + asyncio.run(self._check_proxies()) + + def _throttled_log(self, attr: str, language_provider, interval: float): + current = getattr(self, attr, None) + if current is None: + return ThrottledLogger(logger, language_provider, interval) + current.set_interval(interval) + return current + + async def async_reload_config(self, new_config, mode_changed=False, source_mode_changed=False): + old_port = self.port + + self.config.update(new_config) + self._init_config_values(new_config) + + if self.port != old_port: + logger.info(get_message('port_changed', self.language, old_port, self.port)) + + if mode_changed or source_mode_changed: + self._handle_mode_change() + + def _running_loop(self): + loop = getattr(self, '_event_loop', None) + return loop if loop is not None and loop.is_running() else None + + def _on_loop_thread(self) -> bool: + return threading.current_thread() is getattr(self, '_loop_thread', None) + + def run_coroutine_sync(self, coro_factory, timeout=30): + if self._on_loop_thread(): + raise RuntimeError("不能从服务自己的事件循环线程里同步等待协程") + + loop = self._running_loop() + if loop is not None: + return asyncio.run_coroutine_threadsafe(coro_factory(), loop).result(timeout=timeout) + return asyncio.run(coro_factory()) + + def apply_config_sync(self, new_config, mode_changed=False, source_mode_changed=False, + reset_switch_timers=False, timeout=10) -> None: + if self._on_loop_thread(): + raise RuntimeError("不能从服务自己的事件循环线程里同步应用配置") + + loop = self._running_loop() + if loop is not None: + future = asyncio.run_coroutine_threadsafe( + self.async_reload_config(new_config, mode_changed, source_mode_changed), loop + ) + if reset_switch_timers: + self._reset_switch_timers() + future.result(timeout=timeout) + return + + self.config.update(new_config) + self._init_config_values(new_config) + if reset_switch_timers: + self._reset_switch_timers() + if mode_changed or source_mode_changed: + self._handle_mode_change() + + def reload_local_proxies(self, run_check: bool) -> None: + self.last_switch_attempt = 0 + if self._apply_local_proxy_list(f"重新加载代理列表文件 {self.proxy_file}"): + if run_check: + self._maybe_check_local_proxies() + + def reload_ip_lists(self) -> None: + self.whitelist = load_ip_list(self.whitelist_file) + self.blacklist = load_ip_list(self.blacklist_file) + self.bypass_whitelist = load_bypass_whitelist(self.bypass_whitelist_file) + logger.info(get_message('ip_lists_reloaded', self.language, + len(self.whitelist), len(self.blacklist), + len(self.bypass_whitelist))) + + def close_listener(self) -> None: + self._stop_acceptor() + + def _startup_check_gate(self) -> bool: + if not self.check_proxies_on_startup: + logger.info(get_message('proxy_check_disabled', self.language)) + return False + logger.info(get_message('proxy_check_start', self.language)) + return True + + async def run_startup_check(self) -> None: + if self._startup_check_gate(): + await self._check_proxies() + + def run_startup_check_blocking(self) -> None: + if self._startup_check_gate(): + asyncio.run(self._check_proxies()) async def _check_proxies(self): from modules.modules import check_proxies - valid_proxies = await check_proxies(self.proxies, test_url=self.test_url) - if valid_proxies: - self.proxies = valid_proxies - self.proxy_cycle = cycle(valid_proxies) - self.current_proxy = next(self.proxy_cycle) + + current = self.proxies + if not current: + return + + valid_proxies = await check_proxies( + current, test_url=self.test_url, concurrency=self.check_concurrency + ) + if not valid_proxies: + logger.error(get_message('no_valid_proxies', self.language)) + return + + valid = set(valid_proxies) + checked_at = time.time() + for url in current: + self.proxy_check_cache[(url, self.test_url)] = (checked_at, url in valid) + for url in current: + if url not in valid: + self._workers.drop_worker(url, remember=True) + for url in valid: + self._workers.note_success(self._workers.find(url)) + self._sync_current_proxy() + logger.info(get_message('valid_proxies', self.language, len(valid_proxies))) def _load_file_proxies(self): try: - proxy_file = os.path.join('config', os.path.basename(self.proxy_file)) + proxy_file = os.path.join(_BASE_DIR, 'config', os.path.basename(self.proxy_file)) if os.path.exists(proxy_file): with open(proxy_file, 'r', encoding='utf-8') as f: proxies = [line.strip() for line in f if line.strip()] return proxies else: - logging.error(get_message('proxy_file_not_found', self.language, proxy_file)) + logger.error(get_message('proxy_file_not_found', self.language, proxy_file)) return [] except Exception as e: - logging.error(get_message('load_proxy_file_error', self.language, str(e))) + logger.error(get_message('load_proxy_file_error', self.language, str(e))) return [] - async def start(self): - if not self.running: - self.stop_server = False - self.running = True - + def _create_listen_socket(self): + error = None + for family, address in self._listen_candidates(): + sock = socket.socket(family, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + try: + if family == socket.AF_INET6: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + sock.bind(address) + except OSError as e: + sock.close() + error = e + continue + return sock + + if not self._is_port_in_use(error): + raise error + raise RuntimeError( + get_message('port_in_use', self.language, self.port) + ) from error + + def _listen_candidates(self): + candidates = [] + if socket.has_ipv6: + candidates.append((socket.AF_INET6, ('::', self.port))) + candidates.append((socket.AF_INET, ('0.0.0.0', self.port))) + return candidates + + @staticmethod + def _is_port_in_use(error): + return bool( + getattr(error, 'winerror', None) in (10013, 10048) + or getattr(error, 'errno', None) == errno.EADDRINUSE + ) + + def _log_retry(self, remaining): + self._retry_log.warning( + 'request_retry', + get_message('request_retry', self.language, remaining), + ) + + @staticmethod + def _render_response_head(response, keep_alive=False): + parts = [f'HTTP/1.1 {response.status_code} {response.reason_phrase}\r\n'] + for header_name, header_value in response.headers.items(): + if header_name.lower() not in _HOP_BY_HOP_HEADERS: + parts.append(f'{header_name}: {header_value}\r\n') + parts.append('Connection: keep-alive\r\n' if keep_alive else 'Connection: close\r\n') + parts.append('\r\n') + return ''.join(parts).encode() + + @staticmethod + def _response_allows_reuse(response, method) -> bool: + if method == 'HEAD' or response.status_code in (204, 304): + return True + if 'transfer-encoding' in {name.lower() for name in response.headers.keys()}: + return False + return response.headers.get('content-length') is not None + + async def _open_upstream(self, host, port, timeout=10, *, tls=False): + reader, writer = await asyncio.wait_for( + asyncio.open_connection( + host, port, happy_eyeballs_delay=_HAPPY_EYEBALLS_DELAY, + ssl=_upstream_tls_context(host, port) if tls else None, + ), + timeout=timeout, + ) + sock = writer.get_extra_info('socket') + if sock is not None: try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024 * 1024) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - - sock.bind(('0.0.0.0', self.port)) - - loop = asyncio.get_event_loop() - if hasattr(loop, 'set_default_executor'): - import concurrent.futures - executor = concurrent.futures.ThreadPoolExecutor(max_workers=max(32, os.cpu_count() * 4)) - loop.set_default_executor(executor) - - server = await asyncio.start_server( - self.handle_client, - sock=sock, - backlog=2048, - limit=32768, - ) - - self.server_instance = server - logging.info(get_message('server_running', self.language, '0.0.0.0', self.port)) - - self.tasks.add(asyncio.create_task(self.cleanup_clients())) - self.tasks.add(asyncio.create_task(self._cleanup_pool())) - self.tasks.add(asyncio.create_task(self.cleanup_disconnected_ips())) - - if hasattr(os, 'sched_setaffinity'): + except OSError: + pass + _tune_keepalive(sock) + return reader, writer + + @staticmethod + async def _close_upstream(writer) -> None: + if writer is None: + return + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + def _accept_loop(self, listen_sock, loop): + try: + while not self.stop_server and not self._acceptor_stop.is_set(): + try: + conn, _addr = listen_sock.accept() + except OSError as e: + if self.stop_server or self._acceptor_stop.is_set(): + return + logger.debug(f"accept 异常(已忽略继续): {getattr(e, 'winerror', None) or e.errno}") + time.sleep(0.001) + continue + + try: + conn.setblocking(False) + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + _tune_keepalive(conn) + except OSError: + pass + + try: + loop.call_soon_threadsafe(self._adopt_connection, conn, loop) + except RuntimeError: try: - os.sched_setaffinity(0, range(os.cpu_count())) - except: + conn.close() + except OSError: pass - - async with server: - await server.serve_forever() - + return + finally: + if not self.stop_server and not self._acceptor_stop.is_set(): + logger.error( + f"acceptor 线程意外退出(端口 {self.port}),此后新连接会被系统拒绝" + ) + + def _adopt_connection(self, conn, loop): + task = loop.create_task(self._serve_accepted(conn, loop)) + self.tasks.add(task) + task.add_done_callback(self.tasks.discard) + + async def _serve_accepted(self, conn, loop): + self._live_connections += 1 + self._note_listen_pressure() + try: + try: + reader = asyncio.StreamReader(limit=_STREAM_LIMIT) + protocol = asyncio.StreamReaderProtocol(reader) + transport, _ = await loop.connect_accepted_socket(lambda: protocol, conn) + writer = asyncio.StreamWriter(transport, protocol, reader, loop) except Exception as e: - if not self.stop_server: - logging.error(get_message('server_start_error', self.language, str(e))) - finally: - self.running = False - self.server_instance = None + logger.debug(f"接管已建立的连接失败(已关闭): {e}") + try: + conn.close() + except OSError: + pass + return + await self.handle_client(reader, writer) + finally: + self._live_connections -= 1 + + async def _serve_until_stopped(self) -> None: + while not self.stop_server: + await asyncio.sleep(0.2) + + def _stop_acceptor(self) -> None: + event = getattr(self, '_acceptor_stop', None) + sock = getattr(self, '_listen_sock', None) + thread = getattr(self, '_acceptor', None) + + if self._listen_sock is sock: + self._listen_sock = None + if event is not None: + event.set() + if sock is not None: + try: + sock.close() + except OSError: + pass + if thread is not None and thread.is_alive(): + thread.join(timeout=2) + + async def start(self): + self.stop_server = False + self.last_start_error = None + + sock = None + try: + if port_in_use(self.port): + raise RuntimeError( + get_message('port_in_use', self.language, self.port) + ) + + sock = self._create_listen_socket() + + loop = asyncio.get_running_loop() + self._event_loop = loop + self._loop_thread = threading.current_thread() + self.request_semaphore = asyncio.Semaphore(self.max_concurrent_requests) + self._refill_lock = asyncio.Lock() + self._workers.rebind() + self._reset_elastic_capacity() + self._workers.apply_capacity(self.max_concurrent_per_proxy) + self._workers.set_selection(self._selection_mode()) + self._last_demand_at = 0.0 + self._live_connections = 0 + self._fill_backoff = 0.0 + self._next_fill_attempt = 0.0 + self._last_rotate_attempt = 0.0 + self._last_burst_expand = 0.0 + self._expand_task = None + self._exit_real_ip_probing = set() + with self.client_pool_lock: + self.client_pool.clear() + self._client_inflight.clear() + self._clients_pending_close.clear() + install_loop_noise_filter(loop) + if hasattr(loop, 'set_default_executor'): + import concurrent.futures + executor = concurrent.futures.ThreadPoolExecutor(max_workers=max(32, (os.cpu_count() or 1) * 4)) + loop.set_default_executor(executor) + + self.domain_stats.start() + self.access_records.start() + + self.tasks.add(asyncio.create_task(self.cleanup_clients())) + self.tasks.add(asyncio.create_task(self._replenish_loop())) + self._ensure_tunnel_sweeper() + if self._sweeper_task is not None: + self.tasks.add(self._sweeper_task) + + if hasattr(os, 'sched_setaffinity'): + try: + os.sched_setaffinity(0, range(os.cpu_count() or 1)) + except Exception: + pass + + sock.listen(_LISTEN_BACKLOG) + self._listen_sock = sock + self._acceptor_stop = threading.Event() + self._acceptor = threading.Thread( + target=self._accept_loop, args=(sock, loop), + name="proxycat-accept", daemon=True, + ) + self._acceptor.start() + sock = None + + self.running = True + logger.info(get_message('server_running', self.language, '0.0.0.0', self.port)) + + await self._serve_until_stopped() + + except Exception as e: + if not self.stop_server: + self.last_start_error = str(e) + logger.error(get_message('server_start_error', self.language, str(e))) + finally: + self.running = False + self._stop_acceptor() + if sock is not None: + try: + sock.close() + except Exception: + pass async def stop(self): - if self.running: - self.stop_server = True - if self.server_instance: - self.server_instance.close() - await self.server_instance.wait_closed() - self.server_instance = None - - for task in self.tasks: - task.cancel() - if self.tasks: - await asyncio.gather(*self.tasks, return_exceptions=True) - self.tasks.clear() - - self.running = False - logging.info(get_message('server_shutting_down', self.language)) + self.stop_server = True + + tasks = list(self.tasks) + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self.tasks.difference_update(tasks) + + self._stop_acceptor() - async def get_next_proxy(self): try: - current_time = time.time() - - - if self.mode == 'loadbalance' and self.proxies: - if not self.switching_proxy: - try: - self.switching_proxy = True - self.last_switch_attempt = current_time - - if not self.use_getip: - - if not self.proxy_cycle: - self.proxy_cycle = cycle(self.proxies) - self.current_proxy = next(self.proxy_cycle) - logging.info(f"负载均衡模式选择代理: {self.current_proxy}") - else: - - await self.get_proxy() - finally: - self.switching_proxy = False - return self.current_proxy - - - if self.switching_proxy or (current_time - self.last_switch_attempt < self.switch_cooldown): - return self.current_proxy - - if self.interval > 0 and current_time - self.last_switch_time >= self.interval or \ - (self.use_getip and not self.current_proxy): - try: - self.switching_proxy = True - self.last_switch_attempt = current_time - old_proxy = self.current_proxy - - await self.get_proxy() + loop = getattr(self, '_event_loop', None) + if loop: + default_executor = getattr(loop, '_default_executor', None) + if default_executor: + default_executor.shutdown(wait=False) + except Exception: + pass - finally: - self.switching_proxy = False - - return self.current_proxy - - except Exception as e: - logging.error(get_message('proxy_switch_error', self.language, str(e))) - self.switching_proxy = False - return self.current_proxy + self.domain_stats.stop() + self.access_records.stop() - async def _load_getip_proxy(self): - valid_proxies = [] - for _ in range(4): - new_ip = getip.newip() - if validate_proxy(new_ip): - valid_proxies.append(new_ip) + if self.running: + self.running = False + logger.info(get_message('server_shutting_down', self.language)) + + def _expiry_enabled(self) -> bool: + return self.interval > 0 or self.request_interval > 0 + + def _rotation_is_continuous(self) -> bool: + return (self.proxy_source_mode != 'local' + and self.mode == 'continuous') + + def _selection_mode(self) -> str: + if self.proxy_source_mode == 'local' and self.mode == 'cycle': + return 'order' + return 'load' + + def _expired_exits(self, current_time): + if not self._expiry_enabled(): + return [] + return self._workers.expired_workers(current_time, self.request_interval) + + def _replan_expiries(self) -> None: + self._workers.replan_expiries(self._plan_expiry(time.monotonic())) + + def _plan_expiry(self, current_time) -> float: + if not self._expiry_enabled() or self.interval <= 0: + return 0.0 + return current_time + self.interval + + def _fetch_allowed(self, current_time) -> bool: + return current_time - self.last_switch_attempt >= self.switch_cooldown + + async def _candidate_usable(self, url) -> bool: + if not self.check_proxies_on_use: + return True + return await self._check_worker(url) is True + + def _take_candidates(self, count) -> list: + taken = [] + while len(taken) < count: + url = self._standby.take() + if url is None: break - else: - logging.error(get_message('multiple_proxy_fail', self.language)) - exit(1) - return valid_proxies[0] + if self._workers.find(url) is not None or self._workers.recently_retired(url): + continue + taken.append(url) + return taken + + async def _probe_candidates(self, urls) -> list: + if len(urls) == 1: + return [await self._candidate_usable(urls[0])] + results = await asyncio.gather( + *(self._candidate_usable(url) for url in urls), return_exceptions=True) + return [result is True for result in results] + + async def _fill_from_standby(self, max_fill=None) -> int: + filled = 0 + while len(self._workers.roster) < self._exit_target: + if max_fill is not None and filled >= max_fill: + break + batch = self._take_candidates(_FILL_PROBE_FANOUT) + if not batch: + break + leftovers = [] + for url, usable in zip(batch, await self._probe_candidates(batch)): + if not usable: + continue + if (len(self._workers.roster) >= self._exit_target + or (max_fill is not None and filled >= max_fill)): + leftovers.append(url) + continue + if self._workers.find(url) is not None: + continue + self._workers.add_worker(url, self._plan_expiry(time.monotonic())) + filled += 1 + logger.info(get_message('proxy_exit_added', self.language, sanitize_proxy(url))) + if leftovers: + self._standby.put(leftovers) + return filled + + def _reset_elastic_capacity(self): + self._expansion_extra = 0 + self._capacity_boost = 1 + self._hot_since = None + self._cool_since = None + self._expanding = False + + def _elastic_in_use(self) -> bool: + return self._expansion_extra > 0 or self._capacity_boost != 1 + + @property + def _exit_target(self): + return self.exit_count + self._expansion_extra + + def _expansion_ceiling(self) -> int: + return max(self.exit_count, min(self.exit_count * 2, self.max_concurrent_requests)) + + @property + def elastic_active(self) -> bool: + return (self._elastic_in_use() + or len(self._workers.roster) > self._exit_target) + + def _drop_expired_when_idle(self) -> None: + expired = self._expired_exits(time.monotonic()) + if not expired: + return + dropped = 0 + for worker in expired: + if worker.inflight: + continue + if self._workers.drop_worker(worker.url): + dropped += 1 + if dropped: + self._sync_current_proxy() + logger.info(get_message('exit_pool_drained', self.language, + len(self._workers.roster))) + + def _lifetime_pending(self, worker, current_time) -> bool: + if not self._expiry_enabled(): + return False + return not self._workers.lifetime_ended( + worker, current_time, self.request_interval) + + def _is_excess(self, worker) -> bool: + if self._expansion_extra > 0: + return False + return len(self._workers.roster) > self.exit_count + + def _release_excess_exits(self, include_live=False) -> bool: + target = self._exit_target + current_time = time.monotonic() + changed = False + for worker in sorted( + self._workers.roster, + key=lambda w: (self._lifetime_pending(w, current_time), w.last_used), + ): + if len(self._workers.roster) <= target: + break + if worker.inflight: + continue + if not include_live and self._lifetime_pending(worker, current_time): + continue + self._workers.drop_worker(worker.url) + changed = True + if changed: + self._sync_current_proxy() + return changed + + async def _review_elastic_capacity(self): + now = time.monotonic() + + if len(self._workers.roster) > self._exit_target or self._trim_now: + async with self._refill_lock: + trim_now = self._trim_now + if trim_now: + released = self._release_excess_exits(include_live=True) + else: + released = self._release_excess_exits() + if trim_now and len(self._workers.roster) <= self._exit_target: + self._trim_now = False + if released: + logger.info(get_message('exit_pool_trimmed', self.language, + len(self._workers.roster))) + + ratio = (self._workers.saturation(self.max_concurrent_per_proxy) + if self.auto_expand_enabled else None) + + if ratio is None: + self._hot_since = None + self._cool_since = None + if self._elastic_in_use(): + await self._shrink_elastic_capacity() + return + + if ratio >= _EXPAND_SATURATION_RATIO: + self._cool_since = None + if self._hot_since is None: + self._hot_since = now + elif now - self._hot_since >= _EXPAND_TRIGGER_SECONDS: + await self._grow_elastic_capacity() + self._hot_since = now + return + + used_ratio = self._workers.utilization(self.max_concurrent_per_proxy) + if used_ratio is not None and used_ratio <= _EXPAND_RECOVER_UTILIZATION: + self._hot_since = None + if not self._elastic_in_use(): + self._cool_since = None + return + if self._cool_since is None: + self._cool_since = now + elif now - self._cool_since >= _EXPAND_RECOVER_SECONDS: + await self._shrink_elastic_capacity() + return + + self._hot_since = None + self._cool_since = None + + async def _grow_elastic_capacity(self): + if self._trim_now: + return + if self._expanding: + return + + self._expanding = True + try: + await self._grow_one_step() + finally: + self._expanding = False + + async def _grow_one_step(self): + adopted = max(self._expansion_extra, + max(0, len(self._workers.roster) - self.exit_count)) + if adopted > self._expansion_extra: + self._expansion_extra = adopted + + ceiling_extra = self._expansion_ceiling() - self.exit_count + if adopted >= ceiling_extra: + return + + raised_extra = adopted + 1 + self._expansion_extra = raised_extra + before = len(self._workers.roster) + outcome = await self._maintain_exit_pool() + + if len(self._workers.roster) > before: + logger.info(get_message('elastic_expanded', self.language, + len(self._workers.roster))) + return + + if self._expansion_extra == raised_extra: + self._expansion_extra = adopted + + if not outcome.fetched or outcome.gained: + return + + await self._raise_capacity_boost() + + async def _raise_capacity_boost(self): + if not self.max_concurrent_per_proxy: + return + if self._capacity_boost >= _EXPAND_MAX_CAPACITY_BOOST: + return + + self._capacity_boost = min( + _EXPAND_MAX_CAPACITY_BOOST, self._capacity_boost * 2) + self._workers.apply_capacity( + self.max_concurrent_per_proxy * self._capacity_boost) + logger.info(get_message('elastic_capacity_boosted', self.language, + self._capacity_boost)) + + async def _shrink_elastic_capacity(self): + self._cool_since = None + changed = False + + if self._capacity_boost != 1: + self._capacity_boost = 1 + self._workers.apply_capacity(self.max_concurrent_per_proxy) + changed = True + + if self._expansion_extra: + self._expansion_extra = 0 + changed = True + + if changed: + logger.info(get_message('elastic_released', self.language, + len(self._workers.roster))) + + def _note_listen_pressure(self) -> None: + if not self.auto_expand_enabled or not self.max_concurrent_per_proxy: + return + serving_exits = max(self._exit_target, len(self._workers.roster)) + capacity = serving_exits * self.max_concurrent_per_proxy * self._capacity_boost + if self._live_connections <= capacity: + return + at_exit_ceiling = (self._expansion_extra + >= self._expansion_ceiling() - self.exit_count) + if at_exit_ceiling and self._capacity_boost >= _EXPAND_MAX_CAPACITY_BOOST: + self._ceiling_log.warning( + 'burst-capacity-ceiling', + f"并发已顶到容量上限,但名册上限(exit_count × 2 = " + f"{self.exit_count * 2})与单出口额度放宽(" + f"{_EXPAND_MAX_CAPACITY_BOOST} 倍)都到顶了,无法再扩。" + f"要继续扩容请调大「在用出口数」这一项(当前 {self.exit_count})。", + ) + return + now = time.monotonic() + if now - self._last_burst_expand < _BURST_EXPAND_COOLDOWN: + return + if self._expand_task is not None and not self._expand_task.done(): + return + self._last_burst_expand = now + try: + self._expand_task = asyncio.create_task(self._expand_immediately()) + except RuntimeError: + self._expand_task = None + return + self.tasks.add(self._expand_task) + self._expand_task.add_done_callback(self.tasks.discard) + + async def _expand_immediately(self): + try: + ceiling_extra = self._expansion_ceiling() - self.exit_count + adopted = max(self._expansion_extra, + max(0, len(self._workers.roster) - self.exit_count)) + if adopted > self._expansion_extra: + self._expansion_extra = adopted + serving = max(self.exit_count + adopted, len(self._workers.roster)) + raise_to = min(ceiling_extra, max(1, serving * 2 - self.exit_count)) + + if raise_to > adopted and not self._expanding: + self._expanding = True + previous_extra = self._expansion_extra + reached = False + try: + self._expansion_extra = raise_to + before = len(self._workers.roster) + await self._maintain_exit_pool(force_fetch=True) + reached = len(self._workers.roster) > before + if reached: + logger.info(get_message('elastic_expanded', self.language, + len(self._workers.roster))) + finally: + if not reached and self._expansion_extra == raise_to: + self._expansion_extra = max(previous_extra, adopted) + self._expanding = False + + await self._raise_capacity_boost() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"突发扩容出错(已忽略): {e}") + + async def _fetch_into_standby(self) -> int | None: + missing = max(1, self._exit_target - len(self._workers.roster)) + wanted = min(missing * _FETCH_OVERSUBSCRIBE, _FETCH_MAX_BATCH) + try: + urls = await asyncio.wait_for( + self._fetch_candidates(wanted), timeout=15 + ) + except asyncio.TimeoutError: + logger.error(get_message('proxy_get_timeout', self.language)) + return None + except Exception as e: + logger.error(get_message('proxy_get_error', self.language, str(e))) + return None + + if urls is None: + return None + if not urls: + logger.warning(get_message('proxy_batch_fetch_failed', self.language, + get_message('proxy_get_failed', self.language))) + return 0 + + usable = [url for url in urls + if self._workers.find(url) is None + and not self._workers.recently_retired(url)] + return self._standby.put(usable) + + async def _replace_exit(self, victim) -> bool: + replacement = None + while True: + batch = self._take_candidates(_FILL_PROBE_FANOUT) + if not batch: + break + usable = [url for url, ok + in zip(batch, await self._probe_candidates(batch)) if ok] + if usable: + replacement = usable[0] + if len(usable) > 1: + self._standby.put(usable[1:]) + break + if replacement is None: + return False + + self._workers.add_worker(replacement, self._plan_expiry(time.monotonic())) + self._workers.drop_worker(victim.url, remember=False) + self._sync_current_proxy() + self.last_switch_time = time.time() + logger.info(get_message( + 'proxy_exit_rotated', self.language, + sanitize_proxy(victim.url), sanitize_proxy(replacement), + )) + return True + + async def _rotate_expired(self, current_time) -> int: + rotated = 0 + for victim in self._expired_exits(current_time): + if self._is_excess(victim): + self._workers.drop_worker(victim.url) + self._sync_current_proxy() + rotated += 1 + continue + if not await self._replace_exit(victim): + if self.proxy_source_mode == 'local': + self._workers.readmit(victim, self._plan_expiry(time.monotonic())) + rotated += 1 + continue + break + rotated += 1 + return rotated + + async def _replace_soonest(self) -> bool: + if not self._expiry_enabled(): + return False + roster = self._workers.roster + if not roster: + return False + soonest = min(roster, key=lambda worker: worker.expires_at or float('inf')) + return await self._replace_exit(soonest) + + async def _maintain_exit_pool(self, force_fetch=False, rotate=False, + max_fill=None) -> MaintenanceOutcome: + async with self._refill_lock: + filled = await self._fill_from_standby(max_fill) + + rotated = 0 + if rotate: + rotated = await self._rotate_expired(time.monotonic()) + + current_time = time.time() + expired = self._expired_exits(time.monotonic()) + deficient = len(self._workers.roster) < self._exit_target + + fetched = False + gained = 0 + if ((deficient or (rotate and expired)) + and (force_fetch or self._fetch_allowed(current_time))): + self.last_switch_attempt = current_time + result = await self._fetch_into_standby() + fetched = result is not None + gained = result or 0 + room = None if max_fill is None else max(0, max_fill - filled) + filled += await self._fill_from_standby(room) + if rotate: + rotated += await self._rotate_expired(time.monotonic()) + + return MaintenanceOutcome(rotated > 0 or filled > 0, fetched, gained) + + async def _maintain_exit_pool_for_request(self, **kwargs) -> MaintenanceOutcome: + if self._refill_lock.locked(): + return _NO_MAINTENANCE + try: + return await asyncio.wait_for( + self._maintain_exit_pool(**kwargs), _REQUEST_MAINTENANCE_BUDGET) + except asyncio.TimeoutError: + self._silent_log.warning( + 'request-maintenance-budget', + f"请求路径上的出口池维护超过 {_REQUEST_MAINTENANCE_BUDGET:g} 秒(先按现有名册继续," + f"后台会接着补)", + ) + return _NO_MAINTENANCE + + async def _allocate_exit(self, exclude=None): + self._last_demand_at = time.monotonic() + wait_deadline = time.monotonic() + self.exit_wait_timeout + try: + if not self._workers.roster: + await self._maintain_exit_pool_for_request(force_fetch=True, max_fill=1) + if not self._workers.roster: + await self._wait_for_exit(wait_deadline) + elif (self._expired_exits(time.monotonic()) + and time.monotonic() - self._last_rotate_attempt >= _ROTATE_RETRY_SECONDS): + self._last_rotate_attempt = time.monotonic() + await self._maintain_exit_pool_for_request(rotate=True, max_fill=1) + + lease = await self._workers.acquire(asyncio.current_task(), exclude=exclude) + if lease is None: + if await self._wait_for_exit(wait_deadline): + lease = await self._workers.acquire( + asyncio.current_task(), exclude=exclude) + if lease is None: + return None, self.current_proxy + + self.current_proxy = lease.url + return lease, lease.url + + except Exception as e: + logger.error(get_message('proxy_allocate_error', self.language, str(e))) + return None, self.current_proxy + + async def get_next_proxy(self, exclude=None): + _lease, proxy = await self._allocate_exit(exclude=exclude) + return proxy + + async def _wait_for_exit(self, deadline=None) -> bool: + if deadline is None: + deadline = time.monotonic() + self.exit_wait_timeout + while not self.stop_server and not self._workers.roster: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + self._last_demand_at = time.monotonic() + await self._workers.wait_for_roster_change(min(0.5, remaining)) + return bool(self._workers.roster) + + async def _replenish_loop(self): + while True: + await asyncio.sleep(1) + if not self.running: + return + try: + self._workers.expire_idle(wake=True) + await self._review_elastic_capacity() + self._schedule_real_ip_probes() + + recent = (time.monotonic() - self._last_demand_at + <= _DEMAND_RECENT_SECONDS) + if not self._rotation_is_continuous() and not recent: + self._drop_expired_when_idle() + continue + + if time.monotonic() < self._next_fill_attempt: + continue + before = len(self._workers.roster) + outcome = await self._maintain_exit_pool(rotate=True) + starved = (len(self._workers.roster) < self._exit_target + and len(self._workers.roster) <= before) + if starved or (outcome.fetched and not outcome.gained): + self._fill_backoff = min( + max(1.0, self._fill_backoff * 2), _FILL_BACKOFF_MAX) + else: + self._fill_backoff = 0.0 + self._next_fill_attempt = time.monotonic() + self._fill_backoff + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"维持出口池出错(已忽略): {e}") + + async def _fetch_candidates(self, missing): + if self.proxy_source_mode == 'api': + return await self._load_api_proxies(missing) + if self.proxy_source_mode == 'pool': + return await self._load_pool_proxies(missing) + return self._load_file_proxies() + + def _note_source_error(self, error_text: str) -> None: + reason_type = str(error_text).split(':', 1)[0].strip() + key = { + 'request': 'proxy_source_error_request', + 'config': 'proxy_source_error_config', + }.get(reason_type, 'proxy_source_error_unknown') + self._last_source_error = (get_message(key, self.language), time.time()) + + def last_source_error(self): + if self._last_source_error is None: + return None + message, at = self._last_source_error + return {'message': message, 'at': at} + + async def _load_api_proxies(self, limit=None): + wanted = None if limit is None else max(int(limit), _POOL_FETCH_MIN) + try: + loop = asyncio.get_running_loop() + proxies = await loop.run_in_executor(None, getip_newip_list, wanted) + except Exception as e: + self._note_source_error(str(e)) + logger.error(get_message('proxy_get_error', self.language, str(e))) + return None + self._last_source_error = None + return proxies + + async def _load_pool_proxies(self, limit): + limit = max(int(limit), _POOL_FETCH_MIN) + if not self.pool_remote_url: + return await self._load_inprocess_pool_proxies(limit) + try: + async with httpx.AsyncClient(timeout=10, verify=False, + trust_env=False) as client: + response = await client.get(self.pool_remote_url) + if response.status_code == 404: + logger.warning(get_message('pool_no_proxy', self.language)) + return [] + if response.status_code != 200: + logger.warning(get_message('pool_error_http_status', self.language, response.status_code)) + return None + accepted = [line.strip() for line in response.text.split('\n') + if line.strip() and validate_proxy(line.strip())] + if not accepted: + logger.warning(get_message('pool_error_invalid_format', self.language, response.text[:100])) + return None + return accepted[:limit] + except httpx.ConnectError as e: + logger.error(get_message('pool_error_connect', self.language, str(e))) + return None + except httpx.TimeoutException: + logger.error(get_message('pool_error_timeout', self.language)) + return None + except Exception as e: + logger.error(get_message('pool_fetch_failed', self.language, str(e))) + return None + + async def _load_inprocess_pool_proxies(self, limit): + if self._pool_service is None: + logger.error(get_message('pool_unavailable', self.language, + get_message('pool_not_enabled', self.language))) + return None + + if not self._pool_service.is_running: + logger.warning(get_message( + 'pool_unavailable', + self.language, + self._pool_service.last_error + or get_message('pool_not_running', self.language), + )) + return None + + try: + entries = await self._pool_service.fetch_proxy_entries(limit) + except asyncio.TimeoutError: + logger.warning(get_message('pool_error_timeout', self.language)) + return None + except Exception as e: + logger.error(get_message('pool_fetch_failed', self.language, str(e))) + return None + + accepted = [] + for entry in entries or (): + url = (entry.get('proxy_url') or '').strip() + if not validate_proxy(url): + continue + real_ip = (entry.get('real_ip') or '').strip() + if real_ip: + self._remember_exit_real_ip(url, real_ip) + accepted.append(url) + if not accepted: + logger.warning(get_message('pool_no_proxy', self.language)) + return accepted + + def _store_exit_real_ip(self, url, real_ip): + if not url: + return + self._exit_real_ips.pop(url, None) + self._exit_real_ips[url] = (real_ip, time.monotonic()) + while len(self._exit_real_ips) > _EXIT_REAL_IP_CACHE_LIMIT: + self._exit_real_ips.pop(next(iter(self._exit_real_ips))) + + def _remember_exit_real_ip(self, url, real_ip): + if not url or not real_ip: + return + self._store_exit_real_ip(url, real_ip) + + def _real_ip_for(self, url) -> str: + entry = self._exit_real_ips.get(url) + return entry[0] if entry and entry[0] else '' + + def _load_real_ip_probe_endpoints(self) -> tuple: + try: + from modules import pool_config_ini + + pool_config = pool_config_ini.load_pool_config( + os.path.join(_BASE_DIR, 'config', 'config.ini')) + apis = getattr(pool_config.validator, 'ip_check_apis', ()) or () + return tuple(str(api) for api in apis + if str(api).lower().startswith('https://')) + except Exception as e: + logger.warning(f"读取出口 IP 回显端点失败(真实 IP 探测将跳过): {e}") + return () + + def _schedule_real_ip_probes(self): + if not self.real_ip_probe or self.proxy_source_mode != 'api': + return + if not self._real_ip_probe_endpoints: + return + pending = [ + worker.url for worker in self._workers.roster + if worker.url not in self._exit_real_ip_probing + and self._worker_needs_real_ip_probe(worker) + ] + for url in pending[:_REAL_IP_PROBE_FANOUT]: + self._exit_real_ip_probing.add(url) + self._schedule_async_task(self._probe_exit_real_ip_wrapper(url)) + + def _worker_needs_real_ip_probe(self, worker) -> bool: + entry = self._exit_real_ips.get(worker.url) + if entry is None: + return True + value, probed_at = entry + if probed_at < worker.added_at: + return True + return (not value + and time.monotonic() - probed_at >= _REAL_IP_PROBE_RETRY_SECONDS) + + async def _probe_exit_real_ip_wrapper(self, url): + try: + value = await self._probe_exit_real_ip(url) + except asyncio.CancelledError: + self._exit_real_ip_probing.discard(url) + raise + except Exception as e: + value = '' + logger.debug(f"探测出口真实 IP 出错 {sanitize_proxy(url)}: {e}") + self._exit_real_ip_probing.discard(url) + self._store_exit_real_ip(url, value or '') + + async def _probe_exit_real_ip(self, url): + for endpoint in self._real_ip_probe_endpoints: + try: + async with self._lease_client(url) as client: + response = await client.get( + endpoint, timeout=_REAL_IP_PROBE_TIMEOUT) + except Exception: + continue + if response.status_code == 200: + real_ip = _parse_real_ip_echo(response.text) + if real_ip: + return real_ip + return '' def time_until_next_switch(self): - return float('inf') if self.mode == 'loadbalance' else max(0, self.interval - (time.time() - self.last_switch_time)) + mode = self.switch_countdown_mode() + if mode == 'request_count': + return min((max(0, self.request_interval - w.requests_served) + for w in self._workers.roster), default=0) + if mode == 'time': + now = time.monotonic() + return min((max(0.0, w.expires_at - now) + for w in self._workers.roster if w.expires_at), default=0.0) + return 0 + + def switch_countdown_mode(self): + if self.interval > 0: + return 'time' + if self.request_interval > 0: + return 'request_count' + return 'per_request' + + _KNOWN_CLIENTS_LIMIT = 10000 + + def _record_known_client(self, client_key, client_ip, username_display): + if client_key in self.known_clients: + self.known_clients.move_to_end(client_key) + return + if len(self.known_clients) >= self._KNOWN_CLIENTS_LIMIT: + self.known_clients.popitem(last=False) + self.known_clients[client_key] = True + logger.info(get_message('new_client_connect', self.language, client_ip, username_display)) def check_ip_auth(self, ip): try: @@ -338,33 +1940,80 @@ class AsyncProxyServer: return ip in self.whitelist return True except Exception as e: - logging.error(get_message('whitelist_error', self.language, str(e))) + logger.error(get_message('whitelist_error', self.language, str(e))) return False + def _is_bypass_target(self, host): + return check_bypass_match(host, self.bypass_whitelist) + def _authenticate(self, headers): if not self.auth_required: return True - + auth_header = headers.get('proxy-authorization', '') if not auth_header: return False - + try: scheme, credentials = auth_header.split() if scheme.lower() != 'basic': return False - + decoded = base64.b64decode(credentials).decode() - username, password = decoded.split(':') - + username, password = decoded.split(':', 1) + if username in self.users and self.users[username] == password: return username, password - + except Exception: pass - + return False + @staticmethod + def _peer_ip(writer) -> str: + peername = writer.get_extra_info('peername') if writer is not None else None + if not peername: + return '-' + address = peername[0] + literal = _v4_mapped_literal(address) + if literal is not None: + return literal + try: + parsed = ipaddress.ip_address(address) + except ValueError: + return address + mapped = getattr(parsed, 'ipv4_mapped', None) + return str(mapped) if mapped is not None else address + + def _client_identity(self, writer, username: str = '') -> ClientIdentity: + return ClientIdentity(ip=self._peer_ip(writer), username=username or '') + + async def _reject(self, writer, access: AccessRecord, status_code: int, reason: str): + access.failed(reason, status_code) + phrase = _REASON_PHRASES.get(status_code, '') + try: + writer.write( + f'HTTP/1.1 {status_code} {phrase}\r\nConnection: close\r\n\r\n'.encode() + ) + await writer.drain() + except (ConnectionError, ConnectionResetError, ConnectionAbortedError): + pass + + async def _reject_tunnel(self, writer, access, established, status_code, reason): + if established: + access.failed(reason) + return + await self._reject(writer, access, status_code, reason) + + async def _write_all(self, writer, data: bytes) -> bool: + try: + writer.write(data) + await writer.drain() + return True + except (ConnectionError, ConnectionResetError, ConnectionAbortedError): + return False + async def _close_connection(self, writer): try: if writer and not writer.is_closing(): @@ -381,1086 +2030,1472 @@ class AsyncProxyServer: async def handle_client(self, reader, writer): task = asyncio.current_task() self.tasks.add(task) - peername = writer.get_extra_info('peername') - if peername: - self.active_connections.add(peername) try: - peername = writer.get_extra_info('peername') - if peername: - client_ip = peername[0] - if not self.check_ip_auth(client_ip): - logging.warning(get_message('unauthorized_ip', self.language, client_ip)) - writer.write(b'HTTP/1.1 403 Forbidden\r\n\r\n') - await writer.drain() - return - - first_byte = await reader.read(1) + client_ip = self._peer_ip(writer) + if client_ip != '-' and not self.check_ip_auth(client_ip): + logger.warning(get_message('unauthorized_ip', self.language, client_ip)) + writer.write(b'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') + await writer.drain() + return + + header_deadline = asyncio.get_running_loop().time() + _CLIENT_HEADER_TIMEOUT + try: + first_byte = await asyncio.wait_for(reader.read(1), _CLIENT_HEADER_TIMEOUT) + except asyncio.TimeoutError: + return if not first_byte: return - + if first_byte == b'\x05': await self.handle_socks5_connection(reader, writer) else: - await self._handle_client_impl(reader, writer, first_byte) - - except Exception as e: - logging.error(get_message('client_handle_error', self.language, e)) - finally: - if peername: - self.active_connections.discard(peername) - await self._close_connection(writer) - self.tasks.remove(task) + await self._handle_client_impl(reader, writer, first_byte, header_deadline) - async def _pipe(self, reader, writer): + except Exception as e: + logger.error(get_message('client_handle_error', self.language, e)) + finally: + self._workers.release(task) + try: + await self._close_connection(writer) + finally: + self.tasks.discard(task) + + async def _pipe(self, reader, writer, remote_side=None, access=None, flow=None, + upstream=None, lease=None, record=None, hold_sink_on_dead=False): + source_side = 'client' if flow == 'to_upstream' else 'upstream' + sink_side = 'upstream' if flow == 'to_upstream' else 'client' + + def _note_ended(side: str) -> None: + if access is not None: + access.tunnel_ended(side) + + clean_eof = False try: while True: try: - data = await reader.read(self.buffer_size) + watching = self._needs_idle_watch(access, flow) + if watching: + self._ensure_tunnel_sweeper() + self._idle_watch[asyncio.current_task()] = (access, upstream) + try: + data = await reader.read(_RELAY_CHUNK) + finally: + if watching: + self._idle_watch.pop(asyncio.current_task(), None) + if not data: + _note_ended(source_side) + clean_eof = True break + if record is not None: + record.add(data) + if lease is not None: + lease.touch() + if access is not None: + if flow == 'from_upstream': + first_relay = not access.upstream_data_seen + access.tunnel_relayed() + if first_relay: + self._note_exit_success(upstream) + elif flow == 'to_upstream': + access.tunnel_client_data() try: writer.write(data) await writer.drain() except (ConnectionError, ConnectionResetError): - - await self.handle_proxy_failure() + if remote_side == 'writer': + self._blame_proxy(access, upstream, '') + _note_ended(sink_side) break except (ConnectionError, ConnectionResetError): - - await self.handle_proxy_failure() + if remote_side == 'reader': + self._blame_proxy(access, upstream, '') + _note_ended(source_side) break except asyncio.CancelledError: - pass + if access is not None and access.swept_as_dead: + _note_ended('upstream') + await self._abort_dead_tunnel(access, upstream) except Exception as e: - - await self.handle_proxy_failure() - pass + if remote_side in ('reader', 'writer'): + self._blame_proxy(access, upstream, '') + logger.error( + "隧道中继出错(%s -> %s,出口 %s): %s", + source_side, sink_side, + sanitize_proxy(upstream) if upstream else '直接连接', e, + ) finally: - await self._close_connection(writer) + if hold_sink_on_dead and access is not None and not access.upstream_data_seen: + pass + elif clean_eof: + await self._half_close(writer) + else: + await self._close_connection(writer) + + async def _half_close(self, writer): + try: + if writer and not writer.is_closing(): + writer.write_eof() + await writer.drain() + except Exception: + pass + + async def _relay_until_both_ends(self, to_upstream, from_upstream, upstream_writer): + try: + await asyncio.gather(to_upstream, from_upstream) + finally: + await self._close_connection(upstream_writer) + + async def _relay_replayable_tunnel(self, reader, remote_reader, remote_writer, writer, + access, upstream, lease, record): + to_upstream = asyncio.ensure_future(self._pipe( + reader, remote_writer, remote_side='writer', access=access, + flow='to_upstream', upstream=upstream, lease=lease, record=record)) + from_upstream = asyncio.ensure_future(self._pipe( + remote_reader, writer, remote_side='reader', access=access, + flow='from_upstream', upstream=upstream, lease=lease, + hold_sink_on_dead=True)) + try: + done, _pending = await asyncio.wait( + {to_upstream, from_upstream}, return_when=asyncio.FIRST_COMPLETED) + if from_upstream in done and not access.upstream_data_seen: + to_upstream.cancel() + await asyncio.gather(to_upstream, from_upstream, return_exceptions=True) + except asyncio.CancelledError: + for task in (to_upstream, from_upstream): + task.cancel() + await asyncio.gather(to_upstream, from_upstream, return_exceptions=True) + raise + finally: + await self._close_connection(remote_writer) + + def _needs_idle_watch(self, access, flow) -> bool: + return not (self.tunnel_idle_timeout <= 0 or access is None + or flow != 'from_upstream' or access.upstream_data_seen + or access.direct) + + def _sweep_idle_tunnels(self): + if not self._idle_watch: + return + for task, (access, _upstream) in list(self._idle_watch.items()): + if task.done(): + continue + if self._tunnel_is_dead(access): + access.swept_as_dead = True + task.cancel() + elif self._tunnel_is_abandoned(access): + access.swept_as_idle = True + task.cancel() + + def _ensure_tunnel_sweeper(self): + task = getattr(self, '_sweeper_task', None) + if task is not None and not task.done(): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + self._sweeper_task = loop.create_task(self._tunnel_sweep_loop()) + + async def _tunnel_sweep_loop(self): + while True: + await asyncio.sleep(_TUNNEL_SWEEP_INTERVAL) + if not self.running and not self._idle_watch: + return + try: + self._sweep_idle_tunnels() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"清扫空闲隧道出错(已忽略): {e}") + + def _tunnel_is_dead(self, access) -> bool: + if not access.client_data_seen: + return False + return (time.monotonic() - access.last_activity_at) >= self.tunnel_idle_timeout + + def _tunnel_is_abandoned(self, access) -> bool: + if access.client_data_seen or access.upstream_data_seen: + return False + return (time.monotonic() - access.started_at) >= _IDLE_TUNNEL_TIMEOUT + + async def _abort_dead_tunnel(self, access, upstream=None) -> None: + access.failed(get_message('access_tunnel_no_data', self.language)) + self._schedule_async_task(self._confirm_dead_before_blaming(access, upstream)) + + async def _confirm_dead_before_blaming(self, access, upstream) -> None: + if access is not None and access.proxy_failure_reported: + return + if access is not None: + access.proxy_failure_reported = True + try: + await self._check_worker(upstream) + except Exception as e: + logger.debug(f"判死前的确认探测出错(已忽略): {e}") + + def _has_alternative_exit(self, failed_url) -> bool: + return any(worker.url != failed_url for worker in self._workers.roster) + + def _tunnel_can_replay(self, access, replay, deadline) -> bool: + if access is None or access.direct or access.upstream_data_seen: + return False + if access.tunnel_ended_by != 'upstream': + return False + if not replay.replayable: + return False + return deadline - asyncio.get_running_loop().time() > 0 + + def _blame_proxy(self, access, upstream=None, reason=_REASON_SILENT_TUNNEL) -> None: + if access is not None and (access.proxy_failure_reported or access.direct): + return + if access is not None: + access.proxy_failure_reported = True + self._schedule_async_task(self.handle_proxy_failure(upstream, reason)) + + async def _review_tunnel_outcome(self, access, upstream=None) -> None: + if access.upstream_data_seen or not access.client_data_seen: + return + if access.tunnel_ended_by != 'upstream': + return + self._blame_proxy(access, upstream) def _split_proxy_auth(self, proxy_addr): - match = re.match(r'((?P.+?):(?P.+?)@)?(?P.+)', proxy_addr) - if match: - username = match.group('username') - password = match.group('password') - host = match.group('host') - if username and password: - return f"{username}:{password}", host + if '@' in proxy_addr: + auth, host = proxy_addr.rsplit('@', 1) + return auth, host return None, proxy_addr - async def _create_client(self, proxy): - proxy_type, proxy_addr = proxy.split('://') - proxy_auth = None - - if '@' in proxy_addr: - auth, proxy_addr = proxy_addr.split('@') - proxy_auth = auth - - if proxy_auth: - proxy_url = f"{proxy_type}://{proxy_auth}@{proxy_addr}" - else: - proxy_url = f"{proxy_type}://{proxy_addr}" - - import logging as httpx_logging - httpx_logging.getLogger("httpx").setLevel(logging.WARNING) - httpx_logging.getLogger("hpack").setLevel(logging.WARNING) - httpx_logging.getLogger("h2").setLevel(logging.WARNING) - - return httpx.AsyncClient( - proxies={"all://": proxy_url}, - limits=httpx.Limits( - max_keepalive_connections=100, - max_connections=1000, - keepalive_expiry=30 - ), - timeout=30.0, - http2=True, - verify=False, - follow_redirects=True - ) + @staticmethod + def _socks5_atyp(host): + try: + socket.inet_aton(host) + return 1 + except OSError: + pass + try: + socket.inet_pton(socket.AF_INET6, host) + return 4 + except OSError: + return 3 - async def _cleanup_connections(self): + def _build_client(self, proxy_url=None): + max_connections = self.client_max_connections + if proxy_url is None: + max_connections = max(max_connections, self.max_concurrent_requests) + kwargs = { + 'limits': httpx.Limits( + max_keepalive_connections=self.client_max_keepalive, + max_connections=max_connections, + keepalive_expiry=self.client_keepalive_expiry, + ), + 'timeout': 30.0, + 'http2': True, + 'verify': False, + 'follow_redirects': False, + 'trust_env': False, + 'cookies': _NullCookieJar(), + } + if proxy_url: + kwargs['proxies'] = {"all://": proxy_url} + return httpx.AsyncClient(**kwargs) + + async def _create_client(self, proxy=None): + proxy_url = None + if proxy: + proxy_type, proxy_addr = proxy.split('://', 1) + proxy_auth, proxy_host_port = self._split_proxy_auth(proxy_addr) + if proxy_auth: + proxy_url = f"{proxy_type}://{proxy_auth}@{proxy_host_port}" + else: + proxy_url = f"{proxy_type}://{proxy_host_port}" + return self._build_client(proxy_url) + + async def _acquire_pooled(self, key, factory, *, evict): current_time = time.time() - expired_keys = [ - key for key, client in self.connection_pool.items() - if current_time - client._last_used > 30 - ] - for key in expired_keys: - client = self.connection_pool.pop(key) - await client.aclose() + expired_client = None + + with self.client_pool_lock: + if key in self.client_pool: + client, last_used = self.client_pool[key] + in_use = bool(self._client_inflight.get(key)) + if not client.is_closed and ( + in_use or current_time - last_used < self.client_idle_timeout): + self.client_pool[key] = (client, current_time) + return client + expired_client = self.client_pool.pop(key)[0] + + if expired_client is not None and not expired_client.is_closed: + await expired_client.aclose() + + try: + client = factory() + if asyncio.iscoroutine(client): + client = await client + except Exception as e: + logger.error(f"创建客户端失败: {str(e)}") + raise + + evicted_client = None + with self.client_pool_lock: + if key in self.client_pool: + evicted_client = client + winner = self.client_pool[key][0] + else: + pool_limit = max(self.max_pool_size, + 2 * len(self._workers.roster), + 4 * self.exit_count) + if evict and len(self.client_pool) >= pool_limit: + evictable = [k for k in self.client_pool + if not self._client_inflight.get(k)] + if evictable: + oldest_key = min(evictable, key=lambda x: self.client_pool[x][1]) + evicted_client, _ = self.client_pool.pop(oldest_key) + self.client_pool[key] = (client, current_time) + winner = client + + if evicted_client is not None and not evicted_client.is_closed: + await evicted_client.aclose() + return winner + + @asynccontextmanager + async def _borrow_client(self, key, factory, *, evict): + client = await self._acquire_pooled(key, factory, evict=evict) + with self.client_pool_lock: + self._client_inflight[key] = self._client_inflight.get(key, 0) + 1 + try: + yield client + finally: + pending = None + with self.client_pool_lock: + remaining = self._client_inflight.get(key, 1) - 1 + if remaining > 0: + self._client_inflight[key] = remaining + else: + self._client_inflight.pop(key, None) + pending = self._clients_pending_close.pop(key, None) + for dead in pending or (): + if not dead.is_closed: + await dead.aclose() + + def _lease_direct_client(self): + return self._borrow_client('__direct__', self._create_client, evict=False) async def handle_socks5_connection(self, reader, writer): try: - nmethods = ord(await reader.readexactly(1)) - await reader.readexactly(nmethods) + try: + request = await asyncio.wait_for( + self._read_socks5_request(reader, writer), _CLIENT_HEADER_TIMEOUT + ) + except asyncio.TimeoutError: + logger.debug("SOCKS5 握手超时:客户端未在时限内发完协商报文") + return + if request is None: + return - writer.write(b'\x05\x02' if self.auth_required else b'\x05\x00') + username, dst_addr, dst_port, atyp = request + async with self.request_semaphore: + await self._socks5_forward( + reader, writer, username, dst_addr, dst_port, atyp + ) + except Exception as e: + logger.error(get_message('socks5_connection_error', self.language, str(e))) + writer.write(b'\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00') await writer.drain() - if self.auth_required: - auth_version = await reader.readexactly(1) - if auth_version != b'\x01': - writer.close() - return - - ulen = ord(await reader.readexactly(1)) - username = await reader.readexactly(ulen) - plen = ord(await reader.readexactly(1)) - password = await reader.readexactly(plen) + async def _read_socks5_request(self, reader, writer): + username = '' + nmethods = ord(await reader.readexactly(1)) + await reader.readexactly(nmethods) - username = username.decode() - password = password.decode() - - if username in self.users and self.users[username] == password: - peername = writer.get_extra_info('peername') - if peername: - client_ip = peername[0] - client_key = (client_ip, username) - if client_key not in self.known_clients: - self.known_clients.add(client_key) - logging.info(get_message('new_client_connect', self.language, client_ip, f"{username}:{password}")) - else: - writer.write(b'\x01\x01') - await writer.drain() - writer.close() - return + writer.write(b'\x05\x02' if self.auth_required else b'\x05\x00') + await writer.drain() - writer.write(b'\x01\x00') - await writer.drain() - - version, cmd, _, atyp = struct.unpack('!BBBB', await reader.readexactly(4)) - if cmd != 1: - writer.write(b'\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00') - await writer.drain() + if self.auth_required: + auth_version = await reader.readexactly(1) + if auth_version != b'\x01': writer.close() return - if atyp == 1: - dst_addr = socket.inet_ntoa(await reader.readexactly(4)) - elif atyp == 3: - addr_len = ord(await reader.readexactly(1)) - dst_addr = (await reader.readexactly(addr_len)).decode() - elif atyp == 4: - dst_addr = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16)) + ulen = ord(await reader.readexactly(1)) + username = await reader.readexactly(ulen) + plen = ord(await reader.readexactly(1)) + password = await reader.readexactly(plen) + + username = username.decode() + password = password.decode() + + if username in self.users and self.users[username] == password: + client_ip = self._peer_ip(writer) + if client_ip != '-': + self._record_known_client( + (client_ip, username), client_ip, f"{username}:****" + ) else: - writer.write(b'\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00') + writer.write(b'\x01\x01') await writer.drain() writer.close() return - dst_port = struct.unpack('!H', await reader.readexactly(2))[0] + writer.write(b'\x01\x00') + await writer.drain() - max_retries = 1 + version, cmd, _, atyp = struct.unpack('!BBBB', await reader.readexactly(4)) + if cmd != 1: + writer.write(b'\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00') + await writer.drain() + writer.close() + return + + if atyp == 1: + dst_addr = socket.inet_ntoa(await reader.readexactly(4)) + elif atyp == 3: + addr_len = ord(await reader.readexactly(1)) + dst_addr = (await reader.readexactly(addr_len)).decode() + elif atyp == 4: + dst_addr = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16)) + else: + writer.write(b'\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00') + await writer.drain() + writer.close() + return + + dst_port = struct.unpack('!H', await reader.readexactly(2))[0] + return username, dst_addr, dst_port, atyp + + async def _socks5_forward(self, reader, writer, username, dst_addr, dst_port, atyp): + with self.access_tracker.track( + KIND_SOCKS5, '-', self._client_identity(writer, username), dst_addr, dst_port + ) as access: + if self._is_bypass_target(dst_addr): + try: + logger.info(f"绕过代理直连 SOCKS5: {dst_addr}:{dst_port}") + access.use_direct() + remote_reader, remote_writer = await self._open_upstream( + dst_addr, dst_port) + if not await self._write_all( + writer, b'\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00'): + return + access.tunnel_ready() + await self._relay_until_both_ends( + self._pipe(reader, remote_writer, access=access, flow='to_upstream'), + self._pipe(remote_reader, writer, access=access, flow='from_upstream'), + remote_writer, + ) + await self._review_tunnel_outcome(access) + return + except Exception as e: + logger.error(f"直连 SOCKS5 {dst_addr}:{dst_port} 失败: {e}") + access.failed(str(e)) + writer.write(b'\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00') + await writer.drain() + return + + max_retries = 2 retry_count = 0 last_error = None + loop = asyncio.get_running_loop() + deadline = loop.time() + _TUNNEL_TOTAL_BUDGET_SECONDS + failed_url = None while retry_count < max_retries: + upstream_writer = None + if retry_count and deadline - loop.time() <= 0: + last_error = last_error or "Tunnel time budget exhausted" + break + attempt_budget = (deadline - loop.time()) / (max_retries - retry_count) + attempt_deadline = loop.time() + attempt_budget try: - proxy = await self.get_next_proxy() + lease, proxy = await self._allocate_exit(exclude=failed_url) if not proxy: raise Exception("No proxy available") + failed_url = proxy + + access.use_proxy(proxy, real_ip=self._real_ip_for(proxy)) proxy_type, proxy_addr = proxy.split('://') proxy_auth, proxy_host_port = self._split_proxy_auth(proxy_addr) - proxy_host, proxy_port = proxy_host_port.split(':') + proxy_host, proxy_port = split_host_port(proxy_host_port) proxy_port = int(proxy_port) - remote_reader, remote_writer = await asyncio.wait_for( - asyncio.open_connection(proxy_host, proxy_port), - timeout=10 - ) + remote_reader, remote_writer = await self._open_upstream( + proxy_host, proxy_port, timeout=attempt_budget, + tls=(proxy_type == 'https')) + upstream_writer = remote_writer + handshake_timeout = max(0.0, attempt_deadline - loop.time()) if proxy_type == 'socks5': - await self._initiate_socks5(remote_reader, remote_writer, dst_addr, dst_port) + await self._initiate_socks5(remote_reader, remote_writer, dst_addr, dst_port, + atyp, proxy, handshake_timeout) elif proxy_type in ['http', 'https']: - await self._initiate_http(remote_reader, remote_writer, dst_addr, dst_port, proxy_auth) + await self._initiate_http(remote_reader, remote_writer, dst_addr, dst_port, + proxy_auth, handshake_timeout) + else: + raise UnsupportedUpstreamProxyError( + f"Unsupported proxy type: {proxy_type}") - writer.write(b'\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00') - await writer.drain() + if proxy_type == 'https': + _remember_upstream_tls_session(proxy_host, proxy_port, remote_writer) - await asyncio.gather( - self._pipe(reader, remote_writer), - self._pipe(remote_reader, writer) + if not await self._write_all( + writer, b'\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00'): + return + access.tunnel_ready() + if lease is not None: + lease.idle() + + await self._relay_until_both_ends( + self._pipe(reader, remote_writer, remote_side='writer', + access=access, flow='to_upstream', upstream=proxy, + lease=lease), + self._pipe(remote_reader, writer, remote_side='reader', + access=access, flow='from_upstream', upstream=proxy, + lease=lease), + remote_writer, ) - + + await self._review_tunnel_outcome(access, proxy) return except (asyncio.TimeoutError, ConnectionRefusedError, ConnectionResetError) as e: + await self._close_upstream(upstream_writer) last_error = e - logging.warning(get_message('request_retry', self.language, max_retries - retry_count - 1)) - await self.handle_proxy_failure() + self._log_retry(max_retries - retry_count - 1) + await self.handle_proxy_failure(proxy) retry_count += 1 if retry_count < max_retries: - await asyncio.sleep(1) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) continue - + + except UnsupportedUpstreamProxyError as e: + await self._close_upstream(upstream_writer) + last_error = e + retry_count += 1 + if retry_count < max_retries: + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue + except Exception as e: + await self._close_upstream(upstream_writer) last_error = e - logging.error(get_message('socks5_connection_error', self.language, str(e))) - await self.handle_proxy_failure() + logger.error(get_message('socks5_connection_error', self.language, str(e))) + await self.handle_proxy_failure(proxy) retry_count += 1 if retry_count < max_retries: - await asyncio.sleep(1) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) continue - #if last_error: - #logging.error(get_message('all_retries_failed', self.language, str(last_error))) + if last_error: + logger.error(get_message('all_retries_failed', self.language, str(last_error))) + access.failed( + str(last_error) if last_error + else get_message('pool_no_proxy', self.language)) writer.write(b'\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00') await writer.drain() - except Exception as e: - logging.error(get_message('socks5_connection_error', self.language, str(e))) - writer.write(b'\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00') - await writer.drain() + async def _socks5_upstream_handshake(self, remote_reader, remote_writer, + host, port, atyp, auth, timeout=10.0): + if auth: + remote_writer.write(b'\x05\x02\x00\x02') + else: + remote_writer.write(b'\x05\x01\x00') + await remote_writer.drain() - async def _initiate_socks5(self, remote_reader, remote_writer, dst_addr, dst_port): try: - auth = None - proxy_type, proxy_addr = self.current_proxy.split('://') - if '@' in proxy_addr: - auth, _ = proxy_addr.split('@') + auth_method = await asyncio.wait_for( + remote_reader.readexactly(2), timeout=timeout + ) + if auth_method[0] != 0x05: + raise Exception("Invalid SOCKS5 proxy response") - if auth: - remote_writer.write(b'\x05\x02\x00\x02') - else: - remote_writer.write(b'\x05\x01\x00') - - await remote_writer.drain() - - try: - auth_method = await asyncio.wait_for( - remote_reader.readexactly(2), - timeout=10 - ) - if auth_method[0] != 0x05: - raise Exception("Invalid SOCKS5 proxy response") - - if auth_method[1] == 0x02 and auth: - username, password = auth.split(':') - auth_packet = bytes([0x01, len(username)]) + username.encode() + bytes([len(password)]) + password.encode() - remote_writer.write(auth_packet) - await remote_writer.drain() - - auth_response = await asyncio.wait_for( - remote_reader.readexactly(2), - timeout=10 - ) - if auth_response[1] != 0x00: - raise Exception("Authentication failed") - - if isinstance(dst_addr, str): - remote_writer.write(b'\x05\x01\x00\x03' + len(dst_addr).to_bytes(1, 'big') + - dst_addr.encode() + dst_port.to_bytes(2, 'big')) - else: - remote_writer.write(b'\x05\x01\x00\x01' + socket.inet_aton(dst_addr) + - dst_port.to_bytes(2, 'big')) - + if auth_method[1] == 0x02 and auth: + username, password = auth.split(':', 1) + remote_writer.write(build_socks5_auth_packet(username, password)) await remote_writer.drain() - - response = await asyncio.wait_for( - remote_reader.readexactly(4), - timeout=10 + + auth_response = await asyncio.wait_for( + remote_reader.readexactly(2), timeout=timeout ) - if response[1] != 0x00: - error_codes = { - 0x01: "General failure", - 0x02: "Connection not allowed", - 0x03: "Network unreachable", - 0x04: "Host unreachable", - 0x05: "Connection refused", - 0x06: "TTL expired", - 0x07: "Command not supported", - 0x08: "Address type not supported" - } - error_msg = error_codes.get(response[1], f"Unknown error code {response[1]}") - raise Exception(f"Connection failed: {error_msg}") + if auth_response[1] != 0x00: + raise Exception("Authentication failed") - if response[3] == 0x01: - await asyncio.wait_for( - remote_reader.readexactly(6), - timeout=10 - ) - elif response[3] == 0x03: - domain_len = (await asyncio.wait_for( - remote_reader.readexactly(1), - timeout=10 - ))[0] - await asyncio.wait_for( - remote_reader.readexactly(domain_len + 2), - timeout=10 - ) - elif response[3] == 0x04: - await asyncio.wait_for( - remote_reader.readexactly(18), - timeout=10 - ) - else: - raise Exception(f"Unsupported address type: {response[3]}") - - except asyncio.TimeoutError: - raise Exception("SOCKS5 proxy response timeout") - except Exception as e: - raise Exception(f"SOCKS5 protocol error: {str(e)}") - - except Exception as e: - if isinstance(e, asyncio.TimeoutError): - raise Exception("SOCKS5 connection timeout") - elif "Connection refused" in str(e): - raise Exception("SOCKS5 connection refused") + if atyp == 1: + remote_writer.write(b'\x05\x01\x00\x01' + socket.inet_aton(host) + + port.to_bytes(2, 'big')) + elif atyp == 4: + remote_writer.write(b'\x05\x01\x00\x04' + + socket.inet_pton(socket.AF_INET6, host) + + port.to_bytes(2, 'big')) else: - raise Exception(f"SOCKS5 initialization failed: {str(e)}") + host_bytes = host.encode() + remote_writer.write(b'\x05\x01\x00\x03' + + len(host_bytes).to_bytes(1, 'big') + + host_bytes + port.to_bytes(2, 'big')) - async def _initiate_http(self, remote_reader, remote_writer, dst_addr, dst_port, proxy_auth): - connect_request = f'CONNECT {dst_addr}:{dst_port} HTTP/1.1\r\nHost: {dst_addr}:{dst_port}\r\n' + await remote_writer.drain() + + response = await asyncio.wait_for( + remote_reader.readexactly(4), timeout=timeout + ) + if response[1] != 0x00: + error_codes = { + 0x01: "General failure", + 0x02: "Connection not allowed", + 0x03: "Network unreachable", + 0x04: "Host unreachable", + 0x05: "Connection refused", + 0x06: "TTL expired", + 0x07: "Command not supported", + 0x08: "Address type not supported" + } + error_msg = error_codes.get(response[1], f"Unknown error code {response[1]}") + raise Exception(f"Connection failed: {error_msg}") + + if response[3] == 0x01: + await asyncio.wait_for(remote_reader.readexactly(6), timeout=timeout) + elif response[3] == 0x03: + domain_len = (await asyncio.wait_for( + remote_reader.readexactly(1), timeout=timeout + ))[0] + await asyncio.wait_for( + remote_reader.readexactly(domain_len + 2), timeout=timeout + ) + elif response[3] == 0x04: + await asyncio.wait_for(remote_reader.readexactly(18), timeout=timeout) + else: + raise Exception(f"Unsupported address type: {response[3]}") + + except asyncio.TimeoutError: + raise Exception("SOCKS5 proxy response timeout") + + async def _initiate_socks5(self, remote_reader, remote_writer, dst_addr, dst_port, + atyp=3, proxy=None, timeout=10.0): + auth = None + proxy_url = proxy if proxy is not None else self.current_proxy + if proxy_url: + _, proxy_addr = proxy_url.split('://', 1) + auth, _ = self._split_proxy_auth(proxy_addr) + + try: + await self._socks5_upstream_handshake( + remote_reader, remote_writer, dst_addr, dst_port, atyp, auth, timeout + ) + except Exception as e: + raise Exception(f"SOCKS5 initialization failed: {str(e)}") + + + async def _http_connect_handshake(self, remote_reader, remote_writer, + host, port, proxy_auth, timeout=10.0): + authority = f'[{host}]:{port}' if ':' in host else f'{host}:{port}' + connect_request = f'CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n' if proxy_auth: connect_request += f'Proxy-Authorization: Basic {base64.b64encode(proxy_auth.encode()).decode()}\r\n' connect_request += '\r\n' remote_writer.write(connect_request.encode()) await remote_writer.drain() - + + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + first_line = True + header_lines = 0 while True: - line = await remote_reader.readline() + header_lines += 1 + if header_lines > _MAX_HEADER_LINES: + raise Exception("Upstream proxy CONNECT response too long") + remaining = deadline - loop.time() + if remaining <= 0: + raise asyncio.TimeoutError("Upstream proxy CONNECT handshake timeout") + try: + line = await asyncio.wait_for(remote_reader.readline(), timeout=remaining) + except asyncio.TimeoutError: + raise asyncio.TimeoutError("Upstream proxy CONNECT handshake timeout") + if not line: + raise Exception("Upstream proxy closed connection unexpectedly") + if first_line: + first_line = False + status = None + parts = line.split(b' ', 2) + if len(parts) >= 2: + try: + status = int(parts[1]) + except ValueError: + status = None + if status is None or not (200 <= status < 300): + raise Exception( + "Upstream proxy refused CONNECT: " + + line[:80].decode('latin-1').strip() + ) if line == b'\r\n': break - async def _handle_client_impl(self, reader, writer, first_byte): - try: - peername = writer.get_extra_info('peername') - client_info = f"{peername[0]}:{peername[1]}" if peername else "未知客户端" - - if peername: - client_ip = peername[0] - if not self.check_ip_auth(client_ip): - logging.warning(get_message('unauthorized_ip', self.language, client_ip)) - writer.write(b'HTTP/1.1 403 Forbidden\r\n\r\n') - await writer.drain() - return - - request_line = first_byte + await reader.readline() - if not request_line: - return + async def _initiate_http(self, remote_reader, remote_writer, dst_addr, dst_port, proxy_auth, timeout=10.0): + await self._http_connect_handshake( + remote_reader, remote_writer, dst_addr, dst_port, proxy_auth, timeout + ) + async def _read_headers(self, reader): + headers = {} + consumed = 0 + for _ in range(_MAX_HEADER_LINES): + line = await reader.readline() + consumed += len(line) + if consumed > _MAX_HEADER_BYTES: + logger.debug("请求头超过总字节上限(已断开)") + return None + if line == b'\r\n': + return headers + if line == b'': + return None try: - method, path, _ = request_line.decode('utf-8', errors='ignore').split() - except (ValueError, UnicodeDecodeError) as e: - return + name, value = line.decode('utf-8', errors='ignore').split(':', 1) + headers[name.strip().lower()] = value.strip() + except ValueError: + continue + logger.debug("请求头超过行数上限(已断开)") + return None - headers = {} + @staticmethod + def _strip_hop_by_hop(client_headers): + headers = client_headers.copy() + for hop_header in _HOP_BY_HOP_HEADERS: + headers.pop(hop_header, None) + return headers + + def _forward_headers(self, client_headers, proxy): + headers = self._strip_hop_by_hop(client_headers) + + proxy_type, proxy_addr = proxy.split('://', 1) + if proxy_type in ('http', 'https') and '@' in proxy_addr: + auth, _ = proxy_addr.rsplit('@', 1) + headers['proxy-authorization'] = ( + f'Basic {base64.b64encode(auth.encode()).decode()}' + ) + return headers + + async def _handle_client_impl(self, reader, writer, first_byte, header_deadline=None): + loop = asyncio.get_running_loop() + if header_deadline is None: + header_deadline = loop.time() + _CLIENT_HEADER_TIMEOUT + try: while True: - line = await reader.readline() - if line == b'\r\n': - break - if line == b'': - return try: - name, value = line.decode('utf-8', errors='ignore').strip().split(': ', 1) - headers[name.lower()] = value - except ValueError: - continue - - if self.auth_required: - auth_result = self._authenticate(headers) - if not auth_result: - writer.write(b'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="Proxy"\r\n\r\n') - await writer.drain() + request_line = first_byte + await asyncio.wait_for( + reader.readline(), _remaining_time(header_deadline)) + except asyncio.TimeoutError: + return + if not request_line: return - elif isinstance(auth_result, tuple): - username, password = auth_result - peername = writer.get_extra_info('peername') - if peername: - client_ip = peername[0] - client_key = (client_ip, username) - if client_key not in self.known_clients: - self.known_clients.add(client_key) - logging.info(get_message('new_client_connect', self.language, client_ip, f"{username}:{password}")) - if method == 'CONNECT': - await self._handle_connect(path, reader, writer) - else: - await self._handle_request(method, path, headers, reader, writer) + try: + method, path, version = request_line.decode('utf-8', errors='ignore').split() + except (ValueError, UnicodeDecodeError) as e: + return + + try: + headers = await asyncio.wait_for( + self._read_headers(reader), _remaining_time(header_deadline)) + except asyncio.TimeoutError: + return + if headers is None: + return + + username = '' + if self.auth_required: + auth_result = self._authenticate(headers) + if not auth_result: + writer.write(b'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="Proxy"\r\nConnection: close\r\n\r\n') + await writer.drain() + return + elif isinstance(auth_result, tuple): + username, _ = auth_result + client_ip = self._peer_ip(writer) + if client_ip != '-': + self._record_known_client( + (client_ip, username), client_ip, f"{username}:***" + ) + + if method != 'CONNECT' and '100-continue' in headers.get('expect', '').lower(): + writer.write(b'HTTP/1.1 100 Continue\r\n\r\n') + await writer.drain() + headers.pop('expect', None) + + client = self._client_identity(writer, username) + if method == 'CONNECT': + with self.access_tracker.track(KIND_CONNECT, method, client) as access: + async with self.request_semaphore: + await self._handle_connect(path, reader, writer, access) + return + + keep_alive = (version == 'HTTP/1.1' + and 'close' not in headers.get('connection', '').lower()) + with self.access_tracker.track(KIND_HTTP, method, client) as access: + reusable = await self._handle_request( + method, path, headers, reader, writer, access, + keep_alive=keep_alive) + if not reusable: + return + + self._workers.release(asyncio.current_task()) + + try: + first_byte = await asyncio.wait_for(reader.read(1), _CLIENT_HEADER_TIMEOUT) + except asyncio.TimeoutError: + return + if not first_byte or first_byte == b'\x05': + return + header_deadline = loop.time() + _CLIENT_HEADER_TIMEOUT except (ConnectionError, ConnectionResetError, ConnectionAbortedError): return except asyncio.CancelledError: return except Exception as e: - if not isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError, + if not isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError, asyncio.CancelledError, asyncio.TimeoutError)): - logging.error(get_message('client_request_error', self.language, str(e))) + logger.error(get_message('client_request_error', self.language, str(e))) - async def _handle_connect(self, path, reader, writer): + async def _handle_connect(self, path, reader, writer, access): try: - host, port = path.split(':') - port = int(port) + host, port = split_host_port(path) except ValueError: - writer.write(b'HTTP/1.1 400 Bad Request\r\n\r\n') - await writer.drain() + await self._reject(writer, access, 400, get_message('access_reason_bad_target', self.language, path)) return - max_retries = 1 + access.host, access.port = host, port + + if self._is_bypass_target(host): + try: + logger.info(f"绕过代理直连 HTTPS: {host}:{port}") + access.use_direct() + remote_reader, remote_writer = await self._open_upstream(host, port) + if not await self._write_all( + writer, b'HTTP/1.1 200 Connection Established\r\n\r\n'): + return + access.tunnel_ready(200) + await self._relay_until_both_ends( + self._pipe(reader, remote_writer, access=access, flow='to_upstream'), + self._pipe(remote_reader, writer, access=access, flow='from_upstream'), + remote_writer, + ) + await self._review_tunnel_outcome(access) + return + except Exception as e: + logger.error(f"直连 {host}:{port} 失败: {e}") + await self._reject(writer, access, 502, get_message('access_reason_direct_failed', self.language, e)) + return + + max_retries = 2 retry_count = 0 last_error = None - + loop = asyncio.get_running_loop() + deadline = loop.time() + _TUNNEL_TOTAL_BUDGET_SECONDS + failed_url = None + established = False + replay = _ReplayBuffer(_TUNNEL_REPLAY_LIMIT) + while retry_count < max_retries: + upstream_writer = None + if retry_count and deadline - loop.time() <= 0: + await self._reject_tunnel(writer, access, established, 504, + last_error or "Tunnel time budget exhausted") + return + attempt_budget = (deadline - loop.time()) / (max_retries - retry_count) + attempt_deadline = loop.time() + attempt_budget try: - proxy = await self.get_next_proxy() + lease, proxy = await self._allocate_exit(exclude=failed_url) if not proxy: - writer.write(b'HTTP/1.1 503 Service Unavailable\r\n\r\n') - await writer.drain() + await self._reject_tunnel(writer, access, established, 503, + get_message('pool_no_proxy', self.language)) return + failed_url = proxy + + access.use_proxy(proxy, real_ip=self._real_ip_for(proxy)) try: proxy_type, proxy_addr = proxy.split('://') proxy_auth, proxy_host_port = self._split_proxy_auth(proxy_addr) - proxy_host, proxy_port = proxy_host_port.split(':') + proxy_host, proxy_port = split_host_port(proxy_host_port) proxy_port = int(proxy_port) - remote_reader, remote_writer = await asyncio.wait_for( - asyncio.open_connection(proxy_host, proxy_port), - timeout=10 - ) + remote_reader, remote_writer = await self._open_upstream( + proxy_host, proxy_port, timeout=attempt_budget, + tls=(proxy_type == 'https')) + upstream_writer = remote_writer - if proxy_type == 'http': - connect_headers = [f'CONNECT {host}:{port} HTTP/1.1', f'Host: {host}:{port}'] - if proxy_auth: - auth_header = f'Proxy-Authorization: Basic {base64.b64encode(proxy_auth.encode()).decode()}' - connect_headers.append(auth_header) - connect_request = '\r\n'.join(connect_headers) + '\r\n\r\n' - remote_writer.write(connect_request.encode()) - await remote_writer.drain() - response = await remote_reader.readline() - if not response.startswith(b'HTTP/1.1 200'): - - await self.handle_proxy_failure() - last_error = f"Bad Gateway: {response.decode('utf-8', errors='ignore')}" + if proxy_type in ('http', 'https'): + try: + await self._http_connect_handshake( + remote_reader, remote_writer, host, port, proxy_auth, + timeout=max(0.0, attempt_deadline - loop.time()), + ) + except asyncio.TimeoutError: + await self._close_upstream(upstream_writer) + await self.handle_proxy_failure(proxy) + last_error = "Upstream proxy CONNECT handshake timeout" retry_count += 1 - if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) - continue - raise Exception("Bad Gateway") - while (await remote_reader.readline()) != b'\r\n': - pass + if retry_count >= max_retries or not self._has_alternative_exit(proxy): + await self._reject_tunnel(writer, access, established, 504, last_error) + return + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue + except Exception as e: + await self._close_upstream(upstream_writer) + await self.handle_proxy_failure(proxy) + last_error = f"Bad Gateway: {e}" + retry_count += 1 + if retry_count >= max_retries or not self._has_alternative_exit(proxy): + await self._reject_tunnel(writer, access, established, 502, last_error) + return + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue elif proxy_type == 'socks5': - remote_writer.write(b'\x05\x01\x00') - await remote_writer.drain() - if (await remote_reader.read(2))[1] == 0: - remote_writer.write(b'\x05\x01\x00\x03' + len(host).to_bytes(1, 'big') + host.encode() + port.to_bytes(2, 'big')) - await remote_writer.drain() - if (await remote_reader.read(10))[1] != 0: - - await self.handle_proxy_failure() - last_error = "SOCKS5 connection failed" - retry_count += 1 - if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) - continue - raise Exception("Bad Gateway") + await self._socks5_upstream_handshake( + remote_reader, remote_writer, host, port, + self._socks5_atyp(host), proxy_auth, + ) else: - raise Exception("Unsupported proxy type") + raise UnsupportedUpstreamProxyError("Unsupported proxy type") - writer.write(b'HTTP/1.1 200 Connection Established\r\n\r\n') - await writer.drain() + if proxy_type == 'https': + _remember_upstream_tls_session(proxy_host, proxy_port, remote_writer) - await asyncio.gather( - self._pipe(reader, remote_writer), - self._pipe(remote_reader, writer) + if not established: + if not await self._write_all( + writer, b'HTTP/1.1 200 Connection Established\r\n\r\n'): + return + established = True + elif len(replay): + if not await self._write_all(remote_writer, bytes(replay.data)): + return + access.tunnel_client_data() + access.tunnel_ready(200) + if lease is not None: + lease.idle() + + await self._relay_replayable_tunnel( + reader, remote_reader, remote_writer, writer, + access, proxy, lease, replay, ) - - - return - + + await self._review_tunnel_outcome(access, proxy) + + if not self._tunnel_can_replay(access, replay, deadline): + return + access.retry_tunnel() + retry_count += 1 + if retry_count >= max_retries or not self._has_alternative_exit(proxy): + return + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue + except asyncio.TimeoutError: - - await self.handle_proxy_failure() + + await self._close_upstream(upstream_writer) + await self.handle_proxy_failure(proxy) last_error = "Connection Timeout" retry_count += 1 - if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) - continue - logging.error(get_message('connect_timeout', self.language)) - writer.write(b'HTTP/1.1 504 Gateway Timeout\r\n\r\n') - await writer.drain() - return - except Exception as e: - - await self.handle_proxy_failure() + if retry_count >= max_retries or not self._has_alternative_exit(proxy): + logger.error(get_message('connect_timeout', self.language)) + await self._reject_tunnel(writer, access, established, 504, last_error) + return + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue + except UnsupportedUpstreamProxyError as e: + await self._close_upstream(upstream_writer) last_error = str(e) retry_count += 1 if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) continue - writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n') - await writer.drain() + await self._reject_tunnel(writer, access, established, 502, last_error) return - + + except Exception as e: + + await self._close_upstream(upstream_writer) + await self.handle_proxy_failure(proxy) + last_error = str(e) + retry_count += 1 + if retry_count >= max_retries or not self._has_alternative_exit(proxy): + await self._reject_tunnel(writer, access, established, 502, last_error) + return + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue + except Exception as e: + await self._close_upstream(upstream_writer) last_error = str(e) retry_count += 1 if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) continue - writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n') - await writer.drain() + await self._reject_tunnel(writer, access, established, 502, last_error) return - - - #if last_error: - #logging.error(get_message('all_retries_failed', self.language, last_error)) - async def _handle_request(self, method, path, headers, reader, writer): - async with self.request_semaphore: - max_retries = 1 + + async def _handle_request(self, method, path, headers, reader, writer, access, + keep_alive=False): + async with self.request_semaphore, AsyncExitStack() as borrowed_clients: + target_host = None + target_port = 0 + host_header = headers.get('host', '') + if '://' in path: + request_url = path + from urllib.parse import urlparse + parsed = urlparse(path) + target_host = parsed.hostname + target_port = parsed.port or (443 if parsed.scheme == 'https' else 80) + elif host_header: + request_url = f'http://{host_header}{path}' + try: + target_host, target_port = split_host_port(host_header, default_port=80) + except ValueError: + target_host, target_port = host_header, 80 + else: + await self._reject(writer, access, 400, get_message('access_reason_bad_target', self.language, path)) + return + + if target_host: + access.host, access.port = target_host, target_port + + body = _client_request_body( + headers, reader, + asyncio.get_running_loop().time() + _BODY_TOTAL_TIMEOUT) + reuse_ok = keep_alive and body is None + response_started = False + + if target_host and self._is_bypass_target(target_host): + try: + logger.info(f"绕过代理直连 HTTP: {method} {path}") + access.use_direct() + client = await borrowed_clients.enter_async_context( + self._lease_direct_client()) + try: + request = _build_upstream_request( + client, method, request_url, + self._strip_hop_by_hop(headers), body) + async with _stream_upstream(client, request) as response: + response_started = True + allow_reuse = self._response_allows_reuse(response, method) and reuse_ok + writer.write(self._render_response_head(response, keep_alive=allow_reuse)) + access.succeeded(response.status_code) + try: + pending = 0 + async for chunk in response.aiter_raw(): + if not chunk: + break + try: + writer.write(chunk) + pending += len(chunk) + if pending >= self.buffer_size: + await writer.drain() + pending = 0 + except (ConnectionError, ConnectionResetError, ConnectionAbortedError): + return + await writer.drain() + except (ConnectionError, ConnectionResetError, ConnectionAbortedError): + return + return allow_reuse + except Exception as e: + if response_started: + return + logger.error(f"直连请求失败 {path}: {e}") + await self._reject(writer, access, 502, get_message('access_reason_direct_failed', self.language, e)) + return + except Exception as e: + if response_started: + return + logger.error(f"直连请求失败 {path}: {e}") + await self._reject(writer, access, 502, get_message('access_reason_direct_failed', self.language, e)) + return + + max_retries = 1 if body is not None else 2 retry_count = 0 last_error = None - + failed_url = None + while retry_count < max_retries: + response_started = False try: - proxy = await self.get_next_proxy() + proxy = await self.get_next_proxy(exclude=failed_url) if not proxy: - writer.write(b'HTTP/1.1 503 Service Unavailable\r\n\r\n') - await writer.drain() + await self._reject(writer, access, 503, get_message('pool_no_proxy', self.language)) return + failed_url = proxy + + access.use_proxy(proxy, real_ip=self._real_ip_for(proxy)) try: - client = await self._get_client(proxy) - - proxy_headers = headers.copy() - proxy_type, proxy_addr = proxy.split('://') - if '@' in proxy_addr: - auth, _ = proxy_addr.split('@') - auth_header = f'Basic {base64.b64encode(auth.encode()).decode()}' - proxy_headers['Proxy-Authorization'] = auth_header + client = await borrowed_clients.enter_async_context( + self._lease_client(proxy)) + + proxy_headers = self._forward_headers(headers, proxy) try: - async with client.stream( - method, - path, - headers=proxy_headers, - content=reader, - timeout=30.0 - ) as response: - writer.write(f'HTTP/1.1 {response.status_code} {response.reason_phrase}\r\n'.encode()) - - for header_name, header_value in response.headers.items(): - if header_name.lower() not in ('transfer-encoding', 'connection'): - writer.write(f'{header_name}: {header_value}\r\n'.encode()) - writer.write(b'\r\n') - + request = _build_upstream_request( + client, method, request_url, proxy_headers, body) + async with _stream_upstream(client, request) as response: + response_started = True + allow_reuse = self._response_allows_reuse(response, method) and reuse_ok + writer.write(self._render_response_head(response, keep_alive=allow_reuse)) + access.succeeded(response.status_code) + self._note_exit_success(proxy) + + body_complete = True try: - async for chunk in response.aiter_bytes(chunk_size=self.buffer_size): + pending = 0 + async for chunk in response.aiter_raw(): if not chunk: break try: writer.write(chunk) - if len(chunk) >= self.buffer_size: + pending += len(chunk) + if pending >= self.buffer_size: await writer.drain() + pending = 0 except (ConnectionError, ConnectionResetError, ConnectionAbortedError): return except Exception: - break + body_complete = False + break await writer.drain() except (ConnectionError, ConnectionResetError, ConnectionAbortedError): - return + return except Exception: - pass - - - return + body_complete = False + + + return allow_reuse and body_complete + + except httpx.RequestError as e: + if response_started: + return + if isinstance(e, _UPSTREAM_TRANSPORT_ERRORS): + await self.handle_proxy_failure(proxy) + else: + logger.error(f"请求未能送达上游(不归因给出口) {method} {request_url}: {e}") - except httpx.RequestError: - - await self.handle_proxy_failure() last_error = "Request Error" retry_count += 1 if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) continue - return - except Exception as e: - if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)): - - await self.handle_proxy_failure() - last_error = str(e) - retry_count += 1 - if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) - continue - return - writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n') - await writer.drain() + await self._reject(writer, access, 502, last_error) return + except Exception as e: + if response_started: + return + if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)): + await self.handle_proxy_failure(proxy) - - except httpx.HTTPError: - - await self.handle_proxy_failure() - last_error = "HTTP Error" - retry_count += 1 - if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) - continue - return - except Exception as e: - if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)): - - await self.handle_proxy_failure() last_error = str(e) retry_count += 1 if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) continue - return - writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n') - await writer.drain() + await self._reject(writer, access, 502, last_error) + return + + + except httpx.HTTPError as e: + if response_started: + return + logger.error(f"请求处理错误(不归因给出口) {method} {request_url}: {e}") + last_error = str(e) + retry_count += 1 + if retry_count < max_retries: + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue + await self._reject(writer, access, 502, last_error) + return + except Exception as e: + if response_started: + return + if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)): + await self.handle_proxy_failure(proxy) + + last_error = str(e) + retry_count += 1 + if retry_count < max_retries: + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) + continue + await self._reject(writer, access, 502, last_error) return except Exception as e: + if response_started: + return if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)): - + await self.handle_proxy_failure() last_error = str(e) retry_count += 1 if retry_count < max_retries: - logging.warning(get_message('request_retry', self.language, max_retries - retry_count)) - await asyncio.sleep(1) + self._log_retry(max_retries - retry_count) + await asyncio.sleep(_RETRY_BACKOFF_SECONDS) continue - return + await self._reject(writer, access, 502, last_error) + return if not isinstance(e, (asyncio.CancelledError,)): - logging.error(f"请求处理错误: {str(e)}") + logger.error(f"请求处理错误 {method} {path}: {str(e)}") try: - writer.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n') - await writer.drain() - except: + await self._reject(writer, access, 502, str(e)) + except Exception: pass return - - #if last_error: - logging.error(get_message('all_retries_failed', self.language, last_error)) - async def _get_client(self, proxy): - async with self.client_pool_lock: - current_time = time.time() - if proxy in self.client_pool: - client, last_used = self.client_pool[proxy] - if current_time - last_used < 30 and not client.is_closed: - self.client_pool[proxy] = (client, current_time) - return client - else: - await client.aclose() - del self.client_pool[proxy] + async def _close_client_for(self, proxy_url): + with self.client_pool_lock: + entry = self.client_pool.pop(proxy_url, None) + if entry is None: + return + client = entry[0] + if self._client_inflight.get(proxy_url, 0) > 0: + self._clients_pending_close.setdefault(proxy_url, []).append(client) + return + if not client.is_closed: + await client.aclose() - try: - client = await self._create_client(proxy) - if len(self.client_pool) >= self.max_pool_size: - oldest_proxy = min(self.client_pool, key=lambda x: self.client_pool[x][1]) - old_client, _ = self.client_pool[oldest_proxy] - await old_client.aclose() - del self.client_pool[oldest_proxy] - - self.client_pool[proxy] = (client, current_time) - return client - except Exception as e: - logging.error(f"创建客户端失败: {str(e)}") - raise + def _lease_client(self, proxy): + return self._borrow_client( + proxy, lambda: self._create_client(proxy), evict=True, + ) - async def handle_proxy_failure(self): - - if not self.check_proxies: - return - - - current_time = time.time() - if current_time - self.last_proxy_failure_time < self.proxy_failure_cooldown: - return - - - if self.switching_proxy: - return - + def _worker_for_failure(self, task, proxy): + lease = self._workers.lease_of(task) + if lease is not None: + return lease, lease.worker + if proxy: + return None, self._workers.find(proxy) + return None, self._workers.find(self.current_proxy) + + async def handle_proxy_failure(self, proxy=None, reason=''): try: - if not self.proxy_failure_lock.locked(): - async with self.proxy_failure_lock: - if (current_time - self.last_proxy_failure_time < self.proxy_failure_cooldown or - self.switching_proxy): - return - - - self.last_proxy_failure_time = current_time - - try: - is_valid = await self.check_current_proxy() - - if not is_valid: - #logging.warning(get_message('proxy_failure', self.language, self.current_proxy)) - await self.switch_proxy() - except Exception as e: - logging.error(get_message('proxy_check_error', self.language, str(e))) + task = asyncio.current_task() + lease, worker = self._worker_for_failure(task, proxy) + if worker is None: + return + + if lease is not None and lease.worker is worker: + outcome = self._workers.release(task, blame=True, reason=reason) + else: + outcome = self._workers.report_failure(worker, reason=reason) + + if outcome is None or outcome == BLAME_IGNORED: + return + + if reason == _REASON_SILENT_TUNNEL: + self._silent_log.warning( + ('silent', worker.url), + get_message( + 'proxy_silent_upstream', self.language, + worker.masked, int(self.tunnel_idle_timeout), + ), + ) + + if outcome != BLAME_RETIRED and worker.suspect: + self._schedule_async_task(self._probe_suspect_worker(worker.url)) + + if outcome != BLAME_RETIRED: + return + + logger.warning(get_message('proxy_worker_retired', self.language, worker.masked)) + self._sync_current_proxy() + await self._close_client_for(worker.url) + self._schedule_async_task(self._probe_retired_worker(worker.url)) except Exception as e: - logging.error(f"代理失败处理出错: {str(e)}") + logger.error(f"代理失败处理出错: {str(e)}") + + async def _probe_suspect_worker(self, proxy): + try: + verdict = await self._check_worker(proxy) + except Exception as e: + logger.error(get_message('proxy_check_error', self.language, str(e))) + return + + if verdict is not False: + return + + worker = self._workers.find(proxy) + if worker is None: + return + + if len(self._workers.roster) <= 1: + async with self._refill_lock: + if not len(self._standby) and self._fetch_allowed(time.time()): + self.last_switch_attempt = time.time() + await self._fetch_into_standby() + replaced = await self._replace_exit(worker) + if replaced: + await self._close_client_for(worker.url) + return + + if not self._workers.drop_worker(proxy, remember=True): + return + logger.warning(get_message('proxy_worker_retired', self.language, worker.masked)) + self._sync_current_proxy() + await self._close_client_for(worker.url) + + async def _probe_retired_worker(self, proxy): + try: + await self._check_worker(proxy) + except Exception as e: + logger.error(get_message('proxy_check_error', self.language, str(e))) async def switch_proxy(self): try: current_time = time.time() - - - if current_time - self.last_switch_attempt < self.switch_cooldown: - return False - - if self.switching_proxy: - return False - - self.switching_proxy = True - self.last_switch_attempt = current_time - old_proxy = self.current_proxy - - temp_current_proxy = self.current_proxy - - await self.get_proxy() - - - if temp_current_proxy != self.current_proxy: - self._log_proxy_switch(old_proxy, self.current_proxy) - - self.last_proxy_failure_time = current_time - return True - - return False - - except Exception as e: - logging.error(get_message('proxy_switch_error', self.language, str(e))) - return False - finally: - - self.switching_proxy = False + return {'switching': True} + + elapsed = current_time - self.last_switch_attempt + if elapsed < self.switch_cooldown: + remaining = round(self.switch_cooldown - elapsed, 1) + logger.info(f"刷新冷却中,剩余 {remaining:.1f} 秒") + return {'cooldown': remaining} + + self._standby.clear() + await self._maintain_exit_pool(force_fetch=True) + async with self._refill_lock: + await self._replace_soonest() + return bool(self.proxies) + + except Exception as e: + logger.error(get_message('proxy_switch_error', self.language, str(e))) + return False + + async def _check_worker(self, proxy): + if not proxy: + return None - async def check_current_proxy(self): try: - proxy = self.current_proxy - if not proxy: - return False - + test_url = self.test_url + cache_key = (proxy, test_url) current_time = time.time() - - if not self.check_proxies: - return True - - - if proxy in self.last_check_time: - if current_time - self.last_check_time[proxy] < self.check_cooldown: - return self.proxy_check_cache.get(proxy, (current_time, True))[1] - - if proxy in self.proxy_check_cache: - cache_time, is_valid = self.proxy_check_cache[proxy] - if current_time - cache_time < self.proxy_check_ttl: - return is_valid + cached = self.proxy_check_cache.get(cache_key) + if cached is not None and current_time - cached[0] < self.proxy_check_ttl: + return cached[1] + last_check = self.last_check_time.get(proxy) + if last_check is not None and current_time - last_check < self.check_cooldown: + return None self.last_check_time[proxy] = current_time - test_url = self.config.get('test_url', 'https://www.baidu.com') - - + try: from modules.modules import check_proxy - + is_valid = await check_proxy(proxy, test_url) - logging.warning(f"代理检查结果: {proxy} - {'有效' if is_valid else '无效'}") except Exception as e: - logging.error(f"代理检测错误: {proxy} - {str(e)}") - is_valid = False - - - self.proxy_check_cache[proxy] = (current_time, is_valid) + logger.error(f"出口检测错误: {sanitize_proxy(proxy)} - {str(e)}") + return None + + logger.info( + f"出口检查结果: {sanitize_proxy(proxy)} - {'有效' if is_valid else '无效'}" + ) + self.proxy_check_cache[cache_key] = (time.time(), is_valid) + self._report_proxy_use_to_pool(proxy, is_valid, test_url) + self._write_back_verdict(proxy, is_valid) return is_valid except Exception as e: - logging.error(f"代理检测异常: {str(e)}") - if 'proxy' in locals(): - self.proxy_check_cache[proxy] = (current_time, False) - return False + logger.error(f"出口检测异常: {str(e)}") + return None - def _clean_proxy_cache(self): - current_time = time.time() - self.proxy_check_cache = { - proxy: (cache_time, is_valid) - for proxy, (cache_time, is_valid) in self.proxy_check_cache.items() - if current_time - cache_time < self.proxy_check_ttl - } - self.last_check_time = { - proxy: check_time - for proxy, check_time in self.last_check_time.items() - if current_time - check_time < self.proxy_check_ttl - } - - def initialize_proxies(self): - if hasattr(self, 'proxies') and self.proxies: - self.proxy_cycle = cycle(self.proxies) - return - - if self.use_getip: - logging.info("API模式,将在请求时动态获取代理") - return - - try: - logging.info(f"从文件 {self.proxy_file} 加载代理列表") - self.proxies = self._load_file_proxies() - logging.info(f"加载到 {len(self.proxies)} 个代理") - - if self.proxies: - self.proxy_cycle = cycle(self.proxies) - self.current_proxy = next(self.proxy_cycle) - logging.info(f"初始代理: {self.current_proxy}") - except Exception as e: - logging.error(f"初始化代理列表失败: {str(e)}") - - async def cleanup_disconnected_ips(self): - while True: - try: - active_ips = {addr[0] for addr in self.active_connections} - self.connected_clients = active_ips - except Exception as e: - logging.error(get_message('cleanup_error', self.language, str(e))) - await asyncio.sleep(30) - - def is_docker(): - return os.path.exists('/.dockerenv') - - async def get_proxy_status(self): - if self.mode == 'loadbalance': - return f"{get_message('current_proxy', self.language)}: {self.current_proxy}" - else: - time_left = self.time_until_next_switch() - if time_left == float('inf'): - return f"{get_message('current_proxy', self.language)}: {self.current_proxy}" - else: - return f"{get_message('current_proxy', self.language)}: {self.current_proxy} | {get_message('next_switch', self.language)}: {time_left:.1f}{get_message('seconds', self.language)}" - - - async def _get_proxy_connection(self, proxy): - if proxy in self.proxy_pool: - conn = self.proxy_pool[proxy] - if not conn.is_closed: - conn._last_used = time.time() - return conn - - proxy_type, proxy_addr = proxy.split('://') - if '@' in proxy_addr: - auth, addr = proxy_addr.split('@') - username, password = auth.split(':') - else: - username = self.username - password = self.password - addr = proxy_addr - - host, port = addr.split(':') - port = int(port) - - if proxy_type in ('socks5', 'socks4'): - conn = await self._create_socks_connection( - host, port, username, password, - proxy_type == 'socks5' - ) - else: - conn = await self._create_http_connection( - host, port, username, password - ) - - if len(self.proxy_pool) < self.max_pool_size: - conn._last_used = time.time() - self.proxy_pool[proxy] = conn - - return conn - - async def _create_socks_connection(self, host, port, username, password, is_socks5): - reader, writer = await asyncio.open_connection( - host, port, - limit=self.buffer_size - ) - - if is_socks5: - writer.write(b'\x05\x02\x00\x02' if username else b'\x05\x01\x00') - await writer.drain() - - version, method = await reader.readexactly(2) - if version != 5: - raise Exception('Invalid SOCKS version') - - if method == 2 and username: - auth = bytes([1, len(username)]) + username.encode() + \ - bytes([len(password)]) + password.encode() - writer.write(auth) - await writer.drain() - - auth_version, status = await reader.readexactly(2) - if status != 0: - raise Exception('Authentication failed') - - return reader, writer - - async def _create_http_connection(self, host, port, username, password): - reader, writer = await asyncio.open_connection( - host, port, - limit=self.buffer_size - ) - - if username: - auth = base64.b64encode(f'{username}:{password}'.encode()).decode() - writer.write(f'Proxy-Authorization: Basic {auth}\r\n'.encode()) - await writer.drain() - - return reader, writer - - async def _cleanup_pool(self): - while True: - try: - - def is_expired(conn): - return hasattr(conn, 'is_closed') and conn.is_closed - - to_remove = [] - for proxy, conn in list(self.proxy_pool.items()): - if is_expired(conn): - to_remove.append(proxy) - - for proxy in to_remove: - if proxy in self.proxy_pool: - del self.proxy_pool[proxy] - except Exception as e: - logging.error(f'连接池清理错误: {e}') - await asyncio.sleep(60) - - def _log_proxy_switch(self, old_proxy, new_proxy): - if old_proxy != new_proxy: - old_proxy = old_proxy if old_proxy else get_message('no_proxy', self.language) - new_proxy = new_proxy if new_proxy else get_message('no_proxy', self.language) - - current_time = time.time() - if not hasattr(self, '_last_log_time') or \ - not hasattr(self, '_last_log_content') or \ - current_time - self._last_log_time > 1 or \ - self._last_log_content != f"{old_proxy} -> {new_proxy}": - logging.info(get_message('proxy_switch', self.language, old_proxy, new_proxy)) - self._last_log_time = current_time - self._last_log_content = f"{old_proxy} -> {new_proxy}" - - async def _validate_proxy(self, proxy): + def _note_exit_success(self, proxy): if not proxy: - return False - - - if not self.check_proxies: - return True - + return try: - if not validate_proxy(proxy): - logging.warning(get_message('proxy_invalid', self.language, proxy)) - return False - - proxy_type, proxy_addr = proxy.split('://') - proxy_auth, proxy_host_port = self._split_proxy_auth(proxy_addr) - proxy_host, proxy_port = proxy_host_port.split(':') - proxy_port = int(proxy_port) - - try: - reader, writer = await asyncio.wait_for( - asyncio.open_connection(proxy_host, proxy_port), - timeout=5 - ) - writer.close() - try: - await writer.wait_closed() - except: - pass - return True - except: - return False - + worker = self._workers.find(proxy) + if worker is None or (worker.failures == 0 and not worker.suspect): + return + self._workers.note_success(worker) except Exception as e: - logging.error(get_message('proxy_check_failed', self.language, proxy, str(e))) - return False + logger.debug(f"记录出口成功时出错(已忽略): {e}") - async def get_proxy(self): + def _write_back_verdict(self, proxy, is_valid): + worker = self._workers.find(proxy) + if worker is None: + return + if is_valid: + self._workers.note_success(worker) + else: + self._workers.report_failure(worker, reason='probe') + + def _split_proxy_address(self, proxy_url): try: - old_proxy = self.current_proxy - temp_current_proxy = self.current_proxy - - if not self.use_getip and self.proxies: - if not self.proxy_cycle: - self.proxy_cycle = cycle(self.proxies) - - for _ in range(3): - new_proxy = next(self.proxy_cycle) - if await self._validate_proxy(new_proxy): - self.current_proxy = new_proxy - self.last_switch_time = time.time() - if temp_current_proxy != self.current_proxy: - self._log_proxy_switch(old_proxy, self.current_proxy) - return self.current_proxy - - logging.error(get_message('no_valid_proxies', self.language)) - return self.current_proxy - - if self.use_getip: - try: - new_proxy = await self._load_getip_proxy() - if new_proxy and await self._validate_proxy(new_proxy): + address = str(proxy_url).split('://', 1)[-1] + _auth, host_port = self._split_proxy_auth(address) + host, port = split_host_port(host_port) + except (ValueError, AttributeError, TypeError): + return None + return (host, int(port)) if host and port else None - self.current_proxy = new_proxy - self.last_switch_time = time.time() - if temp_current_proxy != self.current_proxy: - self._log_proxy_switch(old_proxy, self.current_proxy) - return self.current_proxy - else: - logging.error(get_message('proxy_get_failed', self.language)) - except Exception as e: - logging.error(get_message('proxy_get_error', self.language, str(e))) - - return self.current_proxy - + def _report_proxy_use_to_pool(self, proxy, is_valid, test_url): + if self.proxy_source_mode != 'pool' or self.pool_remote_url or self._pool_service is None: + return + + parsed = self._split_proxy_address(proxy) + if parsed is None: + return + + ip, port = parsed + try: + self._schedule_async_task( + self._pool_service.report_proxy_use(ip, port, is_valid, test_url) + ) except Exception as e: - logging.error(get_message('proxy_get_error', self.language, str(e))) - return self.current_proxy + logger.debug(f"使用期反馈调度失败(已忽略): {e}") + + def _log_roster_reload(self, previous_urls): + previous = len(previous_urls) + current = self.proxies + if previous == len(current) and set(previous_urls) == set(current): + return + + samples = ', '.join(sanitize_proxy(url) for url in current[:3]) + if len(current) > 3: + samples += f" ... (+{len(current) - 3})" + if not samples: + samples = get_message('no_proxy', self.language) + logger.info(get_message('proxy_switch', self.language, len(current), previous)) + logger.info(f"当前出口: {samples}") async def cleanup_clients(self): while True: try: - async with self.client_pool_lock: + with self.client_pool_lock: current_time = time.time() - expired_proxies = [ - proxy for proxy, (_, last_used) in self.client_pool.items() - if current_time - last_used > 30 - ] - for proxy in expired_proxies: - client, _ = self.client_pool[proxy] - await client.aclose() - del self.client_pool[proxy] + expired_clients = [] + for proxy, (client, last_used) in list(self.client_pool.items()): + if self._client_inflight.get(proxy, 0) > 0: + continue + if current_time - last_used > self.client_idle_timeout: + expired_clients.append(client) + del self.client_pool[proxy] + for client in expired_clients: + await client.aclose() + for cache_key in list(self.proxy_check_cache.keys()): + cached_at = self.proxy_check_cache[cache_key][0] + if current_time - cached_at > self.proxy_check_ttl: + self.proxy_check_cache.pop(cache_key, None) + live = {proxy for proxy, _test_url in self.proxy_check_cache} + for proxy in list(self.last_check_time.keys()): + if proxy not in live: + self.last_check_time.pop(proxy, None) except Exception as e: - logging.error(f"清理客户端池错误: {str(e)}") + logger.error(f"清理客户端池错误: {str(e)}") await asyncio.sleep(30) - def get_active_connections(self): - active = [] - for task in self.tasks: - if not task.done(): - try: - coro = task.get_coro() - if coro.__qualname__.startswith('AsyncProxyServer.handle_client'): - writer = coro.cr_frame.f_locals.get('writer') - if writer: - peername = writer.get_extra_info('peername') - if peername: - active.append(peername) - except Exception: - continue - return active diff --git a/modules/spool_store.py b/modules/spool_store.py new file mode 100644 index 0000000..ce74de6 --- /dev/null +++ b/modules/spool_store.py @@ -0,0 +1,124 @@ +""" +模块名称:modules.spool_store +功能描述:为「内存缓冲 + 后台线程批量落 SQLite」的存储提供共用骨架:热路径只写内存, + 后台线程按 flush_interval 定时把攒下的一批写进库,并管理 flush/clear/stop。 +职责边界:负责:flush/clear/stop 骨架、后台循环、清空与在途落库的竞态门(generation)、 + 批量落库的执行顺序、clear 的整表删除语句、写入失败时调用回退钩子的编排、 + 连接获取与建表钩子的调用时机;不负责:内存数据结构、批量写入 SQL 与回退钩子 + 的实现、过期清理与读路径,均由 modules.domain_stats / modules.access_records 子类实现。 +关键依赖:仅标准库 sqlite3、threading、os、contextlib、logging;无第三方组件。 +已知限制: + 1. 子类须实现 _ensure_schema、_take_batch、_write_batch、_restore_batch、_clear_memory、_purge_expired 钩子。 + 2. _take_batch 须在 _lock 内取批并返回 (batch, generation),无数据时返回 falsy(None 或空容器),generation 原样带回。 + 3. flush_interval 与 start() 由子类提供,_init_flush_state 不设默认值;stop() 未启动时为空操作。 + 4. 锁顺序固定为 _db_lock → _lock,_lock 只保护内存结构,落库 I/O 不得占着它执行。 + 5. clear() 自增 generation 作废已取出未落库的那批;该批被丢弃且不回调 _restore_batch。 + 6. stop() 只等后台线程 5 秒,最终 flush 失败仅记日志、不再抛出。 +""" + +import logging +import os +import sqlite3 +import threading +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + + +class FlushBackedSqliteStore: + _table = '' + _log_label = '记录' + + def _init_flush_state(self, db_path: str) -> None: + self.db_path = db_path + self._lock = threading.Lock() + self._db_lock = threading.Lock() + self._flush_generation = 0 + self._thread = None + self._stop_event = threading.Event() + self._started = False + + def stop(self) -> None: + if not self._started: + return + self._started = False + self._stop_event.set() + thread, self._thread = self._thread, None + if thread is not None and thread.is_alive(): + thread.join(timeout=5) + try: + self.flush() + except Exception as e: + logger.error("%s最终落库失败: %s", self._log_label, e) + + def flush(self) -> int: + batch, generation = self._take_batch() + if not batch: + return 0 + + with self._db_lock: + if generation != self._flush_generation: + return 0 + if not self._write_batch(batch): + self._restore_batch(batch) + return 0 + self._on_write_succeeded(batch) + + self._after_flush() + return len(batch) + + def clear(self) -> int: + with self._db_lock: + try: + with self._session() as conn: + cursor = conn.execute(f"DELETE FROM {self._table}") + except (sqlite3.Error, OSError) as e: + logger.error("清空%s数据库失败: %s", self._log_label, e) + return 0 + + with self._lock: + self._flush_generation += 1 + return self._clear_memory(cursor.rowcount) + + def _flush_loop(self) -> None: + while not self._stop_event.wait(self.flush_interval): + try: + self.flush() + self._purge_expired() + except Exception as e: + logger.error("%s后台落库异常: %s", self._log_label, e) + + @contextmanager + def _session(self): + os.makedirs(os.path.dirname(self.db_path), exist_ok=True) + conn = sqlite3.connect(self.db_path, timeout=10) + try: + self._ensure_schema(conn) + with conn: + yield conn + finally: + conn.close() + + def _ensure_schema(self, conn): + raise NotImplementedError + + def _take_batch(self): + raise NotImplementedError + + def _write_batch(self, batch) -> bool: + raise NotImplementedError + + def _restore_batch(self, batch) -> None: + raise NotImplementedError + + def _clear_memory(self, deleted_rows: int) -> int: + raise NotImplementedError + + def _purge_expired(self) -> None: + raise NotImplementedError + + def _on_write_succeeded(self, batch) -> None: + pass + + def _after_flush(self) -> None: + pass diff --git a/modules/version_check.py b/modules/version_check.py new file mode 100644 index 0000000..7c55e12 --- /dev/null +++ b/modules/version_check.py @@ -0,0 +1,281 @@ +""" +模块名称:modules.version_check +功能描述:版本检查的调度与状态持久化:决定何时比对版本、结果放在哪、给谁读。启动后由后台 + 线程立即做一次到期判定,之后每 24 小时至多请求一次;时间与结果落盘,内存中缓存结果。 +职责边界:负责:到期判定、发起远端请求并解析版本号、持久化最近一次检查的时间与结果、 + 在内存中缓存结果供只读查询;不负责:HTTP 路由(见 app)、控制台输出与用户 + 可见文案渲染(入口文件与 modules.modules.get_message 负责),本模块只回调。 +关键依赖:modules.modules(CURRENT_VERSION、get_message、read_config_text、 + write_text_atomic)、httpx、packaging。 +已知限制: + 1. 内存时间戳是准绳,状态文件只是尽力而为的写穿:写盘失败只记日志,不影响 24 小时内不再检查的判定。 + 2. 检查失败时保留上一次成功的版本号,result_payload() 仍返回成功形态,失败只体现在日志。 + 3. 时钟回拨时时间戳落到未来,按到期处理,检查一次即以新时间自愈;tick 只用 Event.wait,不按剩余时间计算睡眠。 + 4. 远端请求在锁外发起,_in_flight 只防同进程重入,多进程共用状态文件时仍可能各查一次。 + 5. write_text_atomic 的临时文件名为 .config-*.tmp,进程被强杀时可能在状态文件目录留下残留。 +""" + +import json +import logging +import os +import re +import threading +import time +from datetime import datetime + +import httpx +from packaging import version + +from modules.modules import ( + CURRENT_VERSION, + get_message, + read_config_text, + write_text_atomic, +) + +logger = logging.getLogger(__name__) + +VERSION_URL = "https://y.shironekosan.cn/1.html" + +CHECK_INTERVAL_SECONDS = 24 * 60 * 60 + +CHECK_TICK_SECONDS = 600.0 + +REQUEST_TIMEOUT_SECONDS = 10.0 + +_STATE_TIME_FORMAT = "%Y-%m-%d %H:%M:%S" + +_VERSION_PATTERN = re.compile(r'

(ProxyCat-V\d+\.\d+\.\d+)

') + + +class VersionChecker: + def __init__(self, state_path, language_provider=None, on_result=None): + self.state_path = str(state_path) + self._language_provider = language_provider + self._on_result = on_result + + self._lock = threading.Lock() + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._started = False + self._loaded = False + + self._checked_at: float | None = None + self._latest_version: str | None = None + self._error_key: str | None = None + self._error_args: list = [] + self._in_flight = False + + def start(self, blocking: bool = False) -> bool: + with self._lock: + if self._started: + return False + self._started = True + self._ensure_loaded_locked() + due = self._due_locked() + + logging.getLogger('httpx').setLevel(logging.WARNING) + + if blocking and due: + self.check_if_due() + + self._thread = threading.Thread( + target=self._tick_loop, name="version-check", daemon=True + ) + self._thread.start() + return due + + def stop(self, wait: bool = False) -> None: + if not self._started: + return + self._stop_event.set() + thread = self._thread + if wait and thread is not None and thread.is_alive(): + thread.join(timeout=5) + + def check_if_due(self, notify: bool = True) -> bool: + with self._lock: + self._ensure_loaded_locked() + if not self._due_locked() or self._in_flight: + return False + self._in_flight = True + + try: + started_at = time.time() + latest_version, error_key, error_args = self._fetch_latest() + finally: + with self._lock: + self._in_flight = False + + notify_payload = None + with self._lock: + self._checked_at = started_at + if latest_version: + self._latest_version = latest_version + self._error_key = None + self._error_args = [] + elif error_key: + self._error_key = error_key + self._error_args = error_args + + self._persist_locked() + + if notify and self._on_result is not None: + notify_payload = self._payload_locked() + + if notify_payload is not None: + try: + self._on_result(notify_payload) + except Exception as e: + logger.warning(f"版本检查的结果回调执行失败(已忽略): {e}") + return True + + def _fetch_latest(self) -> tuple: + latest_version = None + error_key = None + error_args: list = [] + try: + with httpx.Client(transport=httpx.HTTPTransport(retries=3)) as client: + response = client.get(VERSION_URL, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + match = _VERSION_PATTERN.search(response.text) + + if match: + latest_version = match.group(1) + else: + error_key = 'version_info_not_found' + except Exception as e: + error_key = 'update_check_error' + error_args = [str(e)] + logger.warning(f"版本检查失败(本次已记账,24 小时内不再重试): {e}") + return latest_version, error_key, error_args + + def result_payload(self) -> dict: + with self._lock: + self._ensure_loaded_locked() + return self._payload_locked() + + def _language(self) -> str: + if self._language_provider is None: + return 'cn' + try: + return self._language_provider() or 'cn' + except Exception: + return 'cn' + + def _payload_locked(self) -> dict: + language = self._language() + if self._latest_version: + return { + 'status': 'success', + 'is_latest': _not_newer(self._latest_version, CURRENT_VERSION), + 'current_version': CURRENT_VERSION, + 'latest_version': self._latest_version, + } + if self._error_key: + return { + 'status': 'error', + 'message': get_message(self._error_key, language, *self._error_args), + } + return { + 'status': 'error', + 'message': get_message('version_not_checked', language), + } + + def _tick_loop(self) -> None: + while True: + try: + self.check_if_due() + except Exception as e: + logger.warning(f"版本检查的后台轮询出错(已忽略,下个周期继续): {e}") + if self._stop_event.wait(CHECK_TICK_SECONDS): + return + + def _due_locked(self) -> bool: + if self._checked_at is None: + return True + elapsed = time.time() - self._checked_at + if elapsed < 0: + return True + return elapsed >= CHECK_INTERVAL_SECONDS + + def _persist_locked(self) -> None: + payload = { + 'checked_at': self._checked_at, + 'checked_at_text': datetime.fromtimestamp(self._checked_at).strftime( + _STATE_TIME_FORMAT + ), + 'current_version': CURRENT_VERSION, + 'latest_version': self._latest_version, + 'error_key': self._error_key, + 'error_args': self._error_args, + } + try: + directory = os.path.dirname(os.path.abspath(self.state_path)) + os.makedirs(directory, exist_ok=True) + write_text_atomic( + self.state_path, + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + ) + except OSError as e: + logger.warning( + f"版本检查状态写入 {self.state_path} 失败(本次结果仍在本进程内生效): {e}" + ) + + def _ensure_loaded_locked(self) -> None: + if self._loaded: + return + self._loaded = True + self._load_state() + + def _load_state(self) -> None: + try: + raw = json.loads(read_config_text(self.state_path)) + except FileNotFoundError: + return + except (OSError, ValueError, UnicodeDecodeError) as e: + logger.warning(f"版本检查状态文件不可用,按未检查处理: {e}") + return + + if not isinstance(raw, dict): + logger.warning("版本检查状态文件不是对象,按未检查处理") + return + + checked_at = raw.get('checked_at') + if not isinstance(checked_at, (int, float)) or isinstance(checked_at, bool): + logger.warning("版本检查状态文件缺少可用的 checked_at,按未检查处理") + return + + if raw.get('current_version') != CURRENT_VERSION: + logger.info( + f"版本已从 {raw.get('current_version')} 变为 {CURRENT_VERSION}," + "立即重新检查" + ) + return + + latest_version = raw.get('latest_version') + error_key = raw.get('error_key') + error_args = raw.get('error_args') + + self._checked_at = float(checked_at) + self._latest_version = latest_version if isinstance(latest_version, str) else None + self._error_key = error_key if isinstance(error_key, str) else None + self._error_args = list(error_args) if isinstance(error_args, list) else [] + + +def _not_newer(latest_version: str, current_version: str) -> bool: + try: + return ( + version.parse(latest_version.split('-V')[1]) + <= version.parse(current_version.split('-V')[1]) + ) + except (IndexError, ValueError): + return True + + +__all__ = [ + "VersionChecker", + "VERSION_URL", + "CHECK_INTERVAL_SECONDS", + "CHECK_TICK_SECONDS", +] diff --git a/requirements.txt b/requirements.txt index d8cad49..e998c08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,30 @@ -colorama==0.4.6 -httpx==0.27.2 -httpx[http2,socks]==0.27.2 -packaging==24.1 -Requests==2.32.3 -tqdm>=4.65.0 +# ProxyCat 运行依赖(含内置的代理池模块)。 +# 需要 Python 3.10 及以上:源码大量使用运行时求值的 X | Y 联合类型注解(未启用 +# from __future__ import annotations),3.9 及更早版本在导入阶段就抛 TypeError。 + flask>=2.0.1 werkzeug>=2.0.0 -asyncio>=3.4.3 -configparser>=5.0.0 \ No newline at end of file +waitress>=3.0.0 +jinja2>=3.1.0 + +# socks extra 不可省:非 CONNECT 的明文 HTTP 转发走 httpx 的代理支持, +# 上游是 socks5 时缺 socksio 会直接 502(CONNECT 隧道是手写握手,不受影响) +httpx[http2,socks]==0.27.2 +requests==2.32.3 +colorama==0.4.6 + +aiosqlite>=0.19.0 +aiohttp>=3.9.0 +beautifulsoup4>=4.12.0 +# aiohttp 自身不支持 SOCKS(socks5:// 会被当成 HTTP 代理去连,必然失败)。 +# 缺了这个依赖,验证器会把可用的 socks5 代理全判成失效。 +aiohttp-socks>=0.8.0 + +maxminddb>=2.6.0 +ipip-ipdb>=0.1.1 + +configparser>=5.0.0 +pyyaml>=6.0 + +packaging==24.1 +tqdm>=4.65.0 diff --git a/web/static/css/animations.css b/web/static/css/animations.css deleted file mode 100644 index dacc4ec..0000000 --- a/web/static/css/animations.css +++ /dev/null @@ -1,25 +0,0 @@ -@keyframes slideIn { - from { transform: translateY(-20px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -@keyframes pulse { - 0% { transform: scale(1); } - 50% { transform: scale(1.05); } - 100% { transform: scale(1); } -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.fade-in { - animation: fadeInUp 0.3s ease; -} \ No newline at end of file diff --git a/web/static/css/base.css b/web/static/css/base.css deleted file mode 100644 index c784fee..0000000 --- a/web/static/css/base.css +++ /dev/null @@ -1,48 +0,0 @@ -:root { - --primary-color: #3498db; - --secondary-color: #2ecc71; - --warning-color: #f1c40f; - --danger-color: #e74c3c; - --background-color: #f5f6fa; - --text-color: #2c3e50; - --border-color: #dcdde1; - --card-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); -} - -body { - background-color: var(--background-color); - color: var(--text-color); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - line-height: 1.6; - margin: 0; - padding: 20px; -} - -.container { - max-width: 1200px; - margin: 0 auto; - padding: 20px; -} - -.card { - background: #fff; - border-radius: 12px; - box-shadow: var(--card-shadow); - margin-bottom: 20px; - padding: 20px; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); -} - -.section-title { - color: var(--text-color); - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 1.5rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid var(--border-color); -} \ No newline at end of file diff --git a/web/static/css/bootstrap.min.css b/web/static/css/bootstrap.min.css deleted file mode 100644 index 2fbee7e..0000000 --- a/web/static/css/bootstrap.min.css +++ /dev/null @@ -1,7 +0,0 @@ -@charset "UTF-8";/*! - * Bootstrap v5.1.3 (https://getbootstrap.com/) - * Copyright 2011-2021 The Bootstrap Authors - * Copyright 2011-2021 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:first-child){border-top:2px solid currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.2rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.3rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} -/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/web/static/css/buttons.css b/web/static/css/buttons.css deleted file mode 100644 index f119dfe..0000000 --- a/web/static/css/buttons.css +++ /dev/null @@ -1,45 +0,0 @@ -.btn { - border: none; - border-radius: 8px; - padding: 10px 20px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -.btn i { - font-size: 1.1em; -} - -.btn-primary { - background-color: var(--primary-color); - color: white; -} - -.btn-success { - background-color: var(--secondary-color); - color: white; -} - -.btn-warning { - background-color: var(--warning-color); - color: white; -} - -.btn-danger { - background-color: var(--danger-color); - color: white; -} - -.btn:hover { - transform: translateY(-1px); - filter: brightness(1.1); -} - -.btn:active { - transform: translateY(1px); -} \ No newline at end of file diff --git a/web/static/css/dark-mode.css b/web/static/css/dark-mode.css deleted file mode 100644 index cd9b4c6..0000000 --- a/web/static/css/dark-mode.css +++ /dev/null @@ -1,61 +0,0 @@ -@media (prefers-color-scheme: dark) { - :root { - --background-color: #1a1a1a; - --text-color: #ffffff; - --border-color: #2d2d2d; - --card-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); - } - - body { - background-color: var(--background-color); - color: var(--text-color); - } - - .card { - background: #2d2d2d; - } - - .status-card { - background: linear-gradient(145deg, #2d2d2d, #252525); - } - - .status-card .card-title { - color: #e0e0e0; - } - - .status-card .card-value { - color: #3498db; - } - - .status-card .card-footer { - color: #888; - } - - .btn { - border: 1px solid rgba(255, 255, 255, 0.1); - } - - input, select, textarea { - background-color: #2d2d2d; - border-color: #3d3d3d; - color: #ffffff; - } - - input:focus, select:focus, textarea:focus { - border-color: var(--primary-color); - box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); - } - - .progress { - background: rgba(255, 255, 255, 0.1); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); - } - - .progress-bar { - background: linear-gradient(45deg, #64b5f6, #4a90e2); - } - - .card:hover { - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); - } -} \ No newline at end of file diff --git a/web/static/css/forms.css b/web/static/css/forms.css deleted file mode 100644 index ea7cd45..0000000 --- a/web/static/css/forms.css +++ /dev/null @@ -1,210 +0,0 @@ -.form-control, .form-select { - border-radius: 8px; - border: 2px solid #e9ecef; - padding: 10px 15px; - transition: all 0.3s ease; -} - -.form-control:focus, .form-select:focus { - border-color: #4a90e2; - box-shadow: 0 0 0 0.2rem rgba(74, 144, 226, 0.25); -} - -.table { - background: white; - border-radius: 10px; - overflow: hidden; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.05); -} - -.table thead th { - background: #f8f9fa; - border-bottom: 2px solid #dee2e6; - color: #2c3e50; - font-weight: 600; - padding: 15px; -} - -.table tbody td { - padding: 12px 15px; - vertical-align: middle; - border-bottom: 1px solid #eee; -} - -@media (prefers-color-scheme: dark) { - .form-control, .form-select { - background-color: #2d2d2d; - color: #ffffff; - border-color: #3d3d3d; - } - - .table { - background-color: #2d2d2d; - color: #ffffff; - } - - .table thead th { - background-color: #3d3d3d; - color: #ffffff; - } -} - -/* 浮动保存按钮样式 */ -.floating-save-container { - position: fixed; - bottom: 30px; - right: 30px; - z-index: 1000; -} - -.floating-save-btn { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 24px; - border-radius: 30px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - transition: all 0.3s ease; - background: linear-gradient(135deg, #4a90e2, #357abd); -} - -.floating-save-btn:hover { - transform: translateY(-2px); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); -} - -.floating-save-btn:active { - transform: translateY(1px); -} - -.floating-save-btn i { - font-size: 1.1em; -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .floating-save-btn { - background: linear-gradient(135deg, #2980b9, #2c3e50); - } -} - -/* 移动端适配 */ -@media (max-width: 768px) { - .floating-save-container { - bottom: 20px; - right: 20px; - } - - .floating-save-btn { - padding: 10px 20px; - } -} - -/* Toast 样式优化 */ -.toast { - position: fixed; - top: 20px; - right: 20px; - min-width: 300px; - padding: 16px 20px; - border-radius: 12px; - display: flex; - align-items: center; - gap: 12px; - color: white; - transform: translateX(120%); - opacity: 0; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 1100; - font-weight: 500; -} - -.toast.show { - transform: translateX(0); - opacity: 1; -} - -/* 优化不同类型的 Toast 样式 */ -.toast-success { - background: linear-gradient(135deg, #00b09b, #96c93d); - border-left: 4px solid #96c93d; -} - -.toast-error { - background: linear-gradient(135deg, #ff5f6d, #ffc371); - border-left: 4px solid #ff5f6d; -} - -.toast-info { - background: linear-gradient(135deg, #2193b0, #6dd5ed); - border-left: 4px solid #2193b0; -} - -.toast-warning { - background: linear-gradient(135deg, #f2994a, #f2c94c); - border-left: 4px solid #f2994a; -} - -/* Toast 图标样式 */ -.toast i { - font-size: 1.4em; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -/* Toast 堆叠效果 */ -.toast + .toast { - margin-top: 16px; -} - -/* Toast 文本样式 */ -.toast span { - flex: 1; - font-size: 14px; - line-height: 1.4; -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .toast { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - } - - .toast-success { - background: linear-gradient(135deg, #004d40, #1b5e20); - border-left: 4px solid #00c853; - } - - .toast-error { - background: linear-gradient(135deg, #b71c1c, #c62828); - border-left: 4px solid #ff1744; - } - - .toast-info { - background: linear-gradient(135deg, #0d47a1, #1976d2); - border-left: 4px solid #2196f3; - } - - .toast-warning { - background: linear-gradient(135deg, #e65100, #f57c00); - border-left: 4px solid #ffd600; - } -} - -/* 移动端适配 */ -@media (max-width: 768px) { - .toast { - min-width: auto; - width: calc(100% - 32px); - margin: 0 16px; - padding: 12px 16px; - } - - .toast + .toast { - margin-top: 12px; - } -} \ No newline at end of file diff --git a/web/static/css/logs.css b/web/static/css/logs.css deleted file mode 100644 index 0da2510..0000000 --- a/web/static/css/logs.css +++ /dev/null @@ -1,107 +0,0 @@ -.log-container { - height: 500px; - position: relative; - margin: 1rem 0; -} - -.log-INFO { - background-color: #e8f5e9; - color: #1b5e20; - border-left: 6px solid #2e7d32; - font-size: 17px; - font-weight: 600; -} - -.log-WARNING { - background-color: #fff3e0; - color: #e65100; - border-left: 6px solid #f57c00; - font-size: 17px; - font-weight: 700; -} - -.log-ERROR { - background-color: #ffebee; - color: #b71c1c; - border-left: 6px solid #c62828; - font-size: 18px; - font-weight: 800; -} - -.log-CRITICAL { - background-color: #ffebee; - color: #7f0000; - border-left: 6px solid #b71c1c; - font-size: 18px; - font-weight: 900; - text-transform: uppercase; -} - -.log-message { - font-weight: 600; - line-height: 1.5; -} - -/* 搜索高亮样式 */ -.log-highlight { - background-color: #fff176; - color: #000000; - padding: 2px 4px; - border-radius: 3px; - font-weight: bold; - margin: 0 2px; -} - -/* 暗色模式下的高亮样式 */ -@media (prefers-color-scheme: dark) { - .log-highlight { - background-color: #ffd600; - color: #000000; - } - - .log-INFO { - background-color: rgba(232, 245, 233, 0.1); - color: #81c784; - border-left-color: #2e7d32; - } - - .log-WARNING { - background-color: rgba(255, 243, 224, 0.1); - color: #ffb74d; - border-left-color: #f57c00; - } - - .log-ERROR { - background-color: rgba(255, 235, 238, 0.1); - color: #e57373; - border-left-color: #c62828; - } - - .log-CRITICAL { - background-color: rgba(255, 235, 238, 0.1); - color: #ef5350; - border-left-color: #b71c1c; - } -} - -/* 日志条目样式 */ -.log-entry { - padding: 8px 12px; - margin-bottom: 4px; - border-radius: 4px; - transition: background-color 0.2s ease; -} - -.log-entry:hover { - filter: brightness(0.95); -} - -.log-time { - font-family: monospace; - opacity: 0.8; -} - -.log-level { - font-weight: bold; - margin: 0 4px; -} \ No newline at end of file diff --git a/web/static/css/main.css b/web/static/css/main.css new file mode 100644 index 0000000..e5c46ca --- /dev/null +++ b/web/static/css/main.css @@ -0,0 +1,2062 @@ +/* +模块名称:面板基础样式(main.css) +功能描述:定义面板全局设计系统与跨页共用组件:主题变量与页面底纹、应用外壳、 + 全局反馈(按钮三态、加载条、失联横幅、任务坞、通知中心)、浮层与常用组件。 +职责边界:负责:跨页通用的基础样式与组件;不负责:代理池专有的概览卡、代理表格、 + 插件表与状态徽标(由 pool.css 负责),以及各页签业务逻辑(由 app.js、pool.js 负责)。 +关键依赖:无第三方样式库;字体走系统字体栈,Font Awesome 由 index.html 引入; + 主题与显隐状态:html[data-theme] 由 app.js 与 index.html 头部内联脚本写;body[data-tab] 由 app.js 写; + body[data-density]、body.batch-bar-open 由 proxies.js 写; + .show 类由 app.js、pool.js、tables.js、palette.js 增删。 +已知限制: + 1. 颜色走 :root 与 [data-theme="dark"] 两套变量;写死色值仅限日志终端配色、协议标签色、看图遮罩、白色滑块与半透明阴影。 + 2. 内容链路(.content 到 .view-wrap)不得出现 overflow 非 visible,否则吸顶元素失效。 + 3. .table-scroll 与 .domain-table-wrap 自带横向滚动,表头吸顶依赖其限高,勿去。 + 4. z-index 是跨文件阶梯,改任一处需整条检查;10 档在本文件 .cfg-actions.sticky。 + 5. 下拉框外观分默认与 @supports (appearance: base-select) 两处,改箭头需同步。 + 6. pool.css 在本文件之后加载,同特异度下其规则胜出,勿把池页面的覆盖规则搬进来。 + 7. .cfg-actions 禁用态靠 :not(.done):not(.failed) 排除已完成按钮,省掉会压掉 .btn.done 的绿勾。 + 8. 768px 块内字段行覆盖选择器须与基础规则同形(带 :not(.settings-note)),否则覆盖不掉。 +*/ + +:root { + color-scheme: light; + + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", + "Noto Sans CJK SC", "Source Han Sans SC", Arial, sans-serif; + --font-mono: ui-monospace, "Cascadia Mono", "SF Mono", SFMono-Regular, + Menlo, Consolas, "Liberation Mono", monospace; + + --settings-label-w: 152px; + --settings-max: 1000px; + --field-num-w: 200px; + --bg: #f2f3fa; + --wash: rgba(79,140,247,.08); + --surface: #ffffff; + --surface-hv: #f5f6fc; + --surface-alt: #f8f9fd; + --border: #e5e7f1; + --border-light: #eef0f7; + --field-bg: #f6f7fc; + + --text: #16192b; + --text-soft: #5a6178; + --text-faint: #646a86; + --text-inv: #ffffff; + + --primary: #4f8cf7; + --primary-hv: #3b76e0; + --primary-sub: #eef4ff; + --primary-glow: rgba(79,140,247,0.18); + --primary-grad: linear-gradient(135deg, #2e68d6 0%, #285cce 100%); + + --primary-text: #2f6ad9; + --success-text: #047857; + --warn-text: #b45309; + --danger-text: #c62828; + --info-text: #1d4ed8; + + --success: #10b981; + --success-hv: #059669; + --success-sub: #ecfdf5; + --success-fill: #047857; + + --danger: #ef4444; + --danger-hv: #dc2626; + --danger-sub: #fef2f2; + --danger-fill: #b91c1c; + + --warn: #f59e0b; + --warn-sub: #fffbeb; + + --info-sub: #eff6ff; + + --scrim: rgba(20,24,45,.45); + --skeleton: #eceef6; + --skeleton-sheen: rgba(255,255,255,.75); + + --r-xs: 8px; + --r-sm: 10px; + --r-md: 16px; + --r-lg: 20px; + + --shadow-card: 0 1px 2px rgba(20,24,45,.04), 0 1px 6px rgba(20,24,45,.03); + --shadow-float: 0 8px 24px rgba(20,24,45,.08), 0 2px 6px rgba(20,24,45,.04); + --shadow-overlay: 0 20px 48px rgba(20,24,45,.16); + --shadow-glow: 0 0 0 3px var(--primary-glow); + + --sidebar-w: 248px; + --content-max: 1560px; + --topbar-h: 56px; + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; + --sp-4: 16px; --sp-5: 24px; --sp-6: 32px; + --fs-xs: 11px; --fs-sm: 12px; --fs-md: 13px; + --fs-lg: 15px; --fs-xl: 18px; --fs-2xl: 24px; + + --sb-track: #eef0f6; + --sb-thumb: #c9cddd; + + --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23939ab3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); + + --ease: cubic-bezier(.4,0,.2,1); + --fast: 140ms; + --norm: 220ms; +} + +[data-theme="dark"] { + color-scheme: dark; + + --bg: #0c0e17; + --wash: rgba(106,164,251,.10); + --surface: #171a24; + --surface-hv: #1e212c; + --surface-alt: #1b1e29; + --border: #282b3a; + --border-light: #212433; + --field-bg: #1e212c; + + --text: #e9ebf2; + --text-soft: #aab1c6; + --text-faint: #8a92ab; + --text-inv: #0c0e16; + + --primary: #6aa4fb; + --primary-hv: #8bbaff; + --primary-sub: #16233a; + --primary-glow: rgba(106,164,251,0.28); + --primary-grad: linear-gradient(135deg, #2e68d6 0%, #285cce 100%); + + --primary-text: #6aa4fb; + --success-text: #34d399; + --warn-text: #fbbf24; + --danger-text: #f87171; + --info-text: #60a5fa; + + --success-fill: #10b981; + --danger-fill: #ef4444; + + --success-sub: #0a2e20; + --danger-sub: #2e1018; + --warn-sub: #2e2208; + --info-sub: #172040; + + --shadow-card: 0 1px 3px rgba(0,0,0,.3); + --shadow-float: 0 10px 28px rgba(0,0,0,.42); + --shadow-overlay: 0 22px 56px rgba(0,0,0,.5); + + --scrim: rgba(0,0,0,.6); + --skeleton: #222634; + --skeleton-sheen: rgba(255,255,255,.05); + + --chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%238a92ab' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); + + --sb-track: #14161f; + --sb-thumb: #383c52; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + +::selection { background: var(--primary-sub); color: var(--text); } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + transition: background var(--norm) var(--ease), color var(--norm) var(--ease); + min-height: 100vh; +} + +body::before { + content: ''; + position: fixed; inset: 0; z-index: -1; + background: + radial-gradient(780px 380px at 16% -12%, var(--wash), transparent 68%), + radial-gradient(680px 340px at 88% -16%, var(--wash), transparent 70%); + pointer-events: none; +} + +.app-shell { min-height: 100vh; } + +.sidebar { + position: fixed; inset: 0 auto 0 0; z-index: 970; + width: var(--sidebar-w); flex-shrink: 0; + display: flex; flex-direction: column; gap: 14px; + padding: 18px 14px 14px; + background: var(--surface); border-right: 1px solid var(--border); + overflow-y: auto; +} + +.sb-brand { display: flex; flex-direction: column; gap: 6px; padding: 0 6px; } +.sb-title { font-size: 15px; font-weight: 750; letter-spacing: -.01em; color: var(--text); } +.sb-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.hdr-ver { + font-size: 11px; font-weight: 700; color: var(--text-faint); + background: var(--surface-alt); border: 1px solid var(--border); + padding: 2px 9px; border-radius: 100px; +} +.hdr-ver-stat { font-size: 10px; font-weight: 650; color: var(--text-faint); } +.ver-ok { color: var(--success-text); } +.ver-new { color: var(--warn-text); } + +.sb-nav { display: flex; flex-direction: column; gap: 2px; } +.sb-item { + display: flex; align-items: center; gap: 10px; + width: 100%; text-align: left; + padding: 9px 11px; border: none; border-radius: var(--r-xs); + background: transparent; font-family: inherit; + font-size: 13px; font-weight: 600; color: var(--text-soft); + cursor: pointer; + transition: background var(--fast) var(--ease), color var(--fast) var(--ease); +} +.sb-item:hover { background: var(--surface-hv); color: var(--text); } +.sb-item.cur { background: var(--primary-sub); color: var(--primary-text); } +.sb-icon { width: 16px; text-align: center; font-size: 13px; flex-shrink: 0; opacity: .75; } +.sb-parent::after { + content: '\f107'; font-family: FontAwesome; margin-left: auto; + font-size: 12px; color: var(--text-faint); opacity: .8; + transition: transform var(--fast) var(--ease); +} +body[data-tab="tab-pool"] .sb-parent::after { transform: rotate(180deg); } +.sb-item.cur .sb-icon { opacity: 1; } + +.sb-sub { + display: none; flex-direction: column; gap: 1px; + margin: 2px 0 2px 21px; padding-left: 9px; + border-left: 1px solid var(--border); +} +body[data-tab="tab-pool"] .sb-sub { display: flex; } +.sb-child { font-size: 12.5px; font-weight: 550; padding: 6px 10px; color: var(--text-faint); } +.sb-child.cur { background: var(--primary-sub); color: var(--primary-text); font-weight: 650; } + +.sb-badge { + margin-left: auto; font-size: 10px; font-weight: 700; line-height: 1.6; + background: var(--primary-sub); color: var(--primary-text); padding: 1px 7px; border-radius: 100px; +} + +.sb-foot { + margin-top: auto; display: flex; flex-direction: column; gap: 8px; + border-top: 1px solid var(--border); padding-top: 12px; +} +.sb-status { display: flex; align-items: center; gap: 7px; padding: 0 6px; font-size: 12px; } +.sb-status .dot { width: 8px; height: 8px; } +.sb-status-text { font-weight: 650; color: var(--text); } +.sb-status-label { margin-left: auto; font-size: 11px; color: var(--text-faint); } + +.sb-tools { display: flex; flex-wrap: wrap; gap: 6px; } +.sb-tool { + display: inline-flex; align-items: center; gap: 5px; + padding: 4px 11px; border: 1px solid var(--border); border-radius: 100px; + background: var(--surface); color: var(--text-soft); + font-family: inherit; font-size: 11px; font-weight: 600; + text-decoration: none; cursor: pointer; + transition: all var(--fast) var(--ease); +} +.sb-tool:hover { border-color: var(--primary-text); color: var(--primary-text); background: var(--primary-sub); } +.sb-tool .fa { font-size: 12px; } + +.sb-scrim { display: none; } + +.content { margin-left: var(--sidebar-w); min-width: 0; } +.mobile-topbar { display: none; } +.content-inner { max-width: var(--content-max); margin: 0 auto; padding: 22px 24px 48px; } + +.view-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; } +.view-title { font-size: 1.15rem; font-weight: 750; letter-spacing: -.015em; color: var(--text); } +.view-sub { font-size: 12px; color: var(--text-faint); } + +.status-bar { display: flex; gap: 10px; margin-bottom: 10px; } +.panel { + flex: 1; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--r-md); + padding: 14px 18px; + box-shadow: var(--shadow-card); + transition: border var(--fast) var(--ease), box-shadow var(--fast) var(--ease); +} +.pnl-hd { + font-size: 10px; font-weight: 700; color: var(--text-faint); + margin-bottom: 8px; letter-spacing: .08em; text-transform: uppercase; +} +.pnl-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +.dot { + width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; position: relative; +} +.dot.on { background: var(--success-fill); } +.dot.on::after { + content: ''; position: absolute; inset: -3px; border-radius: 50%; + background: transparent; border: 2px solid var(--success-fill); + animation: dotPulse 1.8s ease-out infinite; +} +.dot.off { background: var(--danger-fill); } +@keyframes dotPulse { + 0% { transform: scale(1); opacity: .6; } + 100% { transform: scale(2.2); opacity: 0; } +} + +.st-text { font-size: 13px; font-weight: 650; } +.addr-row { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; } +.addr { + font-size: 12px; color: var(--text-soft); + font-family: var(--font-mono); + background: var(--bg); padding: 5px 14px; border-radius: var(--r-xs); + cursor: pointer; user-select: all; + display: inline-flex; align-items: center; gap: 7px; + transition: all var(--fast) var(--ease); + border: 1px solid transparent; +} +.addr:hover { + background: var(--primary-sub); border-color: var(--primary-text); color: var(--primary-text); + box-shadow: var(--shadow-glow); +} +.addr .fa-copy { font-size: 10px; opacity: .35; transition: opacity var(--fast) var(--ease); } +.addr:hover .fa-copy { opacity: .8; } +.addr.copied { background: var(--success-sub); border-color: var(--success-text); color: var(--success-text); } +.addr.copied .fa-copy { opacity: 1; } + + +.st-divider { width: 1px; height: 16px; background: var(--border); margin: 0 4px; flex-shrink: 0; } + + +.pool-view { display: none; } +.pool-view.show { display: block; } + +.log-repeat { + display: none; font-size: 10px; font-weight: 700; color: var(--warn-text); + background: var(--warn-sub); border-radius: 4px; padding: 0 5px; margin-right: 8px; +} +.log-line.folded .log-repeat { display: inline-block; } + +.log-follow { + border: 1px solid var(--border); background: var(--bg); color: var(--text-faint); + border-radius: var(--r-xs); padding: 4px 10px; font-size: 12px; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; + transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease), background var(--fast) var(--ease); + white-space: nowrap; flex-shrink: 0; +} +.log-follow:hover { color: var(--text-soft); border-color: var(--text-faint); } +.log-follow.on { color: var(--primary-text); border-color: var(--primary); background: var(--primary-sub); } + +.fg-check { + display: inline-flex; align-items: center; gap: 9px; + font-size: 13px; cursor: pointer; color: var(--text-soft); + margin-top: 10px; user-select: none; +} +.fg-check input[type="checkbox"], +.switch input[type="checkbox"] { + -webkit-appearance: none; appearance: none; + width: 36px; height: 20px; border-radius: 10px; + background: var(--border); cursor: pointer; flex-shrink: 0; + position: relative; transition: background var(--fast) var(--ease); +} +.fg-check input[type="checkbox"]::after, +.switch input[type="checkbox"]::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 16px; height: 16px; border-radius: 50%; + background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.22); + transition: transform .22s cubic-bezier(.34,1.2,.64,1); +} +.fg-check input[type="checkbox"]:checked, +.switch input[type="checkbox"]:checked { + background: var(--primary); +} +.fg-check input[type="checkbox"]:checked::after, +.switch input[type="checkbox"]:checked::after { + transform: translateX(16px); +} +.fg-check input[type="checkbox"]:disabled, +.switch input[type="checkbox"]:disabled { opacity: .45; cursor: not-allowed; } + +.svc-status-grid { display: flex; gap: 22px; align-items: stretch; } +.svc-side { flex: 0 0 auto; min-width: 300px; } +.proxy-side { + flex: 1; min-width: 0; + border-left: 1px solid var(--border); padding-left: 22px; + display: flex; flex-direction: column; gap: 10px; +} +.proxy-status-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.proxy-gauge-row { + display: flex; gap: 12px; align-items: center; flex-wrap: wrap; + margin-top: auto; +} +.proxy-tags { display: flex; gap: 6px; flex-wrap: wrap; } + +.proxy-side { position: relative; } + +.tag-click { cursor: pointer; transition: all var(--fast) var(--ease); } +.tag-click:hover, .tag-click.on { background: var(--primary-sub); color: var(--primary-text); border-color: var(--primary-text); } + +.exit-pop { + position: absolute; z-index: 850; + top: calc(100% + 8px); right: 0; + width: 400px; max-width: min(92vw, 400px); + display: none; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-md); box-shadow: var(--shadow-overlay); + padding: 10px 14px; +} +.exit-pop.show { display: block; animation: ctxIn .14s var(--ease); } + +.exit-list { + display: flex; flex-direction: column; gap: 2px; + max-height: 260px; overflow-y: auto; +} +.exit-row { + display: flex; gap: 10px; align-items: center; + font-size: 12px; padding: 2px 0; +} +.exit-row .exit-url { + flex: 1; min-width: 0; font-family: var(--font-mono); + color: var(--text); word-break: break-all; +} +.exit-row .exit-load { color: var(--text-soft); flex-shrink: 0; min-width: 54px; text-align: right; } +.exit-row .exit-left { color: var(--text-soft); flex-shrink: 0; min-width: 52px; text-align: right; } +.exit-head { color: var(--text-faint); font-weight: 650; } +.exit-flag { color: var(--warn-text); font-size: 11px; flex-shrink: 0; } +.exit-fails { + flex-shrink: 0; font-size: 10px; font-weight: 700; line-height: 1.6; + padding: 0 5px; border-radius: 100px; + background: var(--danger-sub); color: var(--danger-text); +} +.exit-head .exit-url, .exit-head .exit-load, .exit-head .exit-left { color: inherit; } +.tag { + font-size: 11px; padding: 3px 12px; border-radius: 100px; + background: var(--primary-sub); color: var(--primary-text); + font-weight: 650; letter-spacing: .01em; +} + +.gauge { + width: 140px; height: 8px; + background: var(--border); border-radius: 4px; + overflow: hidden; flex-shrink: 0; +} +.gauge-fill { + display: block; height: 100%; + background: linear-gradient(90deg, var(--primary), var(--primary-hv)); + border-radius: 4px; transition: width .35s linear; +} +.timer { + font-size: 13px; color: var(--text-soft); + min-width: 56px; text-align: right; + font-variant-numeric: tabular-nums; font-weight: 550; +} + +.tip-warn { + width: 100%; margin-top: 8px; + padding: 8px 16px; + background: var(--warn-sub); border: 1px solid var(--warn); + border-radius: var(--r-xs); font-size: 12px; color: var(--warn-text); + display: flex; align-items: center; gap: 7px; +} + +.ad-bar { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-md); padding: 10px 18px; + box-shadow: var(--shadow-card); + display: flex; align-items: center; gap: 12px; + margin-bottom: 10px; + transition: opacity .25s var(--ease), transform .25s var(--ease), + max-height .25s var(--ease), padding .25s var(--ease), + margin .25s var(--ease), border-width .25s var(--ease); + overflow: hidden; max-height: 120px; +} +.ad-bar:hover { border-color: var(--primary); box-shadow: var(--shadow-float); } +.ad-bar.hidden { + opacity: 0; transform: translateY(-8px); + max-height: 0; padding-top: 0; padding-bottom: 0; + margin-bottom: 0; border-width: 0; + pointer-events: none; +} + +.ad-lbl-group { + display: flex; flex-direction: column; align-items: center; gap: 4px; + flex-shrink: 0; min-width: 36px; +} +.ad-label { + font-size: 9px; font-weight: 750; letter-spacing: .1em; + color: var(--text-inv); background: var(--text-faint); + padding: 2px 7px; border-radius: 3px; + text-transform: uppercase; + user-select: none; line-height: 1.4; +} + +.ad-cards { + display: flex; align-items: stretch; gap: 10px; + flex: 1; min-width: 0; +} +.ad-card { + flex: 1; min-width: 0; +} +.ad-card-inner { + display: flex; align-items: center; gap: 10px; + padding: 2px 10px; + min-height: 44px; +} + +.ad-img { + width: 36px; height: 36px; border-radius: var(--r-xs); + object-fit: cover; flex-shrink: 0; + border: 1px solid var(--border-light); +} +.ad-text { + display: flex; flex-direction: column; justify-content: center; gap: 3px; + flex: 1; min-width: 0; +} +.ad-title { + font-size: 12px; font-weight: 700; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.ad-body { + font-size: 11px; color: var(--text-soft); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 1.4; +} + +.ad-link { + display: inline-flex; align-items: center; gap: 4px; + font-size: 10px; font-weight: 650; color: var(--primary-text); + text-decoration: none; padding: 4px 10px; + border: 1px solid var(--primary); border-radius: 100px; + white-space: nowrap; flex-shrink: 0; + transition: all var(--fast) var(--ease); + align-self: center; +} +.ad-link:hover { + background: var(--primary-sub); color: var(--primary-text); + box-shadow: 0 2px 8px var(--primary-glow); +} + +.ad-pagination { + display: flex; gap: 5px; align-items: center; flex-shrink: 0; +} +.ad-dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--border); cursor: pointer; + transition: background var(--fast) var(--ease), transform var(--fast) var(--ease); +} +.ad-dot.active { background: var(--primary); transform: scale(1.45); } +.ad-dot:hover { background: var(--primary); opacity: .65; } + +.ad-timer { + font-size: 10px; color: var(--text-faint); + font-variant-numeric: tabular-nums; text-align: center; + line-height: 1; +} + +.ad-close { + display: inline-flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border-radius: 50%; + border: 1px solid transparent; background: transparent; + color: var(--text-faint); cursor: pointer; + font-size: 12px; flex-shrink: 0; + transition: all var(--fast) var(--ease); +} +.ad-close:hover { + background: var(--danger-sub); color: var(--danger-text); + border-color: var(--danger); +} + +.ad-reopen { + display: inline-flex; align-items: center; gap: 5px; + background: var(--surface); border: 1px solid var(--border); + border-radius: 100px; padding: 4px 11px; cursor: pointer; + font-size: 11px; font-weight: 600; color: var(--text-faint); + transition: all var(--fast) var(--ease); + font-family: inherit; line-height: 1.4; +} +.ad-reopen:hover { + border-color: var(--primary-text); color: var(--primary-text); + background: var(--primary-sub); +} +.ad-reopen .fa { font-size: 12px; } + +.ad-img[src]:not([src=""]) { cursor: pointer; } +.ad-img[src]:not([src=""]):hover { opacity: .85; transform: scale(1.04); transition: all var(--fast) var(--ease); } + +.img-lightbox { + position: fixed; inset: 0; z-index: 99999; + background: rgba(0,0,0,.82); + display: flex; align-items: center; justify-content: center; + opacity: 0; pointer-events: none; + transition: opacity .25s var(--ease); +} +.img-lightbox.show { opacity: 1; pointer-events: auto; } +.img-lightbox img { + max-width: 90vw; max-height: 85vh; + border-radius: var(--r-sm); box-shadow: 0 16px 64px rgba(0,0,0,.4); + transform: scale(.92); + transition: transform .25s var(--ease); +} +.img-lightbox.show img { transform: scale(1); } +.img-lightbox-close { + position: absolute; top: 20px; right: 24px; + width: 40px; height: 40px; border-radius: 50%; + background: rgba(255,255,255,.12); border: none; + color: #fff; font-size: 18px; cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: background var(--fast) var(--ease); +} +.img-lightbox-close:hover { background: rgba(255,255,255,.22); } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + min-height: 36px; + padding: 8px 18px; border: 1px solid var(--border); border-radius: var(--r-sm); + font-size: 13px; font-weight: 600; cursor: pointer; + transition: all var(--fast) var(--ease); + background: var(--surface); color: var(--text-soft); + white-space: nowrap; line-height: 1.35; +} +.btn:hover { + background: var(--bg); border-color: var(--text-faint); color: var(--text); + transform: translateY(-1px); box-shadow: var(--shadow-float); +} +.btn:active { transform: translateY(0); } + +.sb-item:active, .sb-child:active, .palette-item:active, +.log-lv-pill:active, .log-cat-pill:active, .tag-click:active { filter: brightness(.96); } +.btn:disabled { opacity: .35; cursor: not-allowed; transform: none; box-shadow: none; pointer-events: none; } + +.btn.pri { background: var(--primary-grad); border-color: transparent; color: var(--text-inv); } +.btn.pri:hover { + border-color: transparent; filter: brightness(1.07); + box-shadow: 0 6px 18px var(--primary-glow); +} +.btn.ok { background: var(--success-fill); border-color: var(--success-fill); color: var(--text-inv); } +.btn.ok:hover { filter: brightness(1.12); box-shadow: 0 4px 14px rgba(16,185,129,.3); } +.btn.bad { background: var(--danger-fill); border-color: var(--danger-fill); color: var(--text-inv); } +.btn.bad:hover { filter: brightness(1.12); box-shadow: 0 4px 14px rgba(239,68,68,.3); } + +.btn.ghost { background: var(--surface); border-color: var(--border); color: var(--text-soft); } +.btn.ghost:hover { background: var(--surface-hv); border-color: var(--text-faint); color: var(--text); } +.btn.ghost.bad:hover { background: var(--danger-sub); border-color: var(--danger-text); color: var(--danger-text); } +.btn.ghost.ok:hover { background: var(--success-sub); border-color: var(--success-text); color: var(--success-text); } + +.btn.sm { min-height: 30px; padding: 5px 13px; font-size: 12px; border-radius: var(--r-xs); } +.btn.xs { min-height: 24px; padding: 4px 9px; font-size: 11px; border-radius: 5px; background: none; border-color: transparent; color: var(--text-soft); } +.btn.xs:hover { background: var(--bg); border-color: var(--border); color: var(--text); transform: none; box-shadow: none; } +.btn.xs.bad { background: none; border-color: transparent; color: var(--danger-text); } +.btn.xs.bad:hover { background: var(--danger-sub); border-color: var(--danger); } + +.btn.loading, .btn.done, .btn.failed { position: relative; pointer-events: none; } +.btn.loading > *, .btn.done > *, .btn.failed > * { visibility: hidden; } +.btn.loading { opacity: .8; } +.btn.loading::after { + content: ''; position: absolute; top: 50%; left: 50%; + width: 13px; height: 13px; margin: -6.5px 0 0 -6.5px; + border: 2px solid currentColor; border-top-color: transparent; + border-radius: 50%; animation: spin .7s linear infinite; +} +.btn.done, .btn.failed { color: var(--text-inv); } +.btn.done { background: var(--success-fill); border-color: var(--success-fill); } +.btn.failed { background: var(--danger-fill); border-color: var(--danger-fill); } +.btn.done::after, .btn.failed::after { + font-family: FontAwesome; font-size: 13px; + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); +} +.btn.done::after { content: '\f00c'; } +.btn.failed::after { content: '\f00d'; } +@keyframes spin { to { transform: rotate(360deg); } } + +.global-progress { + position: fixed; top: 0; left: 0; right: 0; height: 2px; + z-index: 400; pointer-events: none; overflow: hidden; + opacity: 0; transition: opacity var(--fast) var(--ease); +} +.global-progress.on { opacity: 1; } +.global-progress::after { + content: ''; display: block; height: 100%; width: 40%; + background: var(--primary-grad); + animation: progressSlide 1.1s var(--ease) infinite; +} +@keyframes progressSlide { + from { transform: translateX(-100%); } + to { transform: translateX(350%); } +} + +.conn-banner { + display: none; align-items: center; gap: 9px; + margin: 0 0 12px; padding: 9px 14px; + border: 1px solid var(--warn); border-radius: var(--r-sm); + background: var(--warn-sub); color: var(--warn-text); + font-size: 12.5px; +} +.conn-banner.show { display: flex; } +.conn-banner .btn { margin-left: auto; flex-shrink: 0; } + +.task-dock { + display: none; position: fixed; right: 24px; bottom: 76px; z-index: 860; + width: 268px; padding: 11px 14px; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-md); box-shadow: var(--shadow-overlay); + cursor: pointer; + transition: border-color var(--fast) var(--ease); +} +.task-dock.show { display: block; animation: taskDockIn .2s var(--ease); } +.task-dock:hover { border-color: var(--primary); } +body.batch-bar-open .task-dock { bottom: 132px; } +@keyframes taskDockIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +.task-dock-hd { display: flex; align-items: center; gap: 8px; font-size: 12.5px; font-weight: 650; color: var(--text); } +.task-dock-hd .fa { color: var(--primary-text); } +.task-dock-count { margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-faint); } +.task-dock-msg { + margin-top: 6px; font-size: 11.5px; color: var(--text-faint); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.task-dock-bar { margin-top: 8px; height: 4px; border-radius: 2px; background: var(--border); overflow: hidden; } +.task-dock-bar span { display: block; height: 100%; background: var(--primary-grad); transition: width .3s var(--ease); } + +.view-wrap { min-width: 0; } + +.tab-body { display: none; } +.tab-body.show { display: block; animation: tabFadeIn .25s var(--ease); } +@keyframes tabFadeIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +.cfg-actions { + display: flex; gap: 8px; margin-top: 14px; + padding-top: 14px; border-top: 1px solid var(--border); +} + +.dirty-hint, +.saved-hint { + display: inline-flex; align-items: center; gap: 6px; + margin-right: auto; padding: 3px 10px; + border-radius: 100px; border: 1px solid transparent; + font-size: 12px; font-weight: 600; +} +.dirty-hint { color: var(--warn-text); background: var(--warn-sub); border-color: var(--warn-sub); } +.dirty-hint .fa { font-size: 7px; } +.saved-hint { color: var(--text-faint); background: var(--bg); border-color: var(--border-light); } +.saved-hint .fa { color: var(--success-text); font-size: 12px; } +.cfg-actions .btn.dirty { box-shadow: 0 0 0 3px var(--warn-sub); } + +.cfg-actions .btn[aria-disabled="true"]:not(.done):not(.failed) { + background: var(--surface-alt); color: var(--text-faint); + border-color: var(--border-light); box-shadow: none; cursor: default; +} +.cfg-actions .btn.pri[aria-disabled="true"]:not(.done):not(.failed) { + background: var(--border); color: var(--surface); border-color: transparent; +} + +.adv-config { margin: 10px 0 0; } +.adv-config > summary { + cursor: pointer; font-size: 12px; font-weight: 700; color: var(--text-soft); + padding: 9px 8px; border-bottom: 1px solid var(--border-light); list-style: none; +} +.adv-config > summary:hover { color: var(--text); } +.adv-count { + margin-left: 4px; padding: 0 7px; border-radius: 100px; + background: var(--surface-alt); border: 1px solid var(--border-light); + font-size: 10.5px; font-weight: 700; color: var(--text-faint); +} + +.adv-toolbar { display: flex; justify-content: flex-end; max-width: var(--settings-max); margin-bottom: 10px; } +.adv-config > summary::-webkit-details-marker { display: none; } +.adv-config > summary::before { + content: '\f105'; font-family: FontAwesome; margin-right: 6px; color: var(--text-faint); + display: inline-block; transition: transform .15s; +} +.adv-config[open] > summary::before { transform: rotate(90deg); } +.adv-config > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +.warn-hint { color: var(--warn-text); font-weight: 600; } + + + +.settings-layout { display: flex; gap: 16px; align-items: flex-start; max-width: var(--settings-max); } + +.settings-nav { + width: 172px; flex-shrink: 0; position: sticky; top: 12px; + display: flex; flex-direction: column; gap: 2px; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-md); padding: 6px; +} +.settings-nav-item { + display: flex; align-items: center; gap: 9px; + padding: 8px 11px; border-radius: var(--r-xs); cursor: pointer; + font-size: 13px; font-weight: 600; color: var(--text-soft); + border: 1px solid transparent; user-select: none; + transition: background var(--fast) var(--ease), color var(--fast) var(--ease); +} +.settings-nav-item:hover { background: var(--surface-hv); color: var(--text); } +.settings-nav-item.cur { + background: var(--primary-sub); color: var(--primary-text); + border-color: var(--primary); +} +.settings-nav-item .fa { width: 16px; text-align: center; flex-shrink: 0; } +.settings-nav-item span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.settings-dirty-dot { + width: 7px; height: 7px; border-radius: 50%; background: var(--warn); + flex-shrink: 0; margin-left: auto; +} + +.settings-main { flex: 1; min-width: 0; } +.settings-group { display: none; } +.settings-group.show { display: block; } + +.settings-card { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-md); overflow: hidden; +} +.settings-card-hd { + display: flex; align-items: center; gap: 9px; + padding: 12px 18px; border-bottom: 1px solid var(--border); + background: var(--surface-alt); + font-size: 13px; font-weight: 700; color: var(--text); +} +.settings-card-hd .fa { color: var(--primary-text); } +.settings-card-sub { + margin-left: auto; font-size: 11px; font-weight: 600; color: var(--text-faint); + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.settings-card-count { + margin-left: auto; font-size: 10.5px; font-weight: 700; color: var(--text-faint); + background: var(--bg); padding: 1px 8px; border-radius: 100px; +} +.settings-card-sub + .settings-card-count { margin-left: 8px; } + +.settings-body { display: block; padding: 8px 10px 14px; } + +.settings-body > .fg:not(.settings-note), +.settings-adv-grid > .fg:not(.settings-note) { + position: relative; + display: grid; + grid-template-columns: var(--settings-label-w) minmax(0, 1fr); + column-gap: 16px; + align-items: start; + margin: 0; + padding: 9px 8px; + border-radius: var(--r-xs); + transition: background var(--fast) var(--ease); +} +.settings-body > .fg:not(.settings-note):hover, +.settings-adv-grid > .fg:not(.settings-note):hover { background: var(--surface-hv); } + +.settings-body > .fg:not(.settings-note):not(:first-child)::before, +.settings-adv-grid > .fg:not(.settings-note):not(:first-child)::before { + content: ''; position: absolute; top: 0; + left: calc(var(--settings-label-w) + 16px); right: 8px; + height: 1px; background: var(--border-light); +} + +.settings-body > .fg > *, +.settings-adv-grid > .fg > * { grid-column: 2; min-width: 0; } +.settings-body > .fg > .fg-label-row, +.settings-adv-grid > .fg > .fg-label-row { + grid-column: 1; grid-row: 1; + justify-content: flex-end; + margin: 0; + padding-top: 7px; +} +.settings-body > .fg > .fg-label-row .fg-label, +.settings-adv-grid > .fg > .fg-label-row .fg-label { text-align: right; } +.settings-body > .fg > .fg-hint, +.settings-adv-grid > .fg > .fg-hint { margin-top: 5px; } + +.settings-body > .fg > .fg-input, +.settings-body > .fg > .fg-select, +.settings-adv-grid > .fg > .fg-input, +.settings-adv-grid > .fg > .fg-select { max-width: 520px; width: 100%; } + +.settings-body > .fg > .fg-control, +.settings-adv-grid > .fg > .fg-control { + display: flex; align-items: center; gap: 8px; + max-width: 520px; width: 100%; +} +.fg-control > .fg-input { flex: 0 1 var(--field-num-w); min-width: 0; } +.fg-unit { flex: none; font-size: 12px; font-weight: 600; color: var(--text-soft); } +.fg-unit:empty { display: none; } + +.settings-body > .col-label { margin: 20px 8px 6px; } +.settings-body > .col-label:first-of-type { margin-top: 6px; } +.settings-body .fg-check { margin-top: 0; } +.settings-full { grid-column: 1 / -1; } + + +.settings-note { + font-size: 12px; line-height: 1.5; color: var(--text-soft); + background: var(--surface-alt); border: 1px solid var(--border); + border-radius: var(--r-xs); padding: 8px 12px; margin: 8px 8px 6px; +} + +.settings-adv-grid { display: block; padding-top: 4px; } + +.cfg-actions.sticky { + position: sticky; bottom: 12px; z-index: 10; + background: var(--surface-alt); + border: 1px solid var(--border); border-radius: var(--r-md); + padding: 7px 8px 7px 14px; margin-top: 12px; + display: flex; align-items: center; justify-content: flex-end; gap: 10px; + transition: box-shadow var(--fast) var(--ease); +} +.cfg-actions.sticky:not(.stuck) { + margin-top: -1px; + border-top: none; + border-top-left-radius: 0; border-top-right-radius: 0; +} +.cfg-actions.sticky.stuck { box-shadow: var(--shadow-float); } + +.settings-main > .settings-group > .settings-card, +.settings-main > #pool-form > .settings-group > .settings-card { + border-bottom-left-radius: 0; border-bottom-right-radius: 0; +} + +@media (max-width: 900px) { + .settings-layout { flex-direction: column; align-items: stretch; } + .settings-nav { + position: static; width: 100%; + flex-direction: row; flex-wrap: wrap; gap: 4px; + } + .settings-nav-item { padding: 6px 10px; } +} + + +.col-label { + display: inline-block; + font-size: 11px; font-weight: 700; letter-spacing: .04em; + color: var(--text-soft); + background: var(--surface-alt); border: 1px solid var(--border-light); + padding: 3px 11px; border-radius: 100px; + margin-bottom: 10px; +} + +.source-mode-group { + display: flex; gap: 2px; + padding: 4px; background: var(--bg); + border-radius: var(--r-sm); width: fit-content; +} +.source-mode-btn { + padding: 8px 20px; font-size: 12px; font-weight: 600; + border: none; background: transparent; color: var(--text-faint); + cursor: pointer; border-radius: var(--r-xs); + transition: all var(--fast) var(--ease); + font-family: inherit; +} +.source-mode-btn:hover { color: var(--text-soft); } +.source-mode-btn.active { + background: var(--surface); color: var(--primary-text); + box-shadow: 0 1px 3px rgba(0,0,0,.06); +} + +.cred-row { display: flex; align-items: center; gap: 8px; } +.cred-row select { flex: 1; min-width: 0; } +.cred-row .btn { flex-shrink: 0; align-self: stretch; } + +.proxy-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: 12px; +} +.proxy-cell { position: relative; } +.proxy-tag { + display: inline-block; font-size: 10px; font-weight: 700; letter-spacing: .06em; + padding: 2px 8px; border-radius: 3px; margin-bottom: 5px; +} +.proxy-tag.http { background: #dbeafe; color: #2563eb; } +.proxy-tag.https { background: #dcfce7; color: #16a34a; } +.proxy-tag.socks5 { background: #fef3c7; color: #d97706; } +.proxy-tag.full { background: #f3e8ff; color: #9333ea; } +[data-theme="dark"] .proxy-tag.http { background: #1e3a5f; color: #93c5fd; } +[data-theme="dark"] .proxy-tag.https { background: #14532d; color: #86efac; } +[data-theme="dark"] .proxy-tag.socks5 { background: #422006; color: #fcd34d; } +[data-theme="dark"] .proxy-tag.full { background: #3b0764; color: #d8b4fe; } +.proxy-cell textarea { + width: 100%; padding: 10px 13px; font-size: 12px; + font-family: var(--font-mono); + border: 1px solid var(--border); border-radius: var(--r-sm); + background: var(--field-bg); color: var(--text); + resize: vertical; outline: none; line-height: 1.55; min-height: 90px; + transition: all var(--fast) var(--ease); +} +.proxy-cell textarea:focus { border-color: var(--primary); box-shadow: var(--shadow-glow); } +.proxy-cell textarea::placeholder { color: var(--text-faint); } +.proxy-actions { display: flex; gap: 8px; margin-top: 10px; } + +.tab-cols { display: flex; gap: 20px; } +.tab-cols > div { flex: 1; min-width: 0; } + +.col-hd { + font-size: 13px; font-weight: 700; color: var(--text); + margin-bottom: 12px; display: flex; align-items: center; gap: 8px; +} +.col-hd .fa { font-size: 13px; color: var(--text-faint); } +.col-badge { + margin-left: auto; font-size: 10px; font-weight: 700; + color: var(--text-faint); background: var(--surface-alt); + padding: 2px 10px; border-radius: 100px; + border: 1px solid var(--border); +} + +.tbl-clean { + width: 100%; border-collapse: collapse; +} +.tbl-clean th { + text-align: left; font-size: 10px; color: var(--text-faint); + font-weight: 700; padding: 6px 12px; + border-bottom: 1px solid var(--border); + text-transform: uppercase; letter-spacing: .04em; +} +.tbl-clean td { + padding: 7px 12px; font-size: 13px; + border-bottom: 1px solid var(--border-light); + color: var(--text-soft); +} +.tbl-clean tr:last-child td { border-bottom: none; } +.tbl-clean tr:hover td { background: var(--surface-hv); } +.tbl-clean td:last-child, .tbl-clean th:last-child { text-align: right; } +.tbl-clean .user-name { font-weight: 650; color: var(--text); } +.tbl-clean .user-pass { color: var(--text-soft); } +.tbl-clean .user-pass.masked { color: var(--text-faint); letter-spacing: .14em; } +.tbl-clean .user-eye { margin-left: 6px; font-size: 11px; vertical-align: middle; } + +.ip-stack { display: flex; flex-direction: column; gap: 10px; } +.ip-block { + border: 1px solid var(--border); border-radius: var(--r-sm); + overflow: hidden; +} +.ip-block-hd { + font-size: 11px; font-weight: 700; color: var(--text-faint); + padding: 8px 12px; background: var(--surface-alt); + border-bottom: 1px solid var(--border); + text-transform: uppercase; letter-spacing: .04em; + display: flex; align-items: center; gap: 6px; +} +.ip-block textarea { + width: 100%; padding: 10px 12px; font-size: 12px; min-height: 90px; + border: none; border-radius: 0; resize: vertical; outline: none; + font-family: var(--font-mono); + background: var(--field-bg); color: var(--text); line-height: 1.55; + transition: all var(--fast) var(--ease); +} +.ip-block textarea:focus { box-shadow: inset 0 0 0 2px var(--primary); } +.ip-block textarea::placeholder { color: var(--text-faint); } + +.ip-ft { + display: flex; gap: 8px; align-items: center; margin-top: 6px; +} +.ip-ft .fg-select { flex: 1; min-width: 0; font-size: 12px; } + +.bypass-block { + border: 1px solid var(--border); border-radius: var(--r-sm); + overflow: hidden; background: var(--surface); + margin-top: 20px; + transition: border var(--fast) var(--ease); +} +.bypass-block:focus-within { + border-color: var(--primary); + box-shadow: var(--shadow-float); +} + +.bypass-block-hd { + font-size: 12px; font-weight: 700; color: var(--text); + padding: 10px 14px; background: var(--surface-alt); + border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; +} +.bypass-block-hd .fa { + font-size: 13px; color: var(--primary-text); +} +.bypass-block-hd .bypass-hint { + font-weight: 400; font-size: 11px; color: var(--text-faint); + margin-left: auto; +} +.bypass-block textarea { + width: 100%; padding: 10px 14px; font-size: 12px; min-height: 95px; + border: none; border-radius: 0; resize: vertical; outline: none; + font-family: var(--font-mono); + background: var(--field-bg); color: var(--text); line-height: 1.6; + transition: all var(--fast) var(--ease); +} +.bypass-block textarea::placeholder { color: var(--text-faint); } + +.bypass-ft { + display: flex; gap: 8px; align-items: center; + padding: 8px 14px; border-top: 1px solid var(--border); + background: var(--surface-alt); +} + +.log-toolbar { + display: flex; gap: 8px; align-items: center; margin-bottom: 12px; + flex-wrap: wrap; +} + +.log-lv-pills { display: inline-flex; gap: 3px; flex-wrap: wrap; } +.log-lv-pill { + padding: 5px 14px; font-size: 11px; font-weight: 650; + border: 1px solid var(--border); border-radius: 100px; + background: transparent; color: var(--text-faint); + cursor: pointer; transition: all var(--fast) var(--ease); + font-family: inherit; + white-space: nowrap; flex-shrink: 0; +} +.log-lv-pill:hover { border-color: var(--text-faint); color: var(--text-soft); background: var(--surface-hv); } +.log-lv-pill.cur { + border-color: var(--primary-text); background: var(--primary-sub); color: var(--primary-text); +} +#log-level-pills .log-lv-pill.cur[data-lv="WARNING"] { + background: var(--warn-sub); border-color: var(--warn); color: var(--warn-text); +} +#log-level-pills .log-lv-pill.cur[data-lv="ERROR"] { + background: var(--danger-sub); border-color: var(--danger); color: var(--danger-text); +} +#log-level-pills .log-lv-pill.cur[data-lv="CRITICAL"] { + background: var(--danger-fill); border-color: var(--danger-fill); color: var(--text-inv); +} + +.log-cat-pill .pill-count, .log-lv-pill .pill-count { + margin-left: 6px; padding: 0 6px; border-radius: 100px; + font-size: 10px; font-weight: 700; line-height: 1.7; + font-variant-numeric: tabular-nums; + background: var(--surface-alt); color: var(--text-faint); +} +.log-cat-pill.cur .pill-count { background: var(--surface); color: var(--primary-text); } + +.log-search-wrap { + position: relative; flex: 1; min-width: 140px; +} +.log-search-wrap input { + width: 100%; padding: 6px 34px 6px 32px; + border: 1px solid var(--border); border-radius: 100px; + background: var(--field-bg); color: var(--text); + font-size: 12px; outline: none; transition: all var(--fast) var(--ease); +} +.log-search-wrap input:focus { border-color: var(--primary); box-shadow: var(--shadow-glow); } +.log-search-wrap .fa-search { + position: absolute; left: 12px; top: 50%; transform: translateY(-50%); + color: var(--text-faint); font-size: 11px; pointer-events: none; +} +.log-srch-clr { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + background: none; border: none; color: var(--text-faint); + cursor: pointer; font-size: 12px; display: none; padding: 4px; border-radius: 50%; +} +.log-srch-clr:hover { color: var(--danger-text); background: var(--danger-sub); } + +.log-stats { + display: flex; gap: 6px; flex-wrap: wrap; align-items: center; + margin-bottom: 10px; font-size: 11px; +} +.log-stat-item { + padding: 2px 10px; border-radius: 100px; + font-weight: 650; font-size: 11px; + border: 1px solid var(--border-light); + color: var(--text-faint); +} +.log-stat-item.total { background: var(--surface-alt); } +.log-stat-item.INFO { color: var(--info-text); border-color: var(--info-sub); } +.log-stat-item.WARNING { color: var(--warn-text); border-color: var(--warn-sub); } +.log-stat-item.ERROR { color: var(--danger-text); border-color: var(--danger-sub); } +.log-stat-item.CRITICAL{ color: var(--text-inv); background: var(--danger-fill); border-color: var(--danger-fill); } + +.log-box.is-empty { height: auto; } +.log-box .empty-hint { padding: 26px 24px; } +.log-box .empty-hint::before { width: 40px; height: 40px; margin-bottom: 10px; } + +.log-box { + height: clamp(320px, calc(100vh - 430px), 640px); overflow-y: auto; + background: #0d1117; border: 1px solid #21262d; + border-radius: var(--r-sm); padding: 14px 18px; + font-family: var(--font-mono); + font-size: 12.5px; line-height: 1.8; + color: #c9d1d9; + scrollbar-width: thin; scrollbar-color: #30363d #0d1117; +} +.log-box::-webkit-scrollbar { width: 6px; } +.log-box::-webkit-scrollbar-track { background: #0d1117; } +.log-box::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; } +[data-theme="dark"] .log-box { + background: #080c12; border-color: #1a1f2b; + scrollbar-color: #252a36 #080c12; +} +[data-theme="dark"] .log-box::-webkit-scrollbar-track { background: #080c12; } +[data-theme="dark"] .log-box::-webkit-scrollbar-thumb { background: #252a36; } + +.sidebar, +.exit-list, +.table-scroll, +.domain-table-wrap, +.drawer-body, +.palette-list { + scrollbar-width: thin; + scrollbar-color: var(--sb-thumb) transparent; +} +.sidebar::-webkit-scrollbar, +.exit-list::-webkit-scrollbar, +.table-scroll::-webkit-scrollbar, +.domain-table-wrap::-webkit-scrollbar, +.drawer-body::-webkit-scrollbar, +.palette-list::-webkit-scrollbar { width: 10px; height: 10px; } +.sidebar::-webkit-scrollbar-track, +.exit-list::-webkit-scrollbar-track, +.table-scroll::-webkit-scrollbar-track, +.domain-table-wrap::-webkit-scrollbar-track, +.drawer-body::-webkit-scrollbar-track, +.palette-list::-webkit-scrollbar-track { background: transparent; } +.sidebar::-webkit-scrollbar-thumb, +.exit-list::-webkit-scrollbar-thumb, +.table-scroll::-webkit-scrollbar-thumb, +.domain-table-wrap::-webkit-scrollbar-thumb, +.drawer-body::-webkit-scrollbar-thumb, +.palette-list::-webkit-scrollbar-thumb { + background: var(--sb-thumb); border-radius: 100px; + border: 3px solid transparent; background-clip: content-box; +} +.sidebar::-webkit-scrollbar-thumb:hover, +.exit-list::-webkit-scrollbar-thumb:hover, +.table-scroll::-webkit-scrollbar-thumb:hover, +.domain-table-wrap::-webkit-scrollbar-thumb:hover, +.drawer-body::-webkit-scrollbar-thumb:hover, +.palette-list::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: content-box; } +.sidebar::-webkit-scrollbar-corner, +.table-scroll::-webkit-scrollbar-corner, +.domain-table-wrap::-webkit-scrollbar-corner { background: transparent; } + +.log-line { + padding: 1px 6px; white-space: pre-wrap; word-break: break-all; + border-radius: 3px; margin: 1px 0; + display: flex; gap: 8px; align-items: baseline; + border-left: 2px solid transparent; + transition: background var(--fast) var(--ease); +} +.log-line:hover { background: rgba(255,255,255,.03); } +.log-DEBUG { border-left-color: #6e7681; } +.log-INFO { border-left-color: #58a6ff; } +.log-WARNING { border-left-color: #d2991d; } +.log-ERROR { border-left-color: #f85149; background: rgba(248,81,73,.08); } +.log-CRITICAL { border-left-color: #da3633; background: rgba(218,54,51,.15); } + +.log-time { + color: #6e7681; flex-shrink: 0; font-size: 11px; min-width: 68px; + font-variant-numeric: tabular-nums; +} +.log-level-badge { + font-size: 10px; font-weight: 700; padding: 0 5px; border-radius: 3px; + flex-shrink: 0; min-width: 48px; text-align: center; line-height: 1.5; + text-transform: uppercase; +} +.log-level-badge.DEBUG { background: rgba(110,118,129,.18); color: #8b949e; } +.log-level-badge.INFO { background: rgba(88,166,255,.15); color: #58a6ff; } +.log-level-badge.WARNING { background: rgba(210,153,29,.15); color: #d2991d; } +.log-level-badge.ERROR { background: rgba(248,81,73,.15); color: #f85149; } +.log-level-badge.CRITICAL { background: #da3633; color: #fff; } +.log-msg { flex: 1; color: #c9d1d9; } +.log-copy { + flex-shrink: 0; margin-left: 8px; padding: 0 5px; + border: none; background: transparent; border-radius: 4px; + color: #6e7681; font-size: 11px; line-height: 1.6; cursor: pointer; + opacity: 0; transition: opacity var(--fast) var(--ease), color var(--fast) var(--ease); +} +.log-line:hover .log-copy { opacity: 1; } +.log-copy:hover, .log-copy:focus-visible { opacity: 1; color: #c9d1d9; background: rgba(255,255,255,.08); } + +.log-cat-pills { display: inline-flex; gap: 3px; flex-wrap: wrap; } +.log-cat-pill { + padding: 5px 14px; font-size: 11px; font-weight: 650; + border: 1px solid var(--border); border-radius: var(--r-xs); + background: transparent; color: var(--text-faint); + cursor: pointer; transition: all var(--fast) var(--ease); + font-family: inherit; + white-space: nowrap; flex-shrink: 0; +} +.log-cat-pill:hover { border-color: var(--text-faint); color: var(--text-soft); background: var(--surface-hv); } +.log-cat-pill.cur { border-color: var(--primary-text); background: var(--primary-sub); color: var(--primary-text); } +.log-toolbar-sep { + width: 1px; align-self: stretch; min-height: 22px; + background: var(--border); margin: 0 2px; +} + +.log-file-select { + padding: 6px 10px; border: 1px solid var(--border); border-radius: 100px; + background: var(--field-bg); color: var(--text); + font-size: 12px; font-family: inherit; outline: none; max-width: 260px; + transition: all var(--fast) var(--ease); + flex-shrink: 0; +} +.log-file-select:focus { border-color: var(--primary); box-shadow: var(--shadow-glow); } + +.log-cat { + flex-shrink: 0; font-size: 10px; font-weight: 700; + padding: 0 6px; border-radius: 3px; line-height: 1.5; + background: rgba(148,155,178,.16); color: #949bb2; +} +.log-cat-access { background: rgba(63,185,80,.16); color: #3fb950; } +.log-cat-proxy { background: rgba(88,166,255,.16); color: #58a6ff; } +.log-cat-pool { background: rgba(188,140,255,.16); color: #bc8cff; } +.log-cat-main { background: rgba(45,212,191,.16); color: #2dd4bf; } +.log-hit { + background: var(--warn-sub); color: var(--warn-text); + border-radius: 2px; padding: 0 2px; +} +.log-box.showing-file { border-color: var(--warn); } + +.access-hint { + display: flex; align-items: center; gap: 9px; + margin-top: 14px; padding: 9px 14px; + border: 1px dashed var(--border); border-radius: var(--r-sm); + background: var(--surface-alt); + font-size: 12.5px; color: var(--text-soft); +} +.access-hint .fa-bar-chart { color: var(--primary-text); } +.access-hint .btn { margin-left: auto; flex-shrink: 0; } +.access-hint .btn .fa { font-size: 11px; } + +.domain-panel { margin-top: 16px; } +.domain-head { + display: flex; gap: 10px; align-items: center; flex-wrap: wrap; + margin-bottom: 10px; +} +.domain-title { font-size: 13px; font-weight: 700; color: var(--text); } +.domain-summary { font-size: 11px; color: var(--text-faint); } +.domain-search-wrap { max-width: 220px; } +.domain-head .fg-select { width: auto; min-width: 96px; } +.domain-table-wrap { + overflow-x: auto; border: 1px solid var(--border); + border-radius: var(--r-sm); background: var(--surface); +} + +.table-scroll, +.domain-table-wrap { + max-height: clamp(320px, calc(100vh - 380px), 720px); + overflow: auto; +} +.table-scroll thead th, +.domain-table-wrap thead th { + position: sticky; top: 0; z-index: 2; + background: var(--surface-alt); + box-shadow: inset 0 -1px 0 var(--border); +} + +body[data-density="compact"] .tbl-clean td, +body[data-density="compact"] .tbl-clean th { padding-top: 3px; padding-bottom: 3px; } +body[data-density="compact"] .tbl-proxy td, +body[data-density="compact"] .tbl-proxy th { padding: 3px 8px; } +body[data-density="compact"] .domain-table td, +body[data-density="compact"] .domain-table th { padding: 4px 10px; } +body[data-density="compact"] .log-line { margin: 0; padding: 0 6px; } +.domain-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.domain-table th { + text-align: left; padding: 9px 12px; font-size: 11px; font-weight: 700; + color: var(--text-faint); background: var(--surface-alt); + border-bottom: 1px solid var(--border); white-space: nowrap; +} +.domain-table th.domain-sortable { cursor: pointer; user-select: none; } +.domain-table th.domain-sortable:hover { color: var(--primary-text); } +.domain-table td { + padding: 8px 12px; border-bottom: 1px solid var(--border-light); + color: var(--text-soft); vertical-align: top; +} +.domain-table tr:last-child td { border-bottom: none; } +.domain-table td.empty-hint { text-align: center; } +.domain-ok { color: var(--success-text); font-weight: 650; } +.domain-fail { color: var(--danger-text); font-weight: 650; } + +.record-outcome { + display: inline-block; padding: 1px 8px; + border-radius: 100px; font-size: 11px; font-weight: 650; line-height: 1.7; + background: var(--surface-alt); color: var(--text-faint); +} +.cell-num { text-align: right; font-variant-numeric: tabular-nums; } +.warn-text { color: var(--warn-text); } +.record-outcome.success { background: var(--success-sub); color: var(--success-text); } +.record-outcome.failure { background: var(--danger-sub); color: var(--danger-text); } +.record-outcome.aborted { background: var(--surface-alt); color: var(--text-faint); } + +.status-code { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--text-soft); } +.status-code.ok { color: var(--success-text); } +.status-code.redirect { color: var(--info-text); } +.status-code.client { color: var(--warn-text); } +.status-code.server { color: var(--danger-text); } + +#access-mode-pills { margin-bottom: 10px; } +#access-records-view .filter-bar { margin-bottom: 10px; } +#access-records-view .pager { margin-top: 10px; } +#access-records-view .filter-bar .fg-input { width: auto; min-width: 170px; } +.domain-rate { + padding: 1px 8px; border-radius: 100px; font-weight: 650; + background: var(--surface-alt); color: var(--text-faint); +} +.domain-rate.warn { background: var(--warn-sub); color: var(--warn-text); } +.domain-rate.bad { background: var(--danger-sub); color: var(--danger-text); } +.domain-reason { + max-width: 320px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; color: var(--text-faint); +} +.log-stat-item.log-stat-cat { border-style: dashed; } + +.domain-row { cursor: pointer; } +.domain-row:hover td { background: var(--surface-hv); } +.domain-row.expanded td { background: var(--primary-sub); } +.domain-toggle .fa { + color: var(--text-faint); font-size: 11px; margin-right: 3px; + transition: color var(--fast) var(--ease); +} +.domain-row:hover .domain-toggle .fa { color: var(--primary-text); } +.domain-name { color: var(--text); font-weight: 600; } +.domain-count { + margin-left: 7px; padding: 0 7px; border-radius: 100px; + font-size: 10px; font-weight: 700; + background: var(--surface-alt); color: var(--text-faint); +} +.domain-detail td { + background: var(--surface-alt); color: var(--text-soft); + border-bottom: 1px dashed var(--border-light); +} +.domain-detail .domain-name { padding-left: 26px; font-weight: 500; } + +.fg { margin-bottom: 12px; } +.fg:last-child { margin-bottom: 0; } +.fg-label { + display: block; font-size: 12.5px; color: var(--text); + margin-bottom: 5px; font-weight: 650; +} +.fg-label-row { display: flex; align-items: center; gap: 6px; margin-bottom: 5px; } +.fg-label-row .fg-label { margin-bottom: 0; } +.fg-tip { + font-size: 12px; color: var(--text-soft); cursor: help; + opacity: .7; flex-shrink: 0; + transition: color var(--fast) var(--ease), opacity var(--fast) var(--ease); +} +.fg-tip:hover { color: var(--primary-text); opacity: 1; } +.fg-input, .fg-select { + width: 100%; min-height: 38px; padding: 8px 12px; font-size: 13px; + border: 1px solid var(--border); border-radius: var(--r-xs); + background: var(--field-bg); color: var(--text); + outline: none; transition: all var(--fast) var(--ease); +} +.fg-input::placeholder { color: var(--text-faint); } +.fg-input:focus, .fg-select:focus { + border-color: var(--primary); background: var(--surface); + box-shadow: var(--shadow-glow); +} +.fg-hint { font-size: 12px; line-height: 1.5; color: var(--text-soft); margin-top: 4px; } + +.fg-select, +.log-file-select { + -webkit-appearance: none; appearance: none; + background-image: var(--chevron); + background-repeat: no-repeat; + background-position: right 8px center; + background-size: 16px; + padding-right: 30px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; +} +.log-file-select { padding-right: 33px; text-overflow: ellipsis; } + +.fg-input:not(:disabled):hover, +.fg-select:not(:disabled):hover, +.log-file-select:hover { border-color: var(--text-faint); } + +.fg-select option, .log-file-select option { background: var(--surface); color: var(--text); } + +.fg-input:disabled, .fg-select:disabled { + opacity: .5; cursor: not-allowed; + background-color: var(--bg); color: var(--text-faint); +} + +@supports (appearance: base-select) { + .fg-select, + .log-file-select, + ::picker(select) { appearance: base-select; } + + .fg-select, + .log-file-select { + align-items: center; + background-image: none; + } + .fg-select, + .log-file-select { padding-right: 12px; } + + .log-file-select { width: 260px; } + + + ::picker-icon { + content: ''; + width: 16px; height: 16px; flex-shrink: 0; + background-image: var(--chevron); + background-repeat: no-repeat; + background-position: center; + background-size: 16px; + transition: rotate var(--norm) var(--ease); + } + :open::picker-icon { rotate: 180deg; } + + ::picker(select) { + margin-top: 6px; padding: 5px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-overlay); + max-height: min(320px, 60vh); + overflow-x: hidden; + overflow-y: auto; + width: auto; + min-width: anchor-size(width); + max-width: min(92vw, 420px); + opacity: 0; translate: 0 -6px; + transition: opacity var(--fast) var(--ease), + translate var(--fast) var(--ease), + display var(--fast) allow-discrete, + overlay var(--fast) allow-discrete; + } + ::picker(select):popover-open { opacity: 1; translate: 0 0; } + @starting-style { + ::picker(select):popover-open { opacity: 0; translate: 0 -6px; } + } + + option { + padding: 7px 12px; + border-radius: var(--r-xs); + font-size: 13px; + color: var(--text); + cursor: pointer; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background var(--fast) var(--ease), color var(--fast) var(--ease); + } + option:hover, + option:focus-visible { + background: var(--primary-sub); + color: var(--primary-text); + } + option:checked { + background: var(--primary-sub); + color: var(--primary-text); + font-weight: 650; + } + option::checkmark { display: none; } +} + + +table { width: 100%; border-collapse: collapse; } +th { + text-align: left; font-size: 10px; color: var(--text-faint); + font-weight: 700; padding: 8px 12px; border-bottom: 1px solid var(--border); + letter-spacing: .06em; text-transform: uppercase; + background: var(--surface-alt); +} +td { + font-size: 13px; padding: 8px 12px; border-bottom: 1px solid var(--border-light); + color: var(--text-soft); transition: background var(--fast) var(--ease); +} +td:last-child, th:last-child { text-align: right; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--primary-sub); } + +.skel { + display: block; height: 10px; border-radius: 100px; + background: linear-gradient(90deg, + var(--skeleton) 25%, var(--skeleton-sheen) 37%, var(--skeleton) 63%); + background-size: 400% 100%; + animation: skelShimmer 1.4s ease-in-out infinite; +} +.skel + .skel { margin-top: 7px; } +.skel-30 { width: 30%; } .skel-45 { width: 45%; } +.skel-60 { width: 60%; } .skel-80 { width: 80%; } .skel-100 { width: 100%; } +.skel-row td { padding-top: 11px; padding-bottom: 11px; } +.skel-row:hover td { background: transparent; } +.skel-block { display: flex; flex-direction: column; gap: 9px; padding: 14px 2px; } +@keyframes skelShimmer { from { background-position: 100% 0; } to { background-position: 0 0; } } + +.tbl-alert td { background: var(--warn-sub); border-bottom-color: transparent; } +.tbl-clean tr.tbl-alert:hover td, +.domain-table tr.tbl-alert:hover td { background: var(--warn-sub); } +.tbl-alert-in { + display: flex; align-items: center; gap: 9px; + font-size: 12.5px; line-height: 1.5; color: var(--warn-text); +} +.tbl-alert-in .fa { flex-shrink: 0; font-size: 13px; } +.tbl-alert-in .tbl-alert-text { flex: 1; min-width: 0; } +.tbl-alert-in .btn { flex-shrink: 0; margin-left: auto; } + +.modal-overlay { + position: fixed; inset: 0; z-index: 10000; + background: var(--scrim); backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + opacity: 0; pointer-events: none; + transition: opacity .25s var(--ease); +} +.modal-overlay.show { opacity: 1; pointer-events: auto; } + +body.modal-open { overflow: hidden; } +.modal-box { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-lg); padding: 30px 30px 24px; + width: 420px; max-width: 92vw; + box-shadow: var(--shadow-overlay); text-align: center; + transform: translateY(16px) scale(.95); + transition: transform .25s var(--ease); +} +.modal-overlay.show .modal-box { transform: translateY(0) scale(1); } +.modal-icon { + width: 56px; height: 56px; border-radius: 50%; + margin: 0 auto 16px; position: relative; + display: flex; align-items: center; justify-content: center; font-size: 24px; +} +.modal-icon::after { + content: ''; position: absolute; inset: -5px; border-radius: 50%; opacity: .35; +} +.modal-icon.info { background: var(--primary-sub); color: var(--primary-text); } +.modal-icon.info::after { border: 2px solid var(--primary); } +.modal-icon.warning { background: var(--warn-sub); color: var(--warn-text); } +.modal-icon.warning::after { border: 2px solid var(--warn); } +.modal-title { + font-size: 17px; font-weight: 750; color: var(--text); + margin-bottom: 6px; letter-spacing: -.01em; +} +.modal-body { + font-size: 13.5px; color: var(--text-soft); + margin-bottom: 20px; line-height: 1.6; +} +.modal-input { + width: 100%; padding: 10px 14px; font-size: 14px; + border: 1px solid var(--border); border-radius: var(--r-sm); + background: var(--bg); color: var(--text); outline: none; + margin-bottom: 16px; text-align: center; + transition: all var(--fast) var(--ease); +} +.modal-input:focus { border-color: var(--primary); box-shadow: var(--shadow-glow); } +.modal-actions { display: flex; gap: 10px; justify-content: center; } +.modal-actions .btn { min-width: 100px; padding: 9px 20px; font-weight: 650; } +.modal-actions .btn.pri { box-shadow: 0 2px 8px var(--primary-glow); } + +.fmt-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 18px; } +.fmt-btn { + flex-direction: column; gap: 5px; + min-height: 0; padding: 14px 10px; + border-radius: var(--r-sm); +} +.fmt-btn .fa { font-size: 18px; color: var(--primary-text); } +.fmt-name { font-size: 13px; font-weight: 700; } +.fmt-desc { + font-size: 11px; font-weight: 500; line-height: 1.4; text-align: center; + color: var(--text-faint); white-space: normal; +} +.fmt-btn:hover .fmt-desc { color: var(--text-soft); } + +.export-count, .import-count { + font-size: 12px; font-weight: 650; color: var(--primary-text); + margin: -6px 0 14px; +} +.export-count:empty, .import-count:empty { display: none; } +.import-count { margin: 0 0 12px; text-align: right; } + +.toast-stack { + position: fixed; top: 18px; right: 18px; z-index: 99999; + display: flex; flex-direction: column; align-items: flex-end; gap: 8px; + width: min(380px, calc(100vw - 32px)); + pointer-events: none; +} +.toast { + position: relative; overflow: hidden; + pointer-events: auto; + display: flex; align-items: flex-start; gap: 9px; + width: 100%; padding: 11px 10px 11px 13px; + border: 1px solid var(--border); border-left: 3px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface); color: var(--text); + box-shadow: var(--shadow-overlay); + font-size: 13px; line-height: 1.5; font-weight: 600; + animation: toastIn .22s var(--ease); + transition: opacity .18s var(--ease), transform .18s var(--ease); +} +.toast.leaving { opacity: 0; transform: translateX(14px); } +.toast-ico { flex-shrink: 0; font-size: 15px; line-height: 1.45; } +.toast-msg { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.toast-count { + flex-shrink: 0; align-self: center; font-size: 11px; font-weight: 700; + padding: 0 7px; border-radius: 100px; line-height: 1.7; + background: var(--surface-alt); color: var(--text-soft); +} +.toast-count:empty { display: none; } +.toast-x { + flex-shrink: 0; width: 22px; height: 22px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + border: none; border-radius: 50%; background: transparent; + color: var(--text-faint); font-size: 11px; cursor: pointer; + transition: background var(--fast) var(--ease), color var(--fast) var(--ease); +} +.toast-x:hover { background: var(--surface-hv); color: var(--text); } +.toast-bar { + position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; + background: currentColor; opacity: .5; + transform-origin: left center; + animation: toastBar linear forwards; +} +.toast:hover .toast-bar { animation-play-state: paused; } +@keyframes toastBar { from { transform: scaleX(1); } to { transform: scaleX(0); } } + +.toast.ok { background: var(--success-sub); border-left-color: var(--success); } +.toast.ok .toast-ico { color: var(--success-text); } +.toast.warn { background: var(--warn-sub); border-left-color: var(--warn); } +.toast.warn .toast-ico { color: var(--warn-text); } +.toast.err { background: var(--danger-sub); border-left-color: var(--danger); } +.toast.err .toast-ico { color: var(--danger-text); } +.toast.info { background: var(--primary-sub); border-left-color: var(--primary); } +.toast.info .toast-ico { color: var(--primary-text); } +.toast.ok .toast-bar { color: var(--success); } +.toast.warn .toast-bar { color: var(--warn); } +.toast.err .toast-bar { color: var(--danger); } +.toast.info .toast-bar { color: var(--primary); } + +@keyframes toastIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } + +.empty-hint { + padding: 44px 24px; text-align: center; color: var(--text-faint); font-size: 13px; +} +.empty-hint::before { + content: ''; display: block; + width: 64px; height: 64px; margin: 0 auto 14px; + background: url('/static/images/logo.png') center / contain no-repeat; + filter: grayscale(1); opacity: .3; +} +.empty-hint b { display: block; margin-bottom: 4px; font-size: 13.5px; font-weight: 700; color: var(--text-soft); } +td.empty-hint { padding: 26px 24px; } +td.empty-hint::before { width: 34px; height: 34px; margin-bottom: 8px; } + +.hint-text { + font-size: 12.5px; line-height: 1.6; color: var(--text-soft); + margin-bottom: 12px; +} +.hint-text:empty { display: none; } + +.metric-strip { + display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); + gap: 10px; margin-bottom: 14px; +} +.metric { + display: flex; flex-direction: column; gap: 3px; + padding: 11px 14px; + border: 1px solid var(--border); border-radius: var(--r-sm); + background: var(--surface-alt); +} +.metric-val { + font-family: var(--font-mono); + font-size: 20px; font-weight: 600; line-height: 1.2; color: var(--text); + font-variant-numeric: tabular-nums; +} +.metric.ok .metric-val { color: var(--success-text); } +.metric.bad .metric-val { color: var(--danger-text); } +.metric-unit { margin-left: 3px; font-size: 11px; font-weight: 600; color: var(--text-faint); } +.metric-lbl { font-size: 11px; font-weight: 600; color: var(--text-faint); } + +.spark { display: block; color: var(--primary); opacity: .7; } +.stat-spark { display: block; margin-top: 2px; } +.stat-spark:empty { display: none; } + +.db-backups:not(:empty) { margin-top: 4px; } +.db-backups { overflow-x: auto; } + +.drawer-scrim { + position: fixed; inset: 0; z-index: 9290; + background: var(--scrim); + opacity: 0; pointer-events: none; + transition: opacity var(--norm) var(--ease); +} +.drawer-scrim.show { opacity: 1; pointer-events: auto; } + +.row-drawer { + position: fixed; top: 0; right: 0; bottom: 0; z-index: 9300; + width: 420px; max-width: 92vw; + display: flex; flex-direction: column; + background: var(--surface); border-left: 1px solid var(--border); + box-shadow: var(--shadow-overlay); + transform: translateX(100%); + transition: transform var(--norm) var(--ease); +} +.row-drawer.show { transform: translateX(0); } + +.drawer-hd { + display: flex; align-items: flex-start; gap: 10px; + padding: 16px 18px; border-bottom: 1px solid var(--border); +} +.drawer-hd-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.drawer-title { + font-family: var(--font-mono); + font-size: 15px; font-weight: 700; color: var(--text); word-break: break-all; +} +.drawer-sub { font-size: 12px; color: var(--text-faint); } + +.drawer-body { flex: 1; overflow-y: auto; padding: 4px 18px 18px; } +.drawer-section { + margin: 16px 0 4px; padding-top: 12px; + border-top: 1px solid var(--border-light); + font-size: 11px; font-weight: 750; letter-spacing: .08em; + color: var(--text-faint); +} +.drawer-section:first-child { margin-top: 6px; padding-top: 0; border-top: none; } +.drawer-field { + display: flex; align-items: flex-start; gap: 12px; + padding: 9px 0; border-bottom: 1px solid var(--border-light); +} +.drawer-field:last-child { border-bottom: none; } +.drawer-label { flex: 0 0 104px; font-size: 12.5px; color: var(--text-faint); } +.drawer-val { flex: 1; min-width: 0; font-size: 13px; color: var(--text); word-break: break-all; } +.drawer-val.mono { font-family: var(--font-mono); font-size: 12.5px; } +.drawer-val.copyable { cursor: pointer; } +.drawer-val.copyable:hover { + color: var(--primary-text); text-decoration: underline dotted; text-underline-offset: 3px; +} + +.drawer-ft { + display: flex; gap: 8px; flex-wrap: wrap; + padding: 12px 18px; border-top: 1px solid var(--border); + background: var(--surface-alt); +} +.drawer-ft:empty { display: none; } + +.ctx-menu { + position: fixed; z-index: 9200; + min-width: 172px; padding: 5px; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-sm); box-shadow: var(--shadow-overlay); + display: none; +} +.ctx-menu.show { display: block; animation: ctxIn .12s var(--ease); } +@keyframes ctxIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: none; } +} +.ctx-item { + display: flex; align-items: center; gap: 9px; + width: 100%; padding: 7px 10px; + border: none; border-radius: var(--r-xs); + background: transparent; color: var(--text); + font-family: inherit; font-size: 12.5px; text-align: left; cursor: pointer; +} +.ctx-item .fa { width: 14px; text-align: center; color: var(--text-faint); } +.ctx-item:hover, .ctx-item.focus { background: var(--primary-sub); color: var(--primary-text); } +.ctx-item:hover .fa, .ctx-item.focus .fa { color: inherit; } +.ctx-item.danger, .ctx-item.danger .fa { color: var(--danger-text); } +.ctx-item.danger:hover, .ctx-item.danger.focus { background: var(--danger-sub); } +.ctx-sep { height: 1px; margin: 4px 6px; background: var(--border-light); } + +.tip-pop { + position: fixed; z-index: 10050; + max-width: min(380px, calc(100vw - 24px)); + max-height: min(70vh, 520px); + overflow-y: auto; + padding: 9px 13px; + background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: var(--r-sm); + box-shadow: var(--shadow-overlay); + font-size: 12.5px; line-height: 1.6; + white-space: pre-line; overflow-wrap: anywhere; + opacity: 0; visibility: hidden; transform: translateY(-4px); + transition: opacity .12s var(--ease), transform .12s var(--ease), visibility .12s; + scrollbar-width: thin; scrollbar-color: var(--sb-thumb) transparent; +} +.tip-pop.show { opacity: 1; visibility: visible; transform: none; } +.tip-pop::-webkit-scrollbar { width: 8px; } +.tip-pop::-webkit-scrollbar-thumb { background: var(--sb-thumb); border-radius: 100px; border: 2px solid transparent; background-clip: content-box; } +.fg-tip:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 3px; } + +tr.row-focus td { background: var(--surface-hv); } +tr.row-focus td:first-child { box-shadow: inset 2px 0 0 var(--primary); } + +.palette-overlay { align-items: flex-start; padding: 10vh 16px 16px; } +.palette { + display: flex; flex-direction: column; + width: 580px; max-width: 100%; max-height: 70vh; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r-md); box-shadow: var(--shadow-overlay); + overflow: hidden; + transform: translateY(-10px) scale(.985); + transition: transform var(--norm) var(--ease); +} +.palette-overlay.show .palette { transform: none; } + +.palette-search { + display: flex; align-items: center; gap: 10px; + padding: 14px 18px; border-bottom: 1px solid var(--border); +} +.palette-search .fa { color: var(--text-faint); font-size: 14px; } +.palette-search input { + flex: 1; min-width: 0; + border: none; outline: none; background: transparent; + color: var(--text); font-family: inherit; font-size: 14px; +} +.palette-search input::placeholder { color: var(--text-faint); } +.palette-search:focus-within { border-bottom-color: var(--primary); } + +.palette-list { flex: 1; overflow-y: auto; padding: 6px; } +.palette-item { + display: flex; align-items: center; gap: 10px; + width: 100%; padding: 9px 12px; + border: none; border-radius: var(--r-xs); + background: transparent; color: var(--text); + font-family: inherit; font-size: 13px; text-align: left; cursor: pointer; +} +.palette-item .fa { width: 16px; text-align: center; color: var(--text-faint); } +.palette-item .palette-label { flex: 1; min-width: 0; } +.palette-item .palette-label mark { background: transparent; color: var(--primary-text); font-weight: 700; } +.palette-item .palette-group { + flex-shrink: 0; font-size: 10.5px; font-weight: 650; + color: var(--text-faint); background: var(--surface-alt); + padding: 1px 8px; border-radius: 100px; +} +.palette-item.focus, .palette-item:hover { background: var(--primary-sub); } +.palette-item.focus .fa, .palette-item:hover .fa { color: var(--primary-text); } +.palette-empty { padding: 28px 12px; text-align: center; color: var(--text-faint); font-size: 13px; } + +.palette-foot { + display: flex; gap: 16px; flex-wrap: wrap; + padding: 9px 16px; border-top: 1px solid var(--border); + background: var(--surface-alt); + font-size: 11px; color: var(--text-faint); +} + +kbd { + display: inline-block; min-width: 18px; padding: 1px 5px; margin-right: 3px; + border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; + background: var(--surface); color: var(--text-soft); + font-family: var(--font-mono); + font-size: 10.5px; text-align: center; +} + +.shortcut-grid { display: flex; flex-direction: column; gap: 2px; text-align: left; } +.shortcut-row { + display: flex; align-items: center; gap: 12px; + padding: 7px 0; border-bottom: 1px solid var(--border-light); + font-size: 13px; color: var(--text-soft); +} +.shortcut-row:last-child { border-bottom: none; } +.shortcut-keys { flex: 0 0 136px; text-align: right; } +.shortcut-sub { + margin: 14px 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .06em; + color: var(--text-faint); +} + +.scroll-top-btn { + position: fixed; + bottom: 28px; + right: 28px; + z-index: 900; + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--primary-grad); + color: var(--text-inv); + border: none; + cursor: pointer; + box-shadow: 0 4px 14px var(--primary-glow); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + opacity: 0; + transform: translateY(12px); + pointer-events: none; + transition: opacity .25s var(--ease), transform .25s var(--ease), background .2s var(--ease); +} +.scroll-top-btn.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} +.scroll-top-btn:hover { + background: var(--primary-hv); + box-shadow: 0 6px 20px var(--primary-glow); + transform: translateY(-2px); +} +.scroll-top-btn.visible:hover { + transform: translateY(-2px); +} + +@media (max-width: 1024px) { + .sidebar { + transform: translateX(-100%); + transition: transform var(--norm) var(--ease); + box-shadow: var(--shadow-overlay); + } + .sidebar.open { transform: translateX(0); } + + .sb-scrim { + display: block; position: fixed; inset: 0; z-index: 960; + background: var(--scrim); + opacity: 0; pointer-events: none; + transition: opacity var(--norm) var(--ease); + } + body.sb-open { overflow: hidden; } + body.sb-open .sb-scrim { opacity: 1; pointer-events: auto; } + + .content { margin-left: 0; } + .mobile-topbar { + display: flex; align-items: center; gap: 10px; + position: sticky; top: 0; z-index: 830; + height: var(--topbar-h); padding: 0 16px; + background: var(--surface); border-bottom: 1px solid var(--border); + } + .mt-burger { font-size: 15px; } + .mt-title { font-size: 14px; font-weight: 700; color: var(--text); } + .content-inner { padding: 18px 18px 44px; } +} + +@media (max-width: 768px) { + .status-bar, .tab-cols { flex-direction: column; gap: 10px; } + .svc-status-grid { flex-direction: column; } + .svc-side { min-width: 0; } + .proxy-side { border-left: none; padding-left: 0; border-top: 1px solid var(--border); padding-top: 14px; } + .gauge { width: 100%; } + .addr-row { flex-direction: column; gap: 6px; } + .source-mode-group { flex-wrap: wrap; width: 100%; } + .source-mode-btn { flex: 1; text-align: center; } + .settings-body > .fg:not(.settings-note), + .settings-adv-grid > .fg:not(.settings-note) { + grid-template-columns: minmax(0, 1fr); row-gap: 2px; padding: 10px 8px; + } + .settings-body > .fg:not(.settings-note) > .fg-label-row, + .settings-adv-grid > .fg:not(.settings-note) > .fg-label-row { + grid-column: 1; grid-row: auto; justify-content: flex-start; padding-top: 0; + } + .settings-body > .fg:not(.settings-note) > .fg-label-row .fg-label, + .settings-adv-grid > .fg:not(.settings-note) > .fg-label-row .fg-label { text-align: left; } + .settings-body > .fg:not(.settings-note) > *, + .settings-adv-grid > .fg:not(.settings-note) > * { grid-column: 1; } + .settings-body > .fg:not(.settings-note) > .fg-hint, + .settings-adv-grid > .fg:not(.settings-note) > .fg-hint { margin-top: 3px; } + .settings-body > .fg:not(.settings-note):not(:first-child)::before, + .settings-adv-grid > .fg:not(.settings-note):not(:first-child)::before { left: 8px; } + .cfg-actions.sticky { justify-content: stretch; } + .cfg-actions.sticky .btn { flex: 1; } + .settings-nav { gap: 4px; } + .settings-nav-item { flex: 1 0 auto; justify-content: center; } + .proxy-grid { grid-template-columns: 1fr; } + .log-lv-pills { width: 100%; } + .log-file-select { width: auto; } + .log-lv-pill { flex: 1 0 auto; text-align: center; } + .log-toolbar { flex-direction: column; align-items: stretch; } + .log-cat-pill { flex-shrink: 0; } + .log-line { flex-wrap: wrap; gap: 4px; } + .log-time { min-width: auto; } + .bypass-block-hd { flex-direction: column; align-items: flex-start; gap: 4px; } + .bypass-block-hd .bypass-hint { margin-left: 0; } + .ad-bar { flex-wrap: wrap; gap: 8px; max-height: none; } + .ad-lbl-group { flex-direction: row; gap: 6px; min-width: auto; } + .ad-cards { flex-direction: column; gap: 6px; } + .ad-card-inner { padding: 2px 4px; min-height: 40px; } + .ad-img { width: 30px; height: 30px; } + .ad-link { align-self: center; } + .ad-close { position: absolute; top: 10px; right: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/web/static/css/nav-tabs.css b/web/static/css/nav-tabs.css deleted file mode 100644 index d16352d..0000000 --- a/web/static/css/nav-tabs.css +++ /dev/null @@ -1,33 +0,0 @@ -.nav-tabs { - border-bottom: 2px solid #dee2e6; - margin-bottom: 20px; -} - -.nav-tabs .nav-link { - border: none; - color: #6c757d; - padding: 10px 20px; - font-weight: 500; - transition: all 0.2s; -} - -.nav-tabs .nav-link:hover { - border: none; - color: var(--primary-color); -} - -.nav-tabs .nav-link.active { - border: none; - color: var(--primary-color); - position: relative; -} - -.nav-tabs .nav-link.active:after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - width: 100%; - height: 2px; - background-color: var(--primary-color); -} \ No newline at end of file diff --git a/web/static/css/pool.css b/web/static/css/pool.css new file mode 100644 index 0000000..7e1cb64 --- /dev/null +++ b/web/static/css/pool.css @@ -0,0 +1,358 @@ +/* +模块名称:代理池界面样式(pool.css) +功能描述:代理池页签的池启停条、概览统计卡、区块与工具条、提示条、批量任务进度、插件表、 + 代理列表(筛选 / 批量操作 / 表格 / 分页 / 徽标)与导入导出弹窗。 +职责边界:负责:以代理池页签为主,兼含访问记录页与全局组件复用的通用类(.filter-bar、.pager、 + .icon-btn、.modal-box-wide、.tbl-clean 空态);不负责:设计变量与 .btn / .fg-* / + .tbl-clean / .modal-* 基础组件(由 main.css 负责)。 +关键依赖:main.css 的设计变量与基础组件;index.html 静态模板提供池页骨架 DOM 与类名,并保证本文件 + 在 main.css 之后加载;pool.js / proxies.js 渲染动态行;body 状态类 batch-bar-open、 + col-resizing 仅由 proxies.js 切换;.icon-btn 另用于全局移动端顶栏、行抽屉与 app.js 用户表。 +已知限制: + 1. 768px 断点必须留在本文件:本文件后加载可覆盖 main.css 同特异性规则,搬进 main.css 即失效。 + 2. .chk-inline:not(.switch) 的 :not 必需:两者特异性相同,去掉会把插件表开关压成方框。 + 3. .cell-clip 的截断必须落在行内 span 上:自动布局表格会忽略 td 自身的 max-width。 + 4. 批量操作条是 fixed 浮层:sticky 会被祖先裁切;body.batch-bar-open 由 proxies.js 切换用于让位。 + 5. z-index 阶梯:列宽拖拽手柄 3 < 批量操作条 40 < 回到顶部按钮 900。 + 6. 概览卡着色落在内层 .stat-val;未验证与兜底徽标用中性色、虚线边框,勿与实测档同色。 + */ + +.pool-ctl { + display: flex; align-items: center; gap: 8px; + margin-bottom: 14px; +} +.pool-ctl-state { + display: inline-flex; align-items: center; gap: 8px; + padding: 5px 12px; border-radius: 100px; + background: var(--surface); border: 1px solid var(--border); + font-size: 12.5px; font-weight: 650; +} +.pool-ctl-label { font-size: 11px; font-weight: 600; color: var(--text-faint); } +.pool-ctl .btn:first-of-type { margin-left: auto; } + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 18px; +} +.stat-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 16px 18px; + box-shadow: var(--shadow-card); + display: flex; flex-direction: column; gap: 4px; + transition: border var(--fast) var(--ease), box-shadow var(--fast) var(--ease); +} +.stat-val { + font-family: var(--font-mono); + font-size: 26px; font-weight: 600; line-height: 1.15; + color: var(--text); +} +.stat-lbl { + font-size: 11px; font-weight: 600; letter-spacing: .04em; + color: var(--text-faint); +} +.stat-card.ok .stat-val { color: var(--success-text); } +.stat-card.bad .stat-val { color: var(--danger-text); } +.stat-card.warn .stat-val { color: var(--warn-text); } + +.section { margin-bottom: 26px; } +.section:last-child { margin-bottom: 0; } + +.section-hd { + display: flex; align-items: center; gap: 9px; + font-size: 13px; font-weight: 700; color: var(--text); + margin-bottom: 12px; flex-wrap: wrap; +} +.section-hd .fa { font-size: 13px; color: var(--text-faint); } +.section-hd .btn { margin-left: 4px; } +.section-hd .btn:first-of-type { margin-left: auto; } +.section-hd .col-badge { margin-left: 0; } + +.toolbar-row { + display: flex; gap: 8px; flex-wrap: wrap; + margin-bottom: 12px; +} + +.notice { + display: flex; align-items: flex-start; gap: 10px; + padding: 11px 15px; margin-bottom: 16px; + border-radius: var(--r-sm); + border: 1px solid var(--border); + background: var(--surface-alt); + font-size: 12.5px; line-height: 1.6; color: var(--text-soft); +} +.notice .fa { margin-top: 2px; color: var(--info-text); } +.notice-warn { background: var(--warn-sub); border-color: var(--warn); } +.notice-warn .fa { color: var(--warn-text); } +.notice .btn { margin-left: auto; flex-shrink: 0; } +.notice .btn .fa { margin-top: 0; color: inherit; } + +.task-area { display: flex; flex-direction: column; gap: 8px; } +.task-area:not(:empty) { margin-bottom: 12px; } + +.task-item { + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface-alt); + padding: 10px 14px; +} +.task-head { + display: flex; align-items: center; gap: 8px; + font-size: 12.5px; color: var(--text-soft); margin-bottom: 7px; +} +.task-head .task-name { font-weight: 650; color: var(--text); } +.task-head .task-state { margin-left: auto; font-size: 11.5px; color: var(--text-faint); } +.task-head .task-cancel { margin-left: 6px; flex-shrink: 0; } +.task-state.done { color: var(--success-text); } +.task-state.failed { color: var(--danger-text); } + +.task-bar { + height: 5px; border-radius: 3px; overflow: hidden; + background: var(--border); +} +.task-bar-fill { + height: 100%; border-radius: 3px; + background: var(--primary); + transition: width .3s var(--ease); +} +.task-bar-fill.done { background: var(--success); } +.task-bar-fill.failed { background: var(--danger); } +.task-msg { + margin-top: 7px; font-size: 11.5px; color: var(--text-faint); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.task-counts { + margin-top: 4px; font-size: 11.5px; color: var(--text-faint); + font-variant-numeric: tabular-nums; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.tbl-plugin td, .tbl-plugin th { white-space: nowrap; } +.tbl-plugin td { vertical-align: middle; } +.plugin-name { + font-weight: 650; color: var(--text); + display: flex; align-items: center; gap: 7px; +} +.plugin-name .fa { color: var(--text-faint); font-size: 12px; } +.plugin-sub { font-size: 11px; color: var(--text-faint); margin-top: 2px; } +.plugin-actions { display: flex; gap: 6px; justify-content: flex-end; } + +.switch { margin-top: 0; } + + +.filter-bar { + display: flex; gap: 8px; flex-wrap: wrap; align-items: center; + margin-bottom: 10px; +} +.filter-bar .fg-select { width: auto; min-width: 110px; } +.filter-bar .btn { margin-left: auto; } +.filter-bar-advanced .fg-input { width: auto; min-width: 130px; flex: 1 1 130px; } + +.batch-bar { + position: fixed; left: 50%; transform: translateX(-50%); + bottom: 20px; z-index: 40; + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + max-width: calc(100% - 32px); + padding: 8px 12px 8px 14px; + border: 1px solid var(--border); border-radius: var(--r-lg); + background: var(--surface); + box-shadow: var(--shadow-overlay); + animation: batchBarIn .18s var(--ease); +} +@keyframes batchBarIn { + from { opacity: 0; transform: translateX(-50%) translateY(10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.batch-sep { + width: 1px; align-self: stretch; margin: 0 2px; + background: var(--border); +} +.batch-count { + font-size: 12px; font-weight: 700; color: var(--primary-text); + background: var(--primary-sub); + padding: 3px 10px; border-radius: 100px; + white-space: nowrap; +} +.batch-count:empty { display: none; } +.batch-bar .btn.sm { padding: 5px 11px; } +.batch-bar .btn.xs { padding: 5px 9px; } +body.batch-bar-open .scroll-top-btn { bottom: 84px; } +body.batch-bar-open #pool-view-manage { padding-bottom: 68px; } + +.chk-inline { + display: inline-flex; align-items: center; gap: 7px; + font-size: 12.5px; color: var(--text-soft); + cursor: pointer; user-select: none; +} +.chk-inline:not(.switch) input[type="checkbox"] { + -webkit-appearance: none; appearance: none; + width: 18px; height: 18px; flex-shrink: 0; + border: 1.5px solid var(--border); border-radius: var(--r-xs); + background: var(--surface); cursor: pointer; + transition: all var(--fast) var(--ease); +} +.chk-inline:not(.switch) input[type="checkbox"]:hover { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-glow); +} +.chk-inline:not(.switch) input[type="checkbox"]:checked { + border-color: var(--primary); + background-color: var(--primary); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round' d='M3.9 8l3 3 5.2-5.7'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 13px; +} +.chk-inline:not(.switch) input[type="checkbox"]:checked:hover { box-shadow: none; } +.chk-inline:not(.switch) input[type="checkbox"]:disabled { opacity: .45; cursor: not-allowed; } + +.table-scroll { + overflow-x: auto; border-radius: var(--r-sm); + padding-bottom: 8px; margin-bottom: 10px; +} + +.tbl-proxy { + width: 100%; table-layout: auto; + -webkit-user-select: none; + user-select: none; +} +.tbl-proxy.cols-locked { table-layout: fixed; } +.tbl-proxy.cols-locked th, .tbl-proxy.cols-locked td { + overflow: hidden; text-overflow: ellipsis; +} +.tbl-proxy th, .tbl-proxy td { + padding: 6px 8px; white-space: nowrap; +} +.tbl-proxy td { font-size: 12.5px; } +.tbl-proxy th { position: relative; } +.tbl-proxy tbody tr { cursor: pointer; } +.tbl-proxy .cell-actions { text-align: right; padding-right: 4px; } +.tbl-proxy .proxy-tag { padding: 2px 6px; margin-bottom: 0; } +.tbl-proxy .badge { padding: 1px 6px; } + +.tbl-proxy .col-resize { + position: absolute; top: 0; right: -3px; width: 7px; height: 100%; + cursor: col-resize; z-index: 3; + background: transparent; +} +.tbl-proxy .col-resize::after { + content: ''; position: absolute; top: 20%; left: 3px; + width: 1px; height: 60%; background: transparent; + transition: background var(--fast) var(--ease); +} +.tbl-proxy th:hover .col-resize::after { background: var(--border); } +.tbl-proxy .col-resize:hover::after, +.tbl-proxy .col-resize.dragging::after { background: var(--primary); } +body.col-resizing .tbl-proxy, body.col-resizing .tbl-proxy * { cursor: col-resize !important; } +body.col-resizing { user-select: none; } + +.tbl-proxy .cell-clip { + display: inline-block; vertical-align: bottom; + max-width: min(260px, 100%); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.tbl-proxy .cell-addr { + font-family: var(--font-mono); font-size: 12px; + color: var(--text); +} +.tbl-proxy .cell-mono { + font-family: var(--font-mono); font-size: 11.5px; + color: var(--text-soft); +} + +.tbl-proxy .cell-copy { cursor: pointer; } +.tbl-proxy .cell-copy:hover { + color: var(--primary-text); + text-decoration: underline dotted; + text-underline-offset: 3px; +} + +.tbl-proxy .cell-exit-differs .cell-real-ip-value { color: var(--warn-text); } + +.tbl-proxy .cell-region .region-untranslated { + color: var(--text-faint); font-size: 10px; + margin-left: 3px; vertical-align: super; +} + +.tbl-proxy tr.selected td { background: var(--primary-sub); } +.tbl-proxy tr.selected:hover td { background: var(--primary-sub); } +.tbl-proxy tr.selected td:first-child { box-shadow: inset 2px 0 0 var(--primary); } + +.tbl-proxy .cell-delay, .tbl-proxy .cell-health { + text-align: right; font-variant-numeric: tabular-nums; +} + +.tbl-proxy tr.invalid td:not(.cell-actions) { opacity: .55; } +.tbl-proxy tr.invalid:hover td:not(.cell-actions), +.tbl-proxy tr.invalid.selected td:not(.cell-actions) { opacity: 1; } + +.tbl-clean td.empty-hint { text-align: center; } + +.th-sort { cursor: pointer; user-select: none; } +.th-sort:hover { color: var(--text-soft); } +.sort-icon { font-size: 10px; opacity: .4; } +.sort-icon.active { opacity: 1; color: var(--primary-text); } + +.icon-btn { + border: none; background: transparent; cursor: pointer; + padding: 3px 6px; border-radius: var(--r-xs); + color: var(--text-faint); font-size: 13px; + transition: all var(--fast) var(--ease); +} +.icon-btn:hover { background: var(--surface-hv); color: var(--text); } +.icon-btn.on { color: var(--warn-text); } + +.pager { + display: flex; align-items: center; gap: 8px; + margin-top: 12px; flex-wrap: wrap; +} +.pager .pager-size { width: auto; min-width: 70px; } +.pager .btn { padding: 6px 12px; } +.pager .pager-jump { width: 62px; padding: 6px 8px; text-align: center; } +.pager-info { font-size: 12.5px; color: var(--text-soft); } +.pager-total { margin-left: auto; font-size: 12px; color: var(--text-faint); } + +.badge { + display: inline-block; padding: 1px 8px; + border-radius: 100px; font-size: 11px; font-weight: 650; + line-height: 1.6; white-space: nowrap; + background: var(--surface-alt); color: var(--text-faint); + border: 1px solid var(--border); +} +.badge-elite { background: var(--success-sub); color: var(--success-text); border-color: transparent; } +.badge-anonymous { background: var(--info-sub); color: var(--info-text); border-color: transparent; } +.badge-transparent { background: var(--warn-sub); color: var(--warn-text); border-color: transparent; } +.badge-unverified { color: var(--text-faint); border-style: dashed; } +.badge-fallback { border-style: dashed; } +.badge-latest { background: var(--success-sub); color: var(--success-text); border-color: transparent; } +.badge-invalid { background: var(--danger-sub); color: var(--danger-text); border-color: transparent; } + +.badge-delay { font-family: var(--font-mono); } +.badge-delay.fast { color: var(--success-text); } +.badge-delay.mid { color: var(--warn-text); } +.badge-delay.slow { color: var(--danger-text); } + +.modal-box-wide { width: 620px; } +.modal-textarea { + width: 100%; min-height: 180px; margin-bottom: 14px; + padding: 10px 12px; resize: vertical; + font-family: var(--font-mono); font-size: 12.5px; + line-height: 1.6; + border: 1px solid var(--border); border-radius: var(--r-xs); + background: var(--field-bg); color: var(--text); + outline: none; transition: all var(--fast) var(--ease); +} +.modal-textarea:focus { border-color: var(--primary); box-shadow: var(--shadow-glow); } +.modal-actions-3 { margin-bottom: 10px; } +.modal-actions-3 .btn { flex: 1; } + +@media (max-width: 768px) { + .stat-grid { grid-template-columns: repeat(2, 1fr); } + .filter-bar .btn { margin-left: 0; } + .section-hd .btn:first-of-type { margin-left: 0; } + .modal-box-wide { width: 92vw; } +} diff --git a/web/static/css/progress.css b/web/static/css/progress.css deleted file mode 100644 index 432a648..0000000 --- a/web/static/css/progress.css +++ /dev/null @@ -1,44 +0,0 @@ -.progress-container { - margin-top: auto; -} - -.progress { - height: 8px; - border-radius: 10px; - background: rgba(0, 0, 0, 0.05); - overflow: hidden; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -} - -.progress-bar { - background: linear-gradient(45deg, #4a90e2, #357abd); - box-shadow: 0 0 10px rgba(74, 144, 226, 0.5); - transition: width 0.3s ease; - position: relative; - overflow: hidden; -} - -.progress-bar::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0.3) 50%, - rgba(255, 255, 255, 0) 100% - ); - animation: shimmer 1.5s infinite; -} - -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } -} \ No newline at end of file diff --git a/web/static/css/responsive.css b/web/static/css/responsive.css deleted file mode 100644 index a04226b..0000000 --- a/web/static/css/responsive.css +++ /dev/null @@ -1,41 +0,0 @@ -@media (max-width: 768px) { - .status-card { - margin-bottom: 20px; - } - - .btn { - width: 100%; - margin-bottom: 10px; - } - - .table-responsive { - margin-bottom: 20px; - } - - .log-container { - height: 300px; - } - - .log-search input { - font-size: 14px; - padding: 10px 40px; - } -} - -.language-switch { - position: fixed; - top: 20px; - right: 20px; - z-index: 1000; - background: white; - padding: 8px 16px; - border-radius: 20px; - box-shadow: var(--card-shadow); -} - -@media (max-width: 768px) { - .language-switch { - top: 10px; - right: 10px; - } -} \ No newline at end of file diff --git a/web/static/css/search.css b/web/static/css/search.css deleted file mode 100644 index 9a8bdce..0000000 --- a/web/static/css/search.css +++ /dev/null @@ -1,44 +0,0 @@ -.log-search { - position: relative; - margin-bottom: 20px; -} - -.log-search input { - padding: 12px 45px; - border-radius: 25px; - font-size: 15px; - font-weight: 500; - border: 2px solid #e9ecef; - transition: all 0.3s ease; -} - -.log-search input:focus { - border-color: #4a90e2; - box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2); -} - -.log-search .clear-search { - position: absolute; - right: 15px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: #6c757d; - cursor: pointer; - padding: 5px; - transition: all 0.2s ease; -} - -.log-search .clear-search:hover { - color: #dc3545; - transform: translateY(-50%) scale(1.1); -} - -.log-search i { - position: absolute; - left: 15px; - top: 50%; - transform: translateY(-50%); - color: #6c757d; -} \ No newline at end of file diff --git a/web/static/css/service-control.css b/web/static/css/service-control.css deleted file mode 100644 index 0f8cccd..0000000 --- a/web/static/css/service-control.css +++ /dev/null @@ -1,80 +0,0 @@ -.service-control { - height: 100%; - display: flex; - flex-direction: column; -} - -.service-control .card-body { - display: flex; - flex-direction: column; - height: 100%; -} - -.service-title { - font-size: 1.2rem; - font-weight: 600; - color: var(--text-color); - margin-bottom: 0.5rem; - display: flex; - align-items: center; -} - -.service-description { - font-size: 0.9rem; - color: #666; - margin-bottom: 1rem; -} - -.service-actions { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-top: auto; -} - -.service-btn { - border: none; - border-radius: 6px; - padding: 8px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; -} - -.service-btn .btn-content { - display: flex; - align-items: center; - justify-content: center; - gap: 6px; -} - -.service-btn:hover { - transform: translateY(-1px); - filter: brightness(1.1); -} - -.service-btn:active { - transform: translateY(1px); -} - -@media (max-width: 768px) { - .service-actions { - grid-template-columns: 1fr; - } - - .service-btn { - padding: 10px; - } -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .service-title { - color: #e0e0e0; - } - - .service-description { - color: #888; - } -} \ No newline at end of file diff --git a/web/static/css/status-card.css b/web/static/css/status-card.css deleted file mode 100644 index 6331fe2..0000000 --- a/web/static/css/status-card.css +++ /dev/null @@ -1,827 +0,0 @@ -.status-cards { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 25px; - margin-bottom: 30px; -} - -/* 统一卡片基础样式 */ -.card.status-card, .card.service-control { - height: 150px; - background: linear-gradient(165deg, #ffffff, #f8f9fa); - border-radius: 20px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - overflow: hidden; - position: relative; - border: 1px solid rgba(255, 255, 255, 0.8); - padding: 15px; - margin-bottom: 15px; -} - -.card-body { - height: 100%; - display: flex; - flex-direction: column; - position: relative; - z-index: 1; - padding: 0; -} - -.card.status-card:hover, .card.service-control:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); -} - -/* 统一卡片标题样式 */ -.card-title, .service-title { - font-size: 1rem; - font-weight: 600; - color: var(--text-color); - margin-bottom: 0.7rem; - display: flex; - align-items: center; - gap: 8px; -} - -.card-title i, .service-title i { - font-size: 1.1em; - color: var(--primary-color); - background: rgba(52, 152, 219, 0.1); - padding: 4px; - border-radius: 8px; -} - -/* 统一卡片内容样式 */ -.card-text { - color: var(--text-color); - font-size: 1.1rem; - font-weight: 500; - margin-bottom: 0.7rem; - flex-grow: 1; - display: flex; - align-items: center; -} - -/* 统一按钮容器样式 */ -.button-container, .service-actions, .progress-container { - margin-top: auto; -} - -/* 统一按钮样式 */ -.btn-sm, .service-btn { - padding: 6px 10px; - height: 32px; - font-size: 0.8rem; - border-radius: 8px; - font-weight: 500; - transition: all 0.3s ease; - box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11); - border: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - color: white; - white-space: nowrap; -} - -.btn-sm i, .service-btn i { - font-size: 1rem; -} - -/* 按钮颜色统一为蓝色 */ -.btn-primary, -.service-btn.start-btn, -.service-btn.stop-btn, -.service-btn.restart-btn { - background: linear-gradient(135deg, #3498db, #2980b9); -} - -/* 按钮悬停效果 */ -.btn-primary:hover, -.service-btn:hover { - transform: translateY(-2px); - box-shadow: 0 7px 14px rgba(50, 50, 93, 0.18); - filter: brightness(1.1); -} - -/* 按钮点击效果 */ -.btn-primary:active, -.service-btn:active { - transform: translateY(1px); -} - -/* 服务控制按钮组样式 */ -.service-actions { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; -} - -/* 进度条样式 */ -.progress { - height: 5px; - border-radius: 6px; - background: rgba(0, 0, 0, 0.05); - overflow: hidden; -} - -.progress-bar { - background: linear-gradient(90deg, var(--primary-color), #5dade2); - box-shadow: 0 0 10px rgba(52, 152, 219, 0.5); -} - -/* 卡片图标样式 */ -.card-icon { - position: absolute; - top: 15px; - right: 15px; - font-size: 1.2rem; - color: var(--primary-color); - opacity: 0.15; - transform: scale(2); -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .card.status-card, .card.service-control { - background: linear-gradient(165deg, #2d2d2d, #252525); - border: 1px solid rgba(255, 255, 255, 0.1); - } - - .card-title, .service-title { - color: #e0e0e0; - } - - .card-title i, .service-title i { - background: rgba(52, 152, 219, 0.15); - } - - .card-text { - color: #ffffff; - } - - .card-icon { - color: var(--primary-color); - opacity: 0.1; - } - - .progress { - background: rgba(255, 255, 255, 0.1); - } - - .btn-sm, .service-btn { - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); - } - - .btn-sm:hover, .service-btn:hover { - box-shadow: 0 7px 14px rgba(0, 0, 0, 0.3); - } -} - -/* 响应式布局 */ -@media (max-width: 768px) { - .status-cards { - grid-template-columns: 1fr; - gap: 20px; - } - - .card.status-card, .card.service-control { - height: auto; - min-height: 140px; - } - - .service-actions { - margin-top: 0.5rem; - } - - .btn-sm, .service-btn { - padding: 6px; - } -} - -/* 服务控制卡片特殊样式 */ -.service-control .card-body { - padding: 0; - justify-content: space-between; -} - -.service-control .service-title { - margin-bottom: 1rem; - font-size: 1.1rem; -} - -.service-actions { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - margin-top: auto; -} - -/* 服务控制按钮样式优化 */ -.service-btn { - padding: 6px 10px; - min-height: 32px; - font-size: 0.8rem; -} - -.service-btn .btn-content { - white-space: nowrap; -} - -.service-btn i { - font-size: 1rem; -} - -/* 添加信息卡片样式 */ -.info-card { - background: linear-gradient(165deg, #ffffff, #f8f9fa); - border-radius: 20px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - overflow: hidden; - position: relative; - border: 1px solid rgba(255, 255, 255, 0.8); - padding: 10px; - height: 100%; - min-height: 120px; -} - -.info-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); -} - -/* 地址容器样式 */ -.address-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-top: 0.4rem; -} - -.address-item { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - border: 1px solid rgba(0, 0, 0, 0.05); - min-height: 28px; -} - -.address-label { - display: flex; - align-items: center; - gap: 6px; - font-weight: 500; - color: var(--text-color); - min-width: 80px; - font-size: 0.8rem; -} - -.address-label i { - color: var(--primary-color); - font-size: 1rem; -} - -.address-value { - flex-grow: 1; - font-family: monospace; - font-size: 0.75rem; - color: var(--text-color); - padding: 2px 4px; - background: rgba(0, 0, 0, 0.03); - border-radius: 4px; -} - -.copy-btn { - background: none; - border: none; - color: var(--primary-color); - padding: 3px; - cursor: pointer; - transition: all 0.2s ease; - border-radius: 6px; - font-size: 0.85rem; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -.copy-btn:hover { - background: rgba(52, 152, 219, 0.1); - transform: scale(1.1); -} - -.copy-btn.copy-success { - color: #2ecc71; - background: rgba(46, 204, 113, 0.1); -} - -/* 关于信息样式 */ -.about-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-top: 0.4rem; -} - -.about-item { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - border: 1px solid rgba(0, 0, 0, 0.05); - min-height: 28px; -} - -.about-item i { - font-size: 1.1rem; - width: 20px; -} - -.about-content { - font-size: 0.85rem; -} - -.github-link { - color: var(--primary-color); - text-decoration: none; - display: flex; - align-items: center; - gap: 4px; - font-weight: 500; - font-size: 0.85rem; -} - -.github-link:hover { - text-decoration: underline; -} - -.wechat-name { - font-weight: 500; - color: var(--text-color); -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .info-card { - background: linear-gradient(165deg, #2d2d2d, #252525); - border: 1px solid rgba(255, 255, 255, 0.1); - } - - .address-item, .about-item { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.1); - } - - .address-value { - background: rgba(0, 0, 0, 0.2); - color: #ffffff; - } - - .copy-btn:hover { - background: rgba(52, 152, 219, 0.2); - } - - .wechat-name { - color: #e0e0e0; - } -} - -/* 响应式布局 */ -@media (max-width: 768px) { - .info-card { - margin-bottom: 12px; - min-height: auto; - padding: 10px; - } - - .address-item, .about-item { - padding: 6px 8px; - } -} - -/* 卡片标题样式优化 */ -.info-card .card-title { - font-size: 0.95rem; - margin-bottom: 0.6rem; - gap: 6px; -} - -.info-card .card-title i { - font-size: 1rem; - padding: 4px; -} - -/* 背景图标优化 */ -.info-card .card-icon { - font-size: 1.1rem; - transform: scale(1.8); - top: 12px; - right: 12px; -} - -/* 标签页卡片统一样式 */ -.tab-pane .card { - background: linear-gradient(165deg, #ffffff, #f8f9fa); - border-radius: 20px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - overflow: hidden; - position: relative; - border: 1px solid rgba(255, 255, 255, 0.8); - padding: 20px; -} - -.tab-pane .card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); -} - -/* 标签页标题样式 */ -.tab-pane .card-title { - font-size: 1.1rem; - font-weight: 600; - color: var(--text-color); - margin-bottom: 1.2rem; - display: flex; - align-items: center; - gap: 8px; -} - -.tab-pane .card-title i { - font-size: 1.2em; - color: var(--primary-color); - background: rgba(52, 152, 219, 0.1); - padding: 6px; - border-radius: 8px; -} - -/* 标签页背景图标 */ -.tab-pane .card-icon { - position: absolute; - top: 20px; - right: 20px; - font-size: 1.5rem; - color: var(--primary-color); - opacity: 0.15; - transform: scale(2.5); -} - -/* 按钮统一样式 */ -.tab-pane .btn { - padding: 8px 16px; - height: 36px; - font-size: 0.85rem; - border-radius: 8px; - font-weight: 500; - transition: all 0.3s ease; - box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11); - border: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - color: white; - white-space: nowrap; - background: linear-gradient(135deg, #3498db, #2980b9); -} - -.tab-pane .btn:hover { - transform: translateY(-2px); - box-shadow: 0 7px 14px rgba(50, 50, 93, 0.18); - filter: brightness(1.1); -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .tab-pane .card { - background: linear-gradient(165deg, #2d2d2d, #252525); - border: 1px solid rgba(255, 255, 255, 0.1); - } - - .tab-pane .card-title { - color: #e0e0e0; - } - - .tab-pane .card-title i { - background: rgba(52, 152, 219, 0.15); - } -} - -/* 代理管理标签页样式 */ -.proxy-management { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.proxy-actions { - display: flex; - gap: 12px; - margin-top: 1rem; -} - -.proxy-actions .btn { - padding: 10px 20px; - font-size: 0.9rem; - display: inline-flex; - align-items: center; - gap: 8px; - min-width: 120px; - justify-content: center; - border-radius: 8px; - transition: all 0.3s ease; -} - -.proxy-actions .btn i { - font-size: 1rem; -} - -/* IP名单标签页样式 */ -.ip-lists-container { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.ip-lists-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; - margin-bottom: 1rem; -} - -.ip-list-section { - display: flex; - flex-direction: column; - gap: 0.8rem; -} - -.ip-list-title { - font-size: 1rem; - font-weight: 600; - color: var(--text-color); - margin-bottom: 0.5rem; -} - -.ip-auth-settings { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--border-color); -} - -.ip-lists-actions { - display: flex; - gap: 12px; - margin-top: 1rem; -} - -.ip-lists-actions .btn { - padding: 10px 20px; - font-size: 0.9rem; - display: inline-flex; - align-items: center; - gap: 8px; - min-width: 120px; - justify-content: center; - border-radius: 8px; - transition: all 0.3s ease; -} - -/* 按钮悬停效果 */ -.proxy-actions .btn:hover, -.ip-lists-actions .btn:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .ip-list-title { - color: #e0e0e0; - } - - .ip-auth-settings { - border-top-color: rgba(255, 255, 255, 0.1); - } -} - -/* 代理列表区域样式 */ -.proxy-lists { - background: rgba(255, 255, 255, 0.5); - border-radius: 12px; - padding: 15px; - border: 1px solid rgba(0, 0, 0, 0.05); -} - -.proxy-list-section { - margin-bottom: 1rem; -} - -.proxy-list-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; - font-weight: 500; - color: var(--text-color); -} - -.proxy-list-header i { - color: var(--primary-color); - font-size: 1.1rem; -} - -.proxy-list-help { - margin-left: auto; - font-size: 0.85rem; - color: #666; - font-weight: normal; -} - -.proxy-list-section textarea { - font-family: monospace; - font-size: 0.9rem; - resize: vertical; - background: rgba(255, 255, 255, 0.8); -} - -.proxy-list-section textarea:focus { - background: white; -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .proxy-lists { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.1); - } - - .proxy-list-header { - color: #e0e0e0; - } - - .proxy-list-help { - color: #888; - } - - .proxy-list-section textarea { - background: rgba(0, 0, 0, 0.2); - color: #e0e0e0; - } - - .proxy-list-section textarea:focus { - background: rgba(0, 0, 0, 0.3); - } -} - -/* 地址显示样式 */ -.address-value { - font-family: monospace; - background: rgba(0, 0, 0, 0.05); - padding: 6px 10px; - border-radius: 4px; - font-size: 0.9rem; - color: var(--text-color); - flex-grow: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* 暗色模式适配 */ -@media (prefers-color-scheme: dark) { - .address-value { - background: rgba(255, 255, 255, 0.1); - color: #ffffff; - } - - .copy-btn:hover { - background: rgba(52, 152, 219, 0.2); - } - - .copy-btn.copy-success { - background: rgba(46, 204, 113, 0.2); - } -} - -/* 服务状态样式 */ -.service-status { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 0.7rem; -} - -.status-indicator { - display: flex; - align-items: center; - gap: 8px; -} - -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - transition: background-color 0.3s ease; -} - -.status-dot.running { - background-color: #2ecc71; - box-shadow: 0 0 10px rgba(46, 204, 113, 0.5); - animation: pulse 2s infinite; -} - -.status-dot.stopped { - background-color: #e74c3c; - box-shadow: 0 0 10px rgba(231, 76, 60, 0.5); - animation: none; -} - -.status-text { - font-weight: 600; - color: var(--text-color); - transition: color 0.3s ease; -} - -/* 添加脉冲动画 */ -@keyframes pulse { - 0% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.2); opacity: 0.8; } - 100% { transform: scale(1); opacity: 1; } -} - -/* 服务控制卡片的按钮容器 */ -.status-card .service-actions { - display: flex; - gap: 8px; - justify-content: center; - margin-top: auto; -} - -/* 服务控制按钮基础样式 */ -.status-card .service-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 6px 12px; - border: none; - border-radius: 6px; - color: white; - transition: all 0.3s ease; - font-size: 0.85rem; - min-width: 70px; - height: 32px; - background: linear-gradient(135deg, #3498db, #2980b9); -} - -/* 服务控制按钮交互效果 */ -.status-card .service-btn:hover { - transform: translateY(-2px); - filter: brightness(1.1); - box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); -} - -.status-card .service-btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - background: linear-gradient(135deg, #95a5a6, #7f8c8d); -} - -/* 响应式布局 */ -@media (max-width: 768px) { - .status-card .service-actions { - flex-direction: column; - gap: 8px; - } - - .status-card .service-btn { - width: 100%; - } -} - -/* 添加过渡效果 */ -.status-text { - transition: color 0.3s ease; -} - -/* 优化按钮状态过渡 */ -.service-btn { - transition: all 0.3s ease; -} - -.service-btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..d90a4bb --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,2542 @@ +/** + * 模块名称:面板外壳与「代理配置 / 访问控制 / 运行日志」页(app.js) + * 功能描述:面板骨架与三个自持视图:侧栏导航与 hash 路由、主题切换、toast 通知中心、通用弹窗、剪贴板、 + * 全局请求反馈与 ajax 错误兜底、代理配置页的表单与名单、运行日志页的筛选与访问统计、出口气泡与广告位。 + * 职责边界:负责:面板外壳与配置 / 访问控制 / 运行日志三个页签。不负责:池页签的业务与代理列表 + * (pool.js、proxies.js)、浮层组件与表格状态占位(tables.js)、快捷键面板(palette.js)。 + * 关键依赖:jQuery;i18n.js 的 T() / onLangRender / initLang;common.js 的 escapeHtml 等工具; + * tables.js 的表格占位、抽屉与键盘函数;pool.js 与 proxies.js 的池函数(tables.js 反向 + * 调用本文件的 _overlayOpen / _overlayClosed);脚本加载顺序由 index.html 固定。 + * 已知限制: + * 1. 脚本顺序为 jquery→i18n→common→tables→app→pool→proxies→palette;typeof 守卫只覆盖少量可选模块调用。 + * 2. 视图切换只有 switchTab 一个入口,首次进入无 hash 也要走一遍;hash 写入走 replaceState,applyHash 必须幂等。 + * 3. 本文件自持 /api/status 与日志轮询,池状态轮询归 pool.js;后台轮询失败只计数,判据须精确路径(indexOf 会吞掉主动请求的失败)。 + * 4. 按钮三态:flashSaved / flashFailed 必须在 btnLoad(btn, false) 之后调,否则被 .loading 守卫跳过。 + * 5. 动态拼 HTML 的用户可控文本必须经 escapeHtml 或 .text() 写入;用户名与密码不得拼进内联 onclick。 + * 6. 主题无存档时跟随系统且不写存档,仅手动切换才持久化;index.html 首帧内联脚本判据须与 initTheme 一致。 + * 7. 密码列打码时可见文本是圆点,collectUsers 必须读 .user-pass 的 data-pass,否则会把掩码存回。 + * 8. loadUsers 失败时若清空表格,随后的保存会用空表覆盖真实账号(等于关掉认证),须保留旧行。 + */ + +var _focusReturn = null; + +function _focusablesIn(sel) { + return $(sel).find('a[href], button:not(:disabled), input:not(:disabled), ' + + 'select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])') + .filter(':visible'); +} + +function _overlayOpen(sel, focusTarget, lockScroll) { + if (_focusReturn === null) _focusReturn = document.activeElement; + if (lockScroll) { + var sbw = window.innerWidth - document.documentElement.clientWidth; + $('body').addClass('modal-open'); + if (sbw > 0) $('body').css('padding-right', sbw + 'px'); + } + setTimeout(function () { + var $t = (focusTarget && $(focusTarget).length) ? $(focusTarget) : _focusablesIn(sel).first(); + if ($t.length) $t.trigger('focus'); + }, 60); +} + +function _overlayClosed() { + if ($('.modal-overlay.show, #row-drawer.show').length) return; + $('body').removeClass('modal-open').css('padding-right', ''); + if (_focusReturn && document.body.contains(_focusReturn)) $(_focusReturn).trigger('focus'); + _focusReturn = null; +} + +$(document).on('keydown', function (e) { + if (e.key !== 'Tab') return; + var $box = $('.modal-overlay.show, #row-drawer.show').last(); + if (!$box.length) return; + var $items = _focusablesIn('#' + $box.attr('id')); + if (!$items.length) return; + var first = $items[0], last = $items[$items.length - 1]; + if (e.shiftKey && (document.activeElement === first || !$box[0].contains(document.activeElement))) { + e.preventDefault(); last.focus(); + } else if (!e.shiftKey && (document.activeElement === last || !$box[0].contains(document.activeElement))) { + e.preventDefault(); first.focus(); + } +}); + +function openModal(id) { + $('#' + id).addClass('show'); + _overlayOpen('#' + id, null, true); +} + +function closeModal(id) { + $('#' + id).removeClass('show'); + _overlayClosed(); +} + +function copyToClipboard(text, onDone, onFail) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(onDone, function () { + _copyFallback(text, onDone, onFail); + }); + return; + } + _copyFallback(text, onDone, onFail); +} + +function _copyFallback(text, onDone, onFail) { + if (copyViaTextarea(text)) onDone(); + else if (onFail) onFail(); + else window.prompt(T().copy_manually, text); +} + +var TOAST_LIMIT = 4; +var TOAST_TTL = { ok: 3200, info: 3800, warn: 5200, err: 7000 }; +var _toastLive = []; +var logTimer = null; + +function _toastType(t) { + if (t === 'error') return 'err'; + if (t === 'warn') return 'warn'; + if (t === 'info') return 'info'; + return 'ok'; +} + +function _toastIcon(type) { + if (type === 'err') return 'fa-times-circle'; + if (type === 'warn') return 'fa-exclamation-triangle'; + if (type === 'info') return 'fa-info-circle'; + return 'fa-check-circle'; +} + +function _toastDrop(entry) { + var at = _toastLive.indexOf(entry); + if (at >= 0) _toastLive.splice(at, 1); + clearTimeout(entry.timer); + entry.timer = null; + entry.el.classList.add('leaving'); + setTimeout(function () { + if (entry.el.parentNode) entry.el.parentNode.removeChild(entry.el); + }, 220); +} + +function _toastArm(entry) { + clearTimeout(entry.timer); + entry.timer = setTimeout(function () { _toastDrop(entry); }, entry.ttl); +} + +function dismissToast() { + _toastLive.slice().forEach(_toastDrop); +} + +function _toastRestartBar(el, ttl) { + var old = el.querySelector('.toast-bar'); + if (!old) return; + var bar = old.cloneNode(false); + bar.style.animationDuration = ttl + 'ms'; + old.parentNode.replaceChild(bar, old); +} + +function toast(m, t) { + m = m || (t === 'error' ? T().operation_failed_bare : T().operation_success); + var type = _toastType(t); + var stack = document.getElementById('toast-stack'); + if (!stack) return; + + for (var i = 0; i < _toastLive.length; i++) { + var live = _toastLive[i]; + if (live.type !== type || live.text !== m) continue; + live.count++; + live.el.querySelector('.toast-count').textContent = '×' + live.count; + _toastRestartBar(live.el, live.ttl); + _toastArm(live); + return; + } + + var el = document.createElement('div'); + el.className = 'toast ' + type; + el.innerHTML = + '' + + '' + + '' + + '' + + ''; + el.querySelector('.toast-msg').textContent = m; + var close = el.querySelector('.toast-x'); + close.setAttribute('aria-label', T().drawer_close); + + var entry = { el: el, type: type, text: m, count: 1, ttl: TOAST_TTL[type], timer: null }; + close.addEventListener('click', function () { _toastDrop(entry); }); + el.addEventListener('mouseenter', function () { + clearTimeout(entry.timer); + entry.timer = null; + }); + el.addEventListener('mouseleave', function () { + if (!entry.timer) _toastArm(entry); + }); + + stack.appendChild(el); + _toastLive.push(entry); + while (_toastLive.length > TOAST_LIMIT) _toastDrop(_toastLive[0]); + _toastArm(entry); +} + +function btnLoad(btn, loading) { + btn.prop('disabled', loading).toggleClass('loading', loading); +} + +function markSavedHint(btn) { + var bar = $(btn).closest('.cfg-actions'); + if (!bar.length) return; + bar.find('.dirty-hint').hide(); + bar.find('.saved-hint') + .removeAttr('data-idle-hint') + .html(' ' + + escapeHtml(T().saved_at_hint + T().sep_colon + new Date().toLocaleTimeString())) + .show(); +} + +function idleSavedHintHtml() { + return ' ' + escapeHtml(T().saved_at_hint); +} + +function refreshIdleSavedHints() { + $('.saved-hint[data-idle-hint]').html(idleSavedHintHtml()); +} + +onLangRender(refreshIdleSavedHints); + +function setActionsIdle(selector, idle) { + var bar = $(selector).closest('.cfg-actions'); + if (!bar.length) return; + bar.find('button').attr('aria-disabled', idle ? 'true' : null); +} + +var _stickySyncRaf = null; + +function syncStickyBars() { + $('.cfg-actions.sticky').each(function () { + var bar = $(this); + if (!bar.is(':visible')) { bar.removeClass('stuck'); return; } + var offset = parseFloat(window.getComputedStyle(this).bottom) || 0; + bar.toggleClass('stuck', + this.getBoundingClientRect().bottom >= window.innerHeight - offset - 1); + }); +} + +function scheduleStickySync() { + if (_stickySyncRaf) return; + _stickySyncRaf = window.requestAnimationFrame(function () { + _stickySyncRaf = null; + syncStickyBars(); + }); +} + +$(window).on('scroll resize', scheduleStickySync); + +function flashSaved(btn) { + var el = $(btn); + if (!el.length || el.hasClass('loading')) return; + el.addClass('done'); + setTimeout(function () { el.removeClass('done'); }, 1200); +} + +function flashResult(btn, ok) { + var el = $(btn); + if (!el.length) return; + btnLoad(el, false); + if (ok) flashSaved(el); else flashFailed(el); +} + +function flashFailed(btn) { + var el = $(btn); + if (!el.length || el.hasClass('loading')) return; + el.addClass('failed'); + setTimeout(function () { el.removeClass('failed'); }, 1600); +} + + +var _modalCallback = null; + +function showModal(opts) { + var o = opts || {}; + $('#modal-icon').attr('class', 'modal-icon ' + (o.icon || 'info')); + $('#modal-icon i').attr('class', 'fa ' + (o.iconClass || 'fa-question-circle')); + $('#modal-title').text(o.title || ''); + $('#modal-body').text(o.body || ''); + var input = $('#modal-input'); + if (o.input) { + input.show().val(o.value || '').attr('placeholder', o.placeholder || ''); + setTimeout(function () { input.focus(); }, 150); + } else { + input.hide().val(''); + } + $('#modal-cancel').toggle(!!o.showCancel); + $('#modal-ok').find('span').text(o.okText || T().modal_confirm); + $('#modal-cancel').find('span').text(o.cancelText || T().modal_cancel); + $('#modal-ok').toggleClass('bad', !!o.danger).toggleClass('pri', !o.danger); + $('#modal-overlay').addClass('show'); + _modalCallback = o.callback || null; + _overlayOpen('#modal-overlay', o.input ? input : $('#modal-ok'), true); + + $('#modal-ok').off('click').on('click', function () { + var val = o.input ? input.val().trim() : true; + closeModal('modal-overlay'); + if (_modalCallback) { _modalCallback(val); _modalCallback = null; } + }); + $('#modal-cancel').off('click').on('click', function () { + closeModal('modal-overlay'); + if (_modalCallback) { _modalCallback(null); _modalCallback = null; } + }); + + input.off('keydown').on('keydown', function (e) { + if (e.key === 'Enter') $('#modal-ok').click(); + if (e.key === 'Escape') $('#modal-cancel').click(); + }); + $(document).off('keydown.modal').on('keydown.modal', function (e) { + if (e.key === 'Escape' && !o.input) $('#modal-cancel').click(); + }); +} + +function showConfirm(title, body, cb, opts) { + showModal({ + icon: 'warning', iconClass: 'fa-exclamation-triangle', + title: title, body: body, showCancel: true, + danger: !!(opts && opts.danger), + okText: T().modal_confirm, cancelText: T().modal_cancel, + callback: function (ok) { if (cb) cb(ok === true); } + }); +} + +function showPrompt(title, body, value, placeholder, cb) { + showModal({ + icon: 'info', iconClass: 'fa-pencil', + title: title, body: body, input: true, + value: value || '', placeholder: placeholder || '', + showCancel: true, + okText: T().modal_confirm, cancelText: T().modal_cancel, + callback: function (val) { if (cb) cb(val); } + }); +} + + +function nextTheme() { + return document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; +} + +function cycleTheme() { + setTheme(nextTheme()); +} + +function setTheme(t) { + document.documentElement.setAttribute('data-theme', t); + renderThemeLabel(); + safeSetItem('proxycat-theme', t); +} + +function renderThemeLabel() { + onLangRender(renderThemeLabel); + var isDark = nextTheme() === 'dark'; + var icon = isDark ? 'fa-sun-o' : 'fa-moon-o'; + $('#theme-btn i').attr('class', 'fa ' + icon); + $('#theme-label').text(isDark ? T().theme_toggle_light : T().theme_toggle_dark); +} + +function initTheme() { + var saved = safeGetItem('proxycat-theme'); + if (saved) { setTheme(saved); return; } + var prefersDark = window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); + renderThemeLabel(); +} + + +function _startLogRefresh() { + if (isPolling('log')) return; + updateLogs(); + startPolling('log', updateLogs, 2000); + loadLogFiles(); + _startDomainRefresh(); +} + +function _stopLogRefresh() { + stopPolling('log'); + _stopDomainRefresh(); +} +var TAB_STORAGE_KEY = 'proxycat-active-tab'; +var CONFIG_GROUP_STORAGE_KEY = 'proxycat-config-group'; + +var VIEW_SLUGS = { + 'tab-config': 'config', + 'tab-pool': 'pool', + 'tab-access': 'access', + 'tab-logs': 'logs' +}; +var POOL_VIEW_SLUGS = { + 'pool-view-manage': 'manage', + 'pool-view-settings': 'settings', + 'pool-view-db': 'db' +}; +var CONFIG_GROUPS = ['source', 'listen', 'exits', 'check', 'perf', 'logs', 'advanced']; +var VIEW_TITLE_KEYS = { + 'tab-config': 'config_tab', + 'tab-pool': 'pool_tab', + 'tab-access': 'access_control_tab', + 'tab-logs': 'logs_tab' +}; +var POOL_VIEW_SUB_KEYS = { + 'pool-view-manage': 'view_sub_pool_manage', + 'pool-view-settings': 'view_sub_pool_settings', + 'pool-view-db': 'view_sub_pool_db' +}; + +function currentHash() { + var tabId = $('.tab-body.show').attr('id') || 'tab-config'; + if (tabId === 'tab-config') { + return '#/config/' + (safeGetItem(CONFIG_GROUP_STORAGE_KEY) || 'source'); + } + if (tabId === 'tab-pool') { + var view = safeGetItem(POOL_VIEW_STORAGE_KEY); + return '#/pool/' + (POOL_VIEW_SLUGS[view] || 'manage'); + } + return '#/' + (VIEW_SLUGS[tabId] || 'config'); +} + +function writeHash() { + var next = currentHash(); + if (location.hash === next) return; + history.replaceState(null, '', location.pathname + location.search + next); +} + +function parseHash() { + var m = /^#\/([a-z]+)(?:\/([a-z]+))?/.exec(location.hash || ''); + if (!m) return null; + var tabId = null; + for (var k in VIEW_SLUGS) { if (VIEW_SLUGS[k] === m[1]) tabId = k; } + return tabId ? { tab: tabId, section: m[2] || null } : null; +} + +function applyHash() { + var r = parseHash(); + if (!r) return false; + if (r.tab === 'tab-config') { + switchTab('tab-config', { group: CONFIG_GROUPS.indexOf(r.section) >= 0 ? r.section : null }); + } else if (r.tab === 'tab-pool') { + var view = null; + for (var k in POOL_VIEW_SLUGS) { if (POOL_VIEW_SLUGS[k] === r.section) view = k; } + switchTab('tab-pool', { view: view }); + } else { + switchTab(r.tab); + } + return true; +} + +function toggleSidebar(open) { + var isOpen = open === undefined ? !$('body').hasClass('sb-open') : !!open; + $('body').toggleClass('sb-open', isOpen); + $('#sidebar').toggleClass('open', isOpen); +} + +function syncSidebarActive(tabId, view) { + $('#sb-nav .sb-item').removeClass('cur'); + $('#sb-nav .sb-item[data-tab="' + tabId + '"]').addClass('cur'); + if (tabId === 'tab-pool' && view) { + $('#sb-nav .sb-item[data-view="' + view + '"]').addClass('cur'); + } +} + +function renderViewHead() { + onLangRender(renderViewHead); + var tabId = $('.tab-body.show').attr('id') || 'tab-config'; + var titleKey = VIEW_TITLE_KEYS[tabId] || 'title'; + var subKey = 'view_sub_' + (VIEW_SLUGS[tabId] || 'config'); + if (tabId === 'tab-pool') { + var view = safeGetItem(POOL_VIEW_STORAGE_KEY); + if (POOL_VIEW_SUB_KEYS[view]) subKey = POOL_VIEW_SUB_KEYS[view]; + } + $('#view-title').attr('data-i18n', titleKey).text(T()[titleKey]); + $('#view-sub').text(T()[subKey]); +} + +function switchTab(tabId, opts) { + var o = opts || {}; + if (!$('#' + tabId).length) return; + + $('.tab-body').removeClass('show'); + $('#' + tabId).addClass('show'); + $('body').attr('data-tab', tabId); + safeSetItem(TAB_STORAGE_KEY, tabId); + + if (tabId === 'tab-config') { + showConfigGroup(o.group || safeGetItem(CONFIG_GROUP_STORAGE_KEY) || 'source'); + } + + var view = null; + if (tabId === 'tab-pool') { + view = o.view || safeGetItem(POOL_VIEW_STORAGE_KEY) || 'pool-view-manage'; + switchPoolView(view); + } + + syncSidebarActive(tabId, view); + renderViewHead(); + + if (tabId === 'tab-logs') _startLogRefresh(); else _stopLogRefresh(); + if (tabId === 'tab-pool') { + startPoolPolling(); + restoreFiltersFromStorage(); + if (poolReady()) { + loadPlugins(); + loadSources(); + loadProxies(); + } + } else { + stopPoolPolling(); + } + + writeHash(); + toggleSidebar(false); + window.scrollTo(0, 0); + scheduleStickySync(); +} + +$(document).on('click', '.sb-item[data-tab]', function () { + switchTab($(this).data('tab')); +}); + +$(document).on('click', '.sb-item[data-view]', function () { + var view = $(this).data('view'); + if ($('#tab-pool').hasClass('show')) { + switchPoolView(view); + toggleSidebar(false); + } else { + switchTab('tab-pool', { view: view }); + } +}); + +function initSidebar() { + $(window).on('resize', function () { + if (window.innerWidth > 1024) toggleSidebar(false); + }); + $(window).on('hashchange', function () { applyHash(); }); +} + +function restoreActiveView() { + if (applyHash()) return; + var saved = safeGetItem(TAB_STORAGE_KEY); + switchTab(saved && $('#' + saved).length ? saved : 'tab-config'); +} + + +function controlService(a) { + if (a !== 'start') { + showConfirm(T().svc_confirm_title, + a === 'stop' ? T().svc_stop_confirm_body : T().svc_restart_confirm_body, + function (ok) { if (ok) _doControlService(a); }); + return; + } + _doControlService(a); +} + +function _doControlService(a) { + var btn = $('#svc-' + a), ok = false; + btnLoad(btn, true); + $.ajax({ + url: appendToken('/api/service'), method: 'POST', contentType: 'application/json', + data: JSON.stringify({ action: a }), + success: function (r) { + if (r.status === 'success') { ok = true; updateStatusAndGauge(); toast(r.message, 'success'); } + else toast(r.message, 'error'); + }, + error: function (x) { toast(fillTemplate(T().operation_failed, jqErrorText(x, T().network_error)), 'error'); }, + complete: function () { flashResult(btn, ok); } + }); +} + +function svcBtns(s) { + var r = s === 'running'; + $('#svc-start').prop('disabled', r); + $('#svc-stop, #svc-restart').prop('disabled', !r); +} + +function toggleExitPop() { + $('#exit-pop').toggleClass('show'); + $('#tag-exits').toggleClass('on', $('#exit-pop').hasClass('show')); +} + +function closeExitPop() { + $('#exit-pop').removeClass('show'); + $('#tag-exits').removeClass('on'); +} + +$(document).on('mousedown', function (e) { + if (!$('#exit-pop').hasClass('show')) return; + if ($(e.target).closest('#exit-pop, #tag-exits').length) return; + closeExitPop(); +}); + +function renderSidebarStatus(serviceRunning, poolRunning) { + $('#sb-svc-dot').attr('class', 'dot ' + (serviceRunning ? 'on' : 'off')); + $('#sb-svc-text').text(T().service_status[serviceRunning ? 'running' : 'stopped'] || (serviceRunning ? 'running' : 'stopped')); + $('#sb-pool-dot').attr('class', 'dot ' + (poolRunning ? 'on' : 'off')); + $('#sb-pool-text').text(T().service_status[poolRunning ? 'running' : 'stopped'] || (poolRunning ? 'running' : 'stopped')); +} + + +$(document).on('click', '[data-copy]:not([data-act])', function () { + var text = $(this).attr('data-copy') || ''; + if (!text) return; + copyToClipboard(text, function () { + toast(T().addr_copied + T().sep_colon + text, 'success'); + }, function () { + toast(T().copy_failed_manual, 'error'); + }); +}); + + +function copyAddr(t) { + var e = t === 'http' ? $('#addr-http') : $('#addr-socks5'); + var x = e.find('span').text(); + var done = function () { + e.addClass('copied'); + toast(T().addr_copied + T().sep_colon + x, 'success'); + setTimeout(function () { e.removeClass('copied'); }, 1500); + }; + copyToClipboard(x, done, function () { + toast(T().copy_failed_manual, 'error'); + }); +} + +function collectUsers() { + var users = {}; + $('#user-table tbody tr').each(function () { + var tds = $(this).find('td'); + var $pass = tds.eq(1).find('.user-pass'); + users[tds.eq(0).text()] = $pass.length ? ($pass.attr('data-pass') || '') : tds.eq(1).text(); + }); + return users; +} + +function pickAuth() { + var users = collectUsers(); + var names = Object.keys(users).filter(function (n) { return n; }); + var name = names.length ? names[0] : ''; + return (name && users[name]) ? name + ':' + users[name] + '@' : ''; +} + +function updAddr(c) { + var port = c.port || '1080', a = pickAuth(); + $('#http-addr').text('http://' + a + '127.0.0.1:' + port); + $('#socks5-addr').text('socks5://' + a + '127.0.0.1:' + port); +} + + + +function startGlobalPolling() { + if (isPolling('status')) return; + startPolling('status', updateStatusAndGauge, 1000); +} + +function pauseGlobalPolling() { + stopPolling('status'); + if (typeof _stopLogRefresh === 'function') _stopLogRefresh(); + if (typeof stopPoolPolling === 'function') stopPoolPolling(); +} + +function exitRemainingText(e, cm) { + var parts = []; + if (cm === 'time' && e.expires_in !== null && e.expires_in !== undefined) { + parts.push(escapeHtml(Math.ceil(e.expires_in)) + T().seconds); + } + if ((cm === 'time' || cm === 'request_count') + && e.requests_left !== null && e.requests_left !== undefined) { + parts.push(escapeHtml(e.requests_left) + T().times); + } + return parts.length ? parts.join(' · ') : '--'; +} + +function _sinceText(iso) { + if (!iso) return ''; + var at = Date.parse(iso); + if (isNaN(at)) return ''; + return fillTemplate(T().source_error_ago, fmtDuration((Date.now() - at) / 1000)); +} + +function renderExitRows(exits, cm) { + var $list = $('#exit-list'), $rows = $('#exit-rows'); + if (!exits || !exits.length) { $rows.empty(); $list.hide(); return; } + $rows.html(exits.map(function (e) { + var load = e.capacity > 0 + ? escapeHtml(e.active) + '/' + escapeHtml(e.capacity) + : fillTemplate(T().exit_load_unlimited, e.active); + var flags = ''; + if (e.suspect) { + flags += ''; + } + if (e.failures > 0) { + flags += '' + + escapeHtml(e.failures) + ''; + } + var tip = e.requests_served + ? fillTemplate(T().exit_served, e.requests_served) + : ''; + return '
' + + '' + escapeHtml(e.url) + '' + flags + + '' + load + '' + + '' + exitRemainingText(e, cm) + '' + + '
'; + }).join('')); + $list.show(); +} + +function updateStatusAndGauge() { + onLangRender(updateStatusAndGauge); + $.get(appendToken('/api/status'), function (d) { + var s = d.service_status || 'stopped', r = s === 'running'; + $('#svc-dot').attr('class', 'dot ' + (r ? 'on' : 'off')); + $('#svc-text').text(T().service_status[s] || s); + renderSidebarStatus(r, !!d.pool_running); + svcBtns(s); + var m = d.proxy_source_mode || 'local', modes = { + local: T().proxy_source_mode_local, api: T().proxy_source_mode_api, pool: T().proxy_source_mode_pool + }; + $('#tag-source').text((modes[m] || m)); + if (m === 'local') $('#tag-mode').show().text(T().proxy_mode[d.mode] || d.mode || '--'); + else $('#tag-mode').hide(); + var iv = parseInt(d.interval) || 0; + var cm = d.switch_countdown_mode || 'time'; + var exitList = d.active_proxies || []; + var exitText = fillTemplate( + d.elastic_active ? T().upstream_exits_expanding : T().upstream_exits, exitList.length); + if (exitList.length > 1) $('#tag-exits').show().text(exitText); + else $('#tag-exits').hide(); + renderExitRows(exitList, cm); + if (!exitList.length) closeExitPop(); + if (cm === 'per_request') { $('#tag-interval').text(T().pool_per_request_swap); $('#tip-zero').show(); } + else if (cm === 'request_count') { $('#tag-interval').text((parseInt(d.request_interval) || 0) + ' ' + T().times); $('#tip-zero').hide(); } + else { $('#tag-interval').text(iv + T().seconds); $('#tip-zero').hide(); } + if (d.source_error && d.source_error.message) { + $('#tip-source-error-text').text( + fillTemplate(T().source_error_hint, d.source_error.message) + + _sinceText(d.source_error.at)); + $('#tip-source-error').show(); + } else { + $('#tip-source-error').hide(); + } + updAddr(d); + + if (cm === 'none') { $('#gauge-bar').css('width', '100%'); $('#next-switch').text('--'); return; } + if (cm === 'per_request') { $('#gauge-bar').css('width', '100%'); $('#next-switch').text(T().pool_per_request_swap); return; } + if (!exitList.length) { + $('#gauge-bar').css('width', '0%'); + $('#next-switch').text(T().pool_waiting_exits); + return; + } + var tl = Math.max(0, d.time_left || 0); + if (tl <= 0) { + $('#gauge-bar').css('width', '100%'); + $('#next-switch').text(T().pool_rotate_waiting); + return; + } + if (cm === 'request_count') { + var ri = parseInt(d.request_interval) || 0; + if (ri <= 0) { $('#gauge-bar').css('width', '100%'); $('#next-switch').text('--'); return; } + $('#next-switch').text(Math.ceil(tl) + ' ' + T().times); + $('#gauge-bar').css('width', Math.min(100, ((ri - tl) / ri) * 100) + '%'); + return; + } + $('#next-switch').text(Math.ceil(tl) + ' ' + T().seconds); + $('#gauge-bar').css('width', (iv > 0 ? Math.min(100, ((iv - tl) / iv) * 100) : 0) + '%'); + }); +} + + +function setSourceMode(mode) { + $('#source-mode-group .source-mode-btn').removeClass('active'); + $('#source-mode-group .source-mode-btn[data-mode="' + mode + '"]').addClass('active'); + $('#proxy-source-mode').val(mode); + onSourceChange(); + updateServerDirtyState(); +} + +function loadConfig(opts) { + var o = opts || {}; + $.get(appendToken('/api/config'), function (d) { + if (!o.poolOnly) applyServerConfigToForm(d.server || {}); + if (!o.serverOnly && typeof applyPoolConfigToForm === 'function') { + applyPoolConfigToForm(d.pool || {}, o.force); + } + }); +} + +function discardServerChanges() { + if (!serverFormDirty()) { toast(T().no_changes, 'error'); return; } + loadConfig({ serverOnly: true }); +} + +var SERVER_FORM_DEFAULTS = { + proxy_source_mode: 'local', proxy_file: 'ip.txt', mode: 'cycle', + api_proxy_url: '', proxy_username: '', proxy_password: '', pool_remote_url: '', + client_idle_timeout: '300', + max_pool_size: '500', + client_max_connections: '1000', + port: '1080', interval: '300', request_interval: '0', exit_count: '5', + test_url: 'https://www.baidu.com', ip_auth_priority: 'whitelist', log_level: 'INFO', + check_proxies_on_startup: 'True', check_proxies_on_use: 'True', + log_access_enabled: 'true', access_records_enabled: 'true', domain_stats_enabled: 'true', + access_records_real_ip_probe: 'false', + access_records_retention_days: '7', access_records_max_rows: '200000', + domain_stats_retention_days: '30', + exit_wait_timeout: '15', max_concurrent_per_proxy: '0', + auto_expand_enabled: 'true', + client_idle_timeout: '300', client_max_keepalive: '8', + client_max_connections: '1000', client_keepalive_expiry: '30', + max_concurrent_requests: '1000', max_pool_size: '500', buffer_size: '8192', + check_concurrency: '50', tunnel_idle_timeout: '10', web_port: '5001', + proxy_check_ttl: '60', check_cooldown: '10', proxy_failure_cooldown: '3', + switch_cooldown: '2', access_records_flush_interval: '5', + access_records_buffer_size: '20000', domain_stats_flush_interval: '15', + log_max_bytes: '10485760', log_backup_count: '3', + whitelist_file: 'whitelist.txt', blacklist_file: 'blacklist.txt', + bypass_whitelist_file: 'bypass_whitelist.txt', display_level: '1' +}; + +var SERVER_CHECKBOX_FIELDS = { + check_proxies_on_startup: true, check_proxies_on_use: true, + log_access_enabled: true, access_records_enabled: true, + access_records_real_ip_probe: true, + domain_stats_enabled: true, auto_expand_enabled: true +}; + +var _serverFormBaseline = null; + +function _serverField(key) { return $('[name="' + key + '"]'); } + +function _isServerCheckbox(key) { return SERVER_CHECKBOX_FIELDS[key] === true; } + +function _normalizeDisplayLevel(value) { + var n = parseInt(value, 10); + if (isNaN(n) || n < 0) return SERVER_FORM_DEFAULTS.display_level; + return n >= 2 ? '2' : String(n); +} + +function _writeServerField(key, rawValue) { + var missing = (rawValue === undefined || rawValue === null || rawValue === ''); + var value = missing ? SERVER_FORM_DEFAULTS[key] : rawValue; + if (_isServerCheckbox(key)) { + _serverField(key).prop('checked', String(value).toLowerCase() === 'true'); + return; + } + if (key === 'display_level') value = _normalizeDisplayLevel(value); + _serverField(key).val(key === 'log_level' ? String(value).toUpperCase() : value); +} + +function _readServerField(key) { + if (_isServerCheckbox(key)) return _serverField(key).prop('checked').toString(); + return _serverField(key).val(); +} + +function applyServerConfigToForm(c) { + var m = c.proxy_source_mode || 'local'; + $('#source-mode-group .source-mode-btn').removeClass('active'); + $('#source-mode-group .source-mode-btn[data-mode="' + m + '"]').addClass('active'); + syncModeField(m); + for (var key in SERVER_FORM_DEFAULTS) _writeServerField(key, c[key]); + if ($('#proxy-auth').length) { + $('#proxy-auth').val(_joinProxyAuth(c.proxy_username, c.proxy_password)); + } + syncProxyAuthHint(); + onSourceChange(); onIntervalChange(); updAddr(c); + markServerFormClean(); + updateLogLevelWarning(); +} + +function getSourceMode() { return _serverField('proxy_source_mode').val() || 'local'; } + +function _splitProxyAuth(text) { + var raw = String(text === null || text === undefined ? '' : text); + var at = raw.indexOf(':'); + if (at === -1) return { username: raw, password: '' }; + return { username: raw.slice(0, at), password: raw.slice(at + 1) }; +} + +function _joinProxyAuth(username, password) { + var user = String(username || ''); + return user ? (user + ':' + String(password || '')) : ''; +} + +function syncProxyAuthHint() { + var text = String($('#proxy-auth').val() || ''); + $('#proxy-auth-warn').toggle(text !== '' && text.indexOf(':') === -1); +} + +function collectServerConfig() { + var values = {}; + for (var key in SERVER_FORM_DEFAULTS) { + if (!_serverField(key).length) continue; + values[key] = _readServerField(key); + } + if ($('#proxy-auth').length) { + var auth = _splitProxyAuth($('#proxy-auth').val()); + values.proxy_username = auth.username; + values.proxy_password = auth.password; + } + var idle = parseInt(values.client_keepalive_expiry, 10); + values.client_idle_timeout = String(Math.max(isNaN(idle) ? 0 : idle, 30)); + return values; +} + +function serverConfigChanges() { + var current = collectServerConfig(); + var changes = {}; + for (var key in current) { + if (String(current[key]) !== String(_serverFormBaseline ? _serverFormBaseline[key] : undefined)) { + changes[key] = current[key]; + } + } + return changes; +} + +function markServerFormClean() { + _serverFormBaseline = collectServerConfig(); + updateServerDirtyState(); +} + +function syncServerBaseline(keys) { + if (!_serverFormBaseline) return; + var current = collectServerConfig(); + keys.forEach(function (key) { + _serverFormBaseline[key] = (key in current) ? current[key] : _readServerField(key); + }); + updateServerDirtyState(); +} + +function serverFormDirty() { + return Object.keys(serverConfigChanges()).length > 0; +} + +function updateServerDirtyState() { + var ready = _serverFormBaseline !== null; + var dirty = ready && serverFormDirty(); + $('#server-dirty-hint').toggle(dirty); + $('#btn-save-config').toggleClass('dirty', dirty); + + var saved = $('#server-saved-hint'); + if (dirty) { + saved.hide(); + } else if (ready) { + if (!saved.children().length) saved.html(idleSavedHintHtml()).attr('data-idle-hint', '1'); + saved.show(); + } + setActionsIdle('#btn-save-config', ready && !dirty); + + if (!ready) return; + $('#config-nav .settings-nav-item').each(function () { + var group = $(this).data('group'); + var groupDirty = $('#tab-config .settings-group[data-group="' + group + '"]') + .find('[name]').toArray().some(function (el) { + if (!(el.name in SERVER_FORM_DEFAULTS)) return false; + return String(_readServerField(el.name)) !== String(_serverFormBaseline[el.name]); + }); + $(this).find('.settings-dirty-dot').toggle(groupDirty); + }); +} + +$(document).on('input change', '#tab-config [name]', updateServerDirtyState); + +function updateLogLevelWarning() { + var level = String(_serverField('log_level').val() || 'INFO').toUpperCase(); + var suppressesAccessLog = level !== 'DEBUG' && level !== 'INFO'; + var recordsEnabled = _serverField('log_access_enabled').prop('checked'); + $('#log-level-warning').toggle(suppressesAccessLog && recordsEnabled); +} + +$(document).on('change', 'select[name="log_level"], input[name="log_access_enabled"]', + updateLogLevelWarning); + +function showSettingsGroup(navSelector, group) { + var layout = $(navSelector).closest('.settings-layout'); + layout.find('.settings-nav-item').removeClass('cur'); + layout.find('.settings-nav-item[data-group="' + group + '"]').addClass('cur'); + layout.find('.settings-group').removeClass('show'); + layout.find('.settings-group[data-group="' + group + '"]').addClass('show'); + scheduleStickySync(); +} + +function showConfigGroup(group) { + showSettingsGroup('#config-nav', group); + safeSetItem(CONFIG_GROUP_STORAGE_KEY, group); + writeHash(); +} + +$(document).on('click', '#config-nav .settings-nav-item', function () { + showConfigGroup($(this).data('group')); +}); + +$(window).on('beforeunload', function (e) { + if (serverFormDirty()) { + e.preventDefault(); + e.originalEvent.returnValue = ''; + return ''; + } +}); + +function submitConfig(sections, btn, onSaved) { + if (btn) btnLoad(btn, true); + var ok = false; + $.ajax({ + url: appendToken('/api/config'), method: 'POST', contentType: 'application/json', + data: JSON.stringify(sections), + success: function (r) { + if (r.status !== 'success') { toast(r.message, 'error'); return; } + ok = true; + toast(T().config_save_success, 'success'); + if (btn) { btnLoad(btn, false); flashSaved(btn); markSavedHint(btn); } + if (typeof onSaved === 'function') onSaved(); + if (r.applied && r.applied.proxy === false) { + toast(T().config_apply_failed, 'error'); + } + if (r.applied && r.applied.pool === false) { + toast(T().pool_config_apply_failed, 'error'); + } + if (r.port_changed) { + if (r.service_status === 'running') { + $.ajax({ + url: appendToken('/api/service'), method: 'POST', contentType: 'application/json', + data: JSON.stringify({ action: 'restart' }), + success: function (rr) { + if (rr && rr.status === 'success') toast(rr.message, 'success'); + else toast((rr && rr.message) || T().config_save_failed, 'error'); + updateStatusAndGauge(); + }, + error: function (x) { + toast(fillTemplate(T().operation_failed, jqErrorText(x)), 'error'); + updateStatusAndGauge(); + } + }); + } else { + toast(T().port_saved_stopped, 'success'); + } + } + loadConfig({ poolOnly: !sections.server && !!sections.pool }); + }, + error: function (x) { toastServerConfigError(x); }, + complete: function () { + if (btn && !ok) flashResult(btn, false); + } + }); +} + +function _configField(key) { + var named = _serverField(key); + if (named.length) return named; + var found = $(); + $('#pool-form [data-pool-key]').each(function () { + if ($(this).data('pool-key') === key) { found = $(this); return false; } + }); + return found; +} + +function _configFieldLabel(field) { + return field.closest('.fg') + .find('.fg-label, .fg-check label').first().text(); +} + +function _revealServerField(key) { + var group = _configField(key).closest('.settings-group'); + if (!group.length) return; + var nav = group.closest('.settings-layout').find('.settings-nav'); + if (nav.length) showSettingsGroup('#' + nav.attr('id'), group.data('group')); +} + +function toastServerConfigError(x) { + var details = null; + try { details = JSON.parse(x.responseText).details; } catch (e) { details = null; } + var reason = details && details.reason; + if (details && details.key && reason) { + var label = _configFieldLabel(_configField(details.key)); + if (label) { + _revealServerField(details.key); + toast(fillTemplate(T().config_field_invalid, label + T().sep_colon + reason), 'error'); + return; + } + } + toast(T().config_save_failed + T().sep_colon + jqErrorText(x), 'error'); +} + +function saveAllConfig() { + var changes = serverConfigChanges(); + if (!Object.keys(changes).length) { + toast(T().no_changes, 'info'); + return; + } + if (changes.port === undefined) { + submitConfig({ server: changes }, $('#btn-save-config')); + return; + } + showConfirm(T().config_port_restart_title, T().config_port_restart_body, function (ok) { + if (ok) submitConfig({ server: changes }, $('#btn-save-config')); + }); +} + +var MODE_FIELD_BY_SOURCE = { + local: { + label: 'run_mode_label', hint: 'run_mode_hint', doc: 'run_mode_doc', + options: [['cycle', 'cycle_mode'], ['loadbalance', 'loadbalance_mode']] + }, + api: { + label: 'rotation_mode_label', hint: 'rotation_mode_hint', doc: 'rotation_mode_doc', + options: [['request', 'mode_request'], ['continuous', 'mode_continuous']] + } +}; +MODE_FIELD_BY_SOURCE.pool = MODE_FIELD_BY_SOURCE.api; + +function syncModeField(sourceMode) { + var spec = MODE_FIELD_BY_SOURCE[sourceMode] || MODE_FIELD_BY_SOURCE.api; + var $sel = $('#mode-select'); + if (!$sel.length) return; + + var allowed = spec.options.map(function (o) { return o[0]; }); + var current = $sel.val(); + if (allowed.indexOf(current) === -1) current = allowed[0]; + + $sel.html(spec.options.map(function (o) { + return ''; + }).join('')); + $sel.val(current); + + $('#mode-label').attr('data-i18n', spec.label).text(T()[spec.label] || spec.label); + $('#mode-hint').attr('data-i18n', spec.hint).text(T()[spec.hint] || spec.hint); + $('#mode-tip').attr('data-i18n-title', spec.doc) + .attr('title', T()[spec.doc] || ''); +} + +function _visibilityClauseHolds(clause) { + clause = clause.trim(); + if (!clause) return true; + var negate = clause.indexOf('!=') !== -1; + var splitAt = negate ? clause.indexOf('!=') : clause.indexOf('='); + var key = clause.slice(0, splitAt).trim(); + var wanted = clause.slice(splitAt + (negate ? 2 : 1)).split('|') + .map(function (v) { return v.trim(); }); + var field = _serverField(key); + if (!field.length) field = $('#pool-form [data-pool-key="' + key + '"]'); + if (!field.length) return true; + var actual = field.prop('type') === 'checkbox' + ? field.prop('checked').toString() + : String(field.val() === null || field.val() === undefined ? '' : field.val()).trim(); + var hit = wanted.indexOf(actual) !== -1; + return negate ? !hit : hit; +} + +function syncFieldVisibility() { + $('[data-visible-when]').each(function () { + var clauses = String($(this).attr('data-visible-when') || '').split(';'); + var show = true; + for (var i = 0; i < clauses.length; i++) { + if (!_visibilityClauseHolds(clauses[i])) { show = false; break; } + } + $(this).toggle(show); + }); +} + +$(document).on('change', '#tab-config input, #tab-config select, #pool-form input, #pool-form select', + function () { + syncFieldVisibility(); + syncProxyAuthHint(); + }); + +function onSourceChange() { + var m = getSourceMode(); + syncModeField(m); + syncFieldVisibility(); + if (m === 'pool') updatePoolStatus(); + if (m === 'api') loadApiCredentials(); +} + +function onIntervalChange() { + var seconds = parseInt($('input[name="interval"]').val()) || 0; + var requests = parseInt($('input[name="request_interval"]').val()) || 0; + $('#tip-zero').toggle(seconds === 0 && requests === 0); +} + + +function switchProxy() { + var btn = $('#switch-btn'); if (btn.prop('disabled')) return; + btn.prop('disabled', true).html(' ' + T().switching + ''); + $.get(appendToken('/api/switch_proxy'), function (d) { + if (d.status === 'success') { toast(d.message, 'success'); updateStatusAndGauge(); btn.prop('disabled', false).html(' ' + T().manual_switch_btn + ''); } + else if (d.cooldown) { + toast(d.message, 'error'); + var rem = d.cooldown_remaining || 2; + btn.prop('disabled', true).html(' ' + Math.ceil(rem) + 's'); + var iv = setInterval(function () { + rem -= 0.1; + if (rem <= 0) { clearInterval(iv); btn.prop('disabled', false); btn.html(' ' + T().manual_switch_btn + ''); } + else btn.html(' ' + Math.ceil(rem) + 's'); + }, 100); + } else if (d.switching) { + toast(d.message, 'error'); + btn.prop('disabled', false).html(' ' + T().manual_switch_btn + ''); + } else { + toast(d.message, 'error'); + updateStatusAndGauge(); + btn.prop('disabled', false).html(' ' + T().manual_switch_btn + ''); + } + }).fail(function () { + toast(T().switch_failed_network, 'error'); + btn.prop('disabled', false).html(' ' + T().manual_switch_btn + ''); + }); +} + + +function loadLocalProxies() { + $.get(appendToken('/api/proxies'), function (d) { + var h = [], hs = [], s = [], f = []; + (d.proxies || []).forEach(function (p) { + if (p.indexOf('@') >= 0 || (p.indexOf('://') >= 0 && !p.match(/^(http|https|socks5):\/\/[^@]+:\d+$/))) f.push(p); + else if (p.indexOf('http://') === 0) h.push(p.replace('http://', '')); + else if (p.indexOf('https://') === 0) hs.push(p.replace('https://', '')); + else if (p.indexOf('socks5://') === 0) s.push(p.replace('socks5://', '')); + else h.push(p); + }); + $('#http-proxy-list').val(h.join('\n')); $('#https-proxy-list').val(hs.join('\n')); + $('#socks5-proxy-list').val(s.join('\n')); $('#full-proxy-list').val(f.join('\n')); + }); +} + +function saveProxies() { + var b = $('#btn-save-proxy'); btnLoad(b, true); + var ok = false; + var a = []; + $('#http-proxy-list').val().split('\n').forEach(function (l) { if (l.trim()) a.push('http://' + l.trim()); }); + $('#https-proxy-list').val().split('\n').forEach(function (l) { if (l.trim()) a.push('https://' + l.trim()); }); + $('#socks5-proxy-list').val().split('\n').forEach(function (l) { if (l.trim()) a.push('socks5://' + l.trim()); }); + $('#full-proxy-list').val().split('\n').forEach(function (l) { if (l.trim()) a.push(l.trim()); }); + $.ajax({ + url: appendToken('/api/proxies'), method: 'POST', contentType: 'application/json', data: JSON.stringify({ proxies: a }), + success: function (r) { + if (r.status === 'success') { ok = true; toast(T().proxy_save_success, 'success'); btnLoad(b, false); flashSaved(b); loadLocalProxies(); } + else toast(r.message, 'error'); + }, + error: function (x) { toast(T().proxy_save_failed + T().sep_colon + jqErrorText(x), 'error'); }, + complete: function () { if (!ok) flashResult(b, false); } + }); +} + +function checkProxies() { + var b = $('#btn-check-proxy'); btnLoad(b, true); + var u = $('input[name="test_url"]').val() || 'https://www.baidu.com'; + var ok = false; + $.get(appendToken('/api/check_proxies'), { test_url: u }, function (d) { + if (d.status === 'success') { + ok = true; + loadLocalProxies(); + toast(fillTemplate(T().proxy_check_result, d.total), 'success'); + } else { + toast(d.message, 'error'); + } + }).fail(function (x) { + toast(T().proxy_check_failed + T().sep_colon + jqErrorText(x), 'error'); + }).always(function () { + btnLoad(b, false); + if (!ok) flashResult(b, false); + }); +} + + +function loadIpLists() { + $.get(appendToken('/api/ip_lists'), function (d) { + $('#whitelist').val((d.whitelist || []).join('\n')); + $('#blacklist').val((d.blacklist || []).join('\n')); + }); +} + +function saveIpLists(btn) { + var b = $(btn); + if (b.length) btnLoad(b, true); + var wl = $('#whitelist').val().split('\n').filter(function (l) { return l.trim(); }); + var bl = $('#blacklist').val().split('\n').filter(function (l) { return l.trim(); }); + $.ajax({ + url: appendToken('/api/config'), method: 'POST', contentType: 'application/json', + data: JSON.stringify({ server: { ip_auth_priority: $('select[name="ip_auth_priority"]').val() } }), + success: function () { + syncServerBaseline(['ip_auth_priority']); + var saveWl = $.ajax({ url: appendToken('/api/ip_lists'), method: 'POST', contentType: 'application/json', + data: JSON.stringify({ type: 'whitelist', list: wl }) }); + var saveBl = $.ajax({ url: appendToken('/api/ip_lists'), method: 'POST', contentType: 'application/json', + data: JSON.stringify({ type: 'blacklist', list: bl }) }); + $.when(saveWl, saveBl).done(function () { + toast(T().ip_list_save_success, 'success'); + if (b.length) { btnLoad(b, false); flashSaved(b); } + loadIpLists(); + }).fail(function (x) { + toast(T().ip_list_save_failed, 'error'); + if (b.length) flashResult(b, false); + }); + }, + complete: function () { if (b.length) btnLoad(b, false); } + }); +} + + +function loadBypassWhitelist() { + $.get(appendToken('/api/bypass_whitelist'), function(d) { + $('#bypass-whitelist').val((d.list || []).join('\n')); + }); +} + +function saveBypassWhitelist(btn) { + var b = $(btn); + if (b.length) btnLoad(b, true); + var list = $('#bypass-whitelist').val().split('\n').filter(function(l) { return l.trim(); }); + $.ajax({ + url: appendToken('/api/bypass_whitelist'), method: 'POST', contentType: 'application/json', + data: JSON.stringify({ list: list }), + success: function(r) { + if (r.status === 'success') { + toast(T().bypass_save_success, 'success'); + if (b.length) { btnLoad(b, false); flashSaved(b); } + loadBypassWhitelist(); + } else toast(r.message || T().save_failed, 'error'); + }, + error: function() { toast(T().bypass_save_failed || T().save_failed, 'error'); }, + complete: function() { if (b.length) btnLoad(b, false); } + }); +} + + +var USER_PASS_MASK = '••••••••'; + +function toggleUserPassword($pass, btn) { + var masked = $pass.hasClass('masked'); + if (masked) $pass.text($pass.attr('data-pass') || '').removeClass('masked'); + else $pass.text(USER_PASS_MASK).addClass('masked'); + var label = masked ? T().user_hide_password : T().user_show_password; + $(btn).find('i').attr('class', 'fa ' + (masked ? 'fa-eye-slash' : 'fa-eye')); + $(btn).attr('title', label).attr('aria-label', label); +} + +function loadUsers() { + onLangRender(loadUsers); + beginTableLoad('#user-table tbody', 3, 2); + $.ajax({ url: appendToken('/api/users'), timeout: 15000 }) + .done(function (d) { + if (d.status === 'error' || !d.users) { + showTableAlert('#user-table tbody', 3, d.message || T().users_load_failed, 'users'); + return; + } + clearTableAlert('#user-table tbody'); + var t = $('#user-table tbody').empty(); + var users = d.users || {}, count = Object.keys(users).length; + if (count === 0) t.append(emptyRow(3, T().no_users)); + $.each(users, function (u, p) { + var $row = $(''); + $('').append($('').text(u)).appendTo($row); + + var $pass = $('') + .attr('data-pass', p).text(USER_PASS_MASK); + var $passCell = $('').append($pass).appendTo($row); + + var $actions = $(''); + var $eye = $('' + + ''); + c.append(prevEl); + }); +} + +function updateLogs(force) { + onLangRender(updateLogs); + if (_logFile && !force) return; + if (!_logAuto && !force && _logHasRendered) return; + + var kw = $('#log-search').val() || ''; + var c = $('#log-container'); + var wasAtBottom = c.scrollTop() + c.innerHeight() >= c[0].scrollHeight - 4; + var prevScroll = c.scrollTop(); + + function done(entries) { + _renderLogEntries(entries, kw); + _logHasRendered = true; + if (_logFollow && wasAtBottom) c.scrollTop(c[0].scrollHeight); + else c.scrollTop(prevScroll); + } + + if (_logFile) { + var cacheFresh = _logFileCache && _logFileCache.name === _logFile && + (Date.now() - _logFileCache.at) < 1000; + if (cacheFresh) { + done(_applyLevelFilter(_logFileCache.entries)); + updateLogStats(); + return; + } + $.get(appendToken('/api/logs/file?name=' + encodeURIComponent(_logFile) + '&lines=2000'), function (d) { + if (d.status !== 'success') { toast(d.message || T().log_file_read_failed, 'error'); return; } + _logFileCache = { name: _logFile, entries: (d.lines || []).map(parseLogLine), at: Date.now() }; + done(_applyLevelFilter(_logFileCache.entries)); + }); + } else { + $.get(appendToken('/api/logs?category=' + encodeURIComponent(_logCategory) + + '&level=' + $('#log-level').val() + + '&search=' + encodeURIComponent(kw)), function (d) { + if (d.status !== 'success') return; + done(d.logs || []); + }); + } + updateLogStats(); +} + +function updateLogStats() { + onLangRender(updateLogStats); + $.get(appendToken('/api/logs/stats'), function (d) { + if (d.status !== 'success') return; + var s = d.stats, lvs = s.levels || {}, cats = s.categories || {}, a = []; + a.push('' + escapeHtml(T().log_total) + ' ' + s.total + ''); + ['INFO', 'WARNING', 'ERROR', 'CRITICAL'].forEach(function (k) { + if (lvs[k] !== undefined) a.push('' + k + ' ' + lvs[k] + ''); + }); + Object.keys(CATEGORY_LABELS).forEach(function (k) { + if (k === 'all' || cats[k] === undefined) return; + var label = CATEGORY_LABELS[k]; + a.push('' + escapeHtml(label ? label() : k) + ' ' + cats[k] + ''); + }); + $('#log-stats').html(a.join('')); + _paintCategoryCounts(s); + }); +} + +function _paintCategoryCounts(s) { + var cats = (s && s.categories) || {}; + $('#log-cat-pills .log-cat-pill').each(function () { + var cat = $(this).attr('data-cat'); + var n = cat === 'all' ? s.total : cats[cat]; + var $count = $(this).find('.pill-count'); + if (n === undefined || n === null) { $count.remove(); return; } + if (!$count.length) $count = $('').appendTo(this); + $count.text(n); + }); +} + +$(document).on('click', '.log-copy', function () { + var $line = $(this).closest('.log-line'); + var text = [ + $line.find('.log-time').text(), + $line.find('.log-level-badge').text(), + $line.find('.log-msg').text() + ].filter(function (v) { return v; }).join(' '); + copyToClipboard(text, function () { + toast(T().log_line_copied, 'success'); + }); +}); + +function exportLogs() { + var url = '/api/logs/export?'; + if (_logFile) url += 'file=' + encodeURIComponent(_logFile); + else url += 'category=' + encodeURIComponent(_logCategory) + + '&level=' + $('#log-level').val() + + '&search=' + encodeURIComponent($('#log-search').val() || ''); + window.open(appendToken(url), '_blank'); + toast(T().log_export_started, 'success'); +} + +function onLogSearch() { + var v = $('#log-search').val(); $('#log-clear-btn').toggle(!!v); + clearTimeout(logTimer); logTimer = setTimeout(function () { updateLogs(true); }, 300); +} + +function clearLogSearch() { + $('#log-search').val(''); + $('#log-clear-btn').hide(); + updateLogs(true); +} + +function clearLogs() { + var label = CATEGORY_LABELS[_logCategory]; + var scope = label ? label() : _logCategory; + showConfirm(T().confirm_clear_title, + T().confirm_clear_logs + T().sep_paren_open + scope + T().sep_paren_close, function (ok) { + if (!ok) return; + $.ajax({ + url: appendToken('/api/logs/clear'), method: 'POST', contentType: 'application/json', + data: JSON.stringify({ category: _logCategory }), + success: function (r) { + if (r.status !== 'success') { toast(r.message || T().clear_logs_failed, 'error'); return; } + toast(T().logs_cleared, 'success'); + $('#log-container').empty(); $('#log-stats').empty(); + loadLogFiles(); + }, + error: function () { toast(T().clear_logs_failed, 'error'); } + }); + }, { danger: true }); +} + +var _domainSearchTimer = null; +var _domainSort = 'last_seen'; +var _domainOrder = 'desc'; +var _upstreamRows = []; +var _domainPayload = null; +var _expandedUpstreams = {}; + +var _accessMode = 'summary'; +var _recordsPage = 1; +var _recordsPageSize = 100; +var _recordsTotal = 0; +var _recordsAuto = true; +var _recordsSearchTimer = null; +var _recordsPayload = null; +var _recordsOptionsLoaded = false; + +function _startDomainRefresh() { + if (isPolling('domain')) return; + _updateAccessPanelVisibility(); + _refreshAccessPanel(); + startPolling('domain', _refreshAccessPanel, 5000); +} + +function _refreshAccessPanel() { + if (_accessMode === 'records') { + if (_recordsAuto) loadAccessRecords(); + } else { + loadDomainStats(); + } +} + +function _stopDomainRefresh() { + stopPolling('domain'); +} + +function _updateAccessPanelVisibility() { + $('#log-cat-pills').toggle(!_logFile); + $('#log-clear-btn-wrap').toggle(!_logFile); + + var show = _logCategory === 'access' && !_logFile; + var wasHidden = !$('#access-stats-panel').is(':visible'); + $('#access-stats-panel').toggle(show); + $('#access-hint').toggle(!show && !_logFile); + if (show && wasHidden) { + if (_accessMode === 'records') _loadRecordOptions(function () { loadAccessRecords(); }); + else loadDomainStats(); + } +} + +function showAccessStats() { + _logFile = ''; + $('#log-file-select').val(''); + $('#log-container').removeClass('showing-file'); + setLogCategory('access'); +} + + +function setAccessMode(mode) { + _accessMode = mode === 'records' ? 'records' : 'summary'; + $('#access-mode-pills .log-lv-pill').removeClass('cur'); + $('#access-mode-pills .log-lv-pill[data-access-mode="' + _accessMode + '"]').addClass('cur'); + $('#access-summary-view').toggle(_accessMode === 'summary'); + $('#access-records-view').toggle(_accessMode === 'records'); + + if (_accessMode === 'records') _loadRecordOptions(function () { loadAccessRecords(); }); + else loadDomainStats(); +} + +function _loadRecordOptions(onReady) { + var done = onReady || function () {}; + if (_recordsOptionsLoaded) { done(); return; } + + $.ajax({ + url: appendToken('/api/logs/records/options'), + success: function (d) { + if (d.status !== 'success') { done(); return; } + var html = ''; + (d.upstreams || []).forEach(function (item) { + html += ''; + }); + $('#records-upstream').html(html); + _recordsOptionsLoaded = true; + done(); + }, + error: done + }); +} + +function _recordRangeParams() { + var since = $('#records-since').val() || ''; + var until = $('#records-until').val() || ''; + return '&since=' + encodeURIComponent(since) + '&until=' + encodeURIComponent(until); +} + +function _recordsFilterParams() { + return '&outcome=' + encodeURIComponent($('#records-outcome').val() || '') + + '&upstream=' + encodeURIComponent($('#records-upstream').val() || '') + + '&search=' + encodeURIComponent($('#records-search').val() || '') + + _recordRangeParams(); +} + +function _toLocalInputValue(date) { + function pad(n) { return (n < 10 ? '0' : '') + n; } + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); +} + +function setRecordsRange(preset) { + var now = new Date(); + var since; + if (preset === 'today') { + since = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + } else { + var minutes = { '5m': 5, '1h': 60, '24h': 1440 }[preset] || 60; + since = new Date(now.getTime() - minutes * 60000); + } + + $('#records-since').val(_toLocalInputValue(since)); + $('#records-until').val(''); + $('#access-records-view .log-lv-pill').removeClass('cur'); + $('#access-records-view .log-lv-pill[data-records-range="' + preset + '"]').addClass('cur'); + _recordsPage = 1; + loadAccessRecords(); +} + +function onRecordsRangeChange() { + $('#access-records-view .log-lv-pill').removeClass('cur'); + _recordsPage = 1; + loadAccessRecords(); +} + +function onRecordsFilterChange() { + _recordsPage = 1; + loadAccessRecords(); +} + +function onRecordsSearch() { + clearTimeout(_recordsSearchTimer); + _recordsSearchTimer = setTimeout(function () { + _recordsPage = 1; + loadAccessRecords(); + }, 300); +} + +function toggleRecordsAuto() { + _recordsAuto = !_recordsAuto; + $('#records-auto-btn').toggleClass('on', _recordsAuto); + if (_recordsAuto) loadAccessRecords(); +} + +function _pauseRecordsAutoOnPaging() { + if (_recordsPage > 1 && _recordsAuto) { + _recordsAuto = false; + $('#records-auto-btn').removeClass('on'); + } +} + +function recordsPrevPage() { + if (_recordsPage <= 1) return; + _recordsPage -= 1; + _pauseRecordsAutoOnPaging(); + loadAccessRecords(); +} + +function recordsNextPage() { + if (_recordsPage * _recordsPageSize >= _recordsTotal) return; + _recordsPage += 1; + _pauseRecordsAutoOnPaging(); + loadAccessRecords(); +} + +function loadAccessRecords() { + onLangRender(loadAccessRecords); + if (_accessMode !== 'records') return; + if (!$('#access-stats-panel').is(':visible')) return; + + var q = '/api/logs/records?limit=' + _recordsPageSize + + '&offset=' + ((_recordsPage - 1) * _recordsPageSize) + + _recordsFilterParams(); + beginTableLoad('#records-tbody', 9, 6); + $.ajax({ + url: appendToken(q), + timeout: 15000, + success: function (d) { + if (d.status !== 'success') { toast(d.message || '', 'error'); return; } + clearTableAlert('#records-tbody'); + _recordsPayload = d; + _recordsTotal = d.total || 0; + _renderRecordTable(); + }, + error: function (x) { + showTableAlert('#records-tbody', 9, + jqErrorText(x, T().network_error) + T().sep_mid + T().stale_data_note, 'records'); + } + }); +} + +registerTableRetry('records', loadAccessRecords); + +function _renderRecordTable() { + var payload = _recordsPayload || {}; + var tbody = $('#records-tbody'); + tbody.empty(); + + if (payload.enabled === false) { + tbody.append(emptyRow(9, T().records_disabled)); + } else if (!(payload.records || []).length) { + tbody.append(emptyRow(9, T().records_no_data)); + } else { + payload.records.forEach(function (record) { tbody.append(_recordRow(record)); }); + } + + var counts = payload.outcome_counts || {}; + var parts = [ + T().records_summary_range + ' ' + (payload.total || 0) + ' ' + T().records_summary_unit, + T().access_outcome_success + ' ' + (counts.success || 0), + T().access_outcome_failure + ' ' + (counts.failure || 0), + T().access_outcome_aborted + ' ' + (counts.aborted || 0) + ]; + if (payload.pending) { + parts.push(fillTemplate(T().records_summary_pending, payload.pending)); + } + if (payload.dropped) { + parts.push(fillTemplate(T().records_summary_dropped, payload.dropped)); + } + $('#records-summary').text(parts.join(' · ')); + + var pages = Math.max(1, Math.ceil(_recordsTotal / _recordsPageSize)); + $('#records-page-info').text(fillTemplate( + T().records_page_info, _recordsPage, pages)); + $('#records-prev-btn').prop('disabled', _recordsPage <= 1); + $('#records-next-btn').prop('disabled', _recordsPage >= pages); +} + +function _recordRow(record) { + var outcome = record.outcome || ''; + return $('' + + '' + escapeHtml(record.ts || '') + '' + + '' + escapeHtml(record.client || '') + '' + + '' + escapeHtml(record.target || '') + '' + + '' + escapeHtml(record.upstream_label || record.upstream || '') + '' + + '' + escapeHtml(record.real_ip || '--') + '' + + '' + + escapeHtml(_outcomeLabel(outcome)) + '' + + '' + _statusCell(record.status_code) + '' + + '' + (record.elapsed_ms || 0) + ' ms' + + '' + + escapeHtml(record.reason || '--') + ''); +} + +function _statusCell(code) { + if (code === null || code === undefined) return '--'; + var cls = 'status-code'; + if (code >= 200 && code < 300) cls += ' ok'; + else if (code >= 300 && code < 400) cls += ' redirect'; + else if (code >= 400 && code < 500) cls += ' client'; + else if (code >= 500) cls += ' server'; + return '' + escapeHtml(code) + ''; +} + +function _outcomeLabel(outcome) { + if (outcome === 'success') return T().access_outcome_success; + if (outcome === 'failure') return T().access_outcome_failure; + if (outcome === 'aborted') return T().access_outcome_aborted; + return outcome || '--'; +} + +function openRecordDrawer(id) { + var list = (_recordsPayload && _recordsPayload.records) || []; + var r = null; + for (var i = 0; i < list.length; i++) { + if (String(list[i].id) === String(id)) { r = list[i]; break; } + } + if (!r) return; + + openRowDrawer({ + title: r.target || '--', + subtitle: (r.ts || '') + ' · ' + _outcomeLabel(r.outcome), + fields: drawerSections([ + [T().drawer_group_request, [ + { label: T().records_col_time, value: r.ts, mono: true }, + { label: T().records_col_client, value: r.client, mono: true }, + { label: T().records_col_target, value: r.target, mono: true, copy: true }, + { label: T().records_method, value: r.method || '' }, + { label: T().records_kind, value: r.kind || '' } + ]], + [T().drawer_group_upstream, [ + { label: T().records_col_upstream, value: r.upstream_label || r.upstream, mono: true, copy: true }, + { label: T().records_col_real_ip, value: r.real_ip, mono: true } + ]], + [T().drawer_group_result, [ + { label: T().records_col_outcome, value: _outcomeLabel(r.outcome) }, + { label: T().records_col_status, value: (r.status_code === null || r.status_code === undefined) ? '' : String(r.status_code), mono: true }, + { label: T().records_col_elapsed, value: (r.elapsed_ms || 0) + ' ms', mono: true }, + { label: T().records_col_reason, value: r.reason || '' } + ]] + ]) + }); +} + +function initAccessRecordsTable() { + var table = $('#records-table'); + if (!table.length) return; + table.on('click', 'tbody tr[data-id]', function () { + openRecordDrawer($(this).attr('data-id')); + }); + attachTableKeyboard(table, { + onActivate: function (i) { + var row = table.find('tbody tr[data-id]').eq(i); + if (row.length) openRecordDrawer(row.attr('data-id')); + } + }); +} + +function exportAccessRecords() { + var url = '/api/logs/records/export?format=' + encodeURIComponent($('#records-format').val() || 'csv') + + _recordsFilterParams(); + var limit = (_recordsPayload && _recordsPayload.export_limit) || 0; + + if (limit && _recordsTotal > limit) { + showConfirm( + T().records_export_btn, + fillTemplate(T().records_export_truncated, + _recordsTotal, limit), + function (ok) { + if (!ok) return; + window.open(appendToken(url)); + toast(T().log_export_started, 'success'); + } + ); + return; + } + window.open(appendToken(url)); + toast(T().log_export_started, 'success'); +} + +function clearAccessRecords() { + showConfirm(T().confirm_clear_title, + T().confirm_clear_records, + function (ok) { + if (!ok) return; + $.ajax({ + url: appendToken('/api/logs/records/clear'), method: 'POST', + success: function (r) { + if (r.status !== 'success') { toast(r.message || '', 'error'); return; } + toast(r.message || '', 'success'); + _recordsPage = 1; + _recordsOptionsLoaded = false; + _loadRecordOptions(function () { loadAccessRecords(); }); + }, + error: function () { toast(T().clear_logs_failed, 'error'); } + }); + }, { danger: true }); +} + +function sortDomainStats(field) { + if (_domainSort === field) _domainOrder = _domainOrder === 'desc' ? 'asc' : 'desc'; + else { _domainSort = field; _domainOrder = 'desc'; } + loadDomainStats(); +} + +function onDomainSearch() { + clearTimeout(_domainSearchTimer); + _domainSearchTimer = setTimeout(loadDomainStats, 300); +} + +function loadDomainStats() { + onLangRender(loadDomainStats); + if (!$('#access-stats-panel').is(':visible')) return; + var q = '/api/logs/domains?sort=' + _domainSort + '&order=' + _domainOrder + + '&limit=200&search=' + encodeURIComponent($('#domain-search').val() || ''); + beginTableLoad('#domain-tbody', 7, 6); + $.ajax({ url: appendToken(q), timeout: 15000 }) + .done(function (d) { + if (d.status !== 'success') return; + clearTableAlert('#domain-tbody'); + _upstreamRows = d.proxies || []; + _domainPayload = d; + _renderDomainTable(); + }) + .fail(function (x) { + showTableAlert('#domain-tbody', 7, + jqErrorText(x, T().network_error) + T().sep_mid + T().stale_data_note, 'domains'); + }); +} + +registerTableRetry('domains', loadDomainStats); + +function _rateCell(row) { + var total = row.total || 0; + var rate = total ? Math.round((row.success / total) * 100) : 0; + var cls = total && row.failure ? (rate < 50 ? 'domain-rate bad' : 'domain-rate warn') : 'domain-rate'; + return '' + rate + '%'; +} + +function _renderDomainTable() { + var payload = _domainPayload || {}; + var tbody = $('#domain-tbody'); + tbody.empty(); + + if (payload.enabled === false) { + tbody.append(emptyRow(7, T().domain_disabled)); + } else if (!_upstreamRows.length) { + tbody.append(emptyRow(7, T().domain_no_data)); + } else { + _upstreamRows.forEach(function (row) { + tbody.append(_upstreamRow(row)); + var detail = _expandedUpstreams[row.upstream]; + if (detail === undefined) return; + if (detail === 'loading') { + tbody.append(emptyRow(7, T().loading, 'domain-detail')); + return; + } + detail.forEach(function (d) { tbody.append(_domainDetailRow(d)); }); + }); + } + + var s = payload.summary || {}; + $('#domain-summary').text( + T().access_summary + ' ' + (payload.total || 0) + + ' · ' + T().access_summary_hosts + ' ' + (s.hosts || 0) + + ' · ' + T().domain_summary_ok + ' ' + (s.success || 0) + + ' / ' + T().domain_summary_fail + ' ' + (s.failure || 0)); +} + +function _upstreamRow(row) { + var expanded = _expandedUpstreams[row.upstream] !== undefined; + var label = row.upstream_label || row.upstream; + return $('' + + ' ' + escapeHtml(label) + '' + + '' + + row.host_count + '' + + '' + row.success + '' + + '' + row.failure + '' + + '' + row.total + '' + + '' + _rateCell(row) + '' + + '' + escapeHtml(row.last_seen || '--') + '' + + '' + + escapeHtml(row.last_error || '--') + '') + .data('upstream', row.upstream); +} + +function _domainDetailRow(d) { + return $('' + + '' + escapeHtml(d.host) + '' + + '' + d.success + '' + + '' + d.failure + '' + + '' + d.total + '' + + '' + _rateCell(d) + '' + + '' + escapeHtml(d.last_seen || '--') + '' + + '' + + escapeHtml(d.last_error || '--') + ''); +} + +function toggleUpstreamRow(upstream) { + if (_expandedUpstreams[upstream] !== undefined) { + delete _expandedUpstreams[upstream]; + _renderDomainTable(); + return; + } + _expandedUpstreams[upstream] = 'loading'; + _renderDomainTable(); + $.ajax({ + url: appendToken('/api/logs/domains?limit=200&upstream=' + encodeURIComponent(upstream)), + success: function (d) { + _expandedUpstreams[upstream] = (d.status === 'success' && d.domains) ? d.domains : []; + _renderDomainTable(); + }, + error: function () { + _expandedUpstreams[upstream] = []; + _renderDomainTable(); + } + }); +} + +$(document).on('click', '.domain-row', function () { + toggleUpstreamRow($(this).data('upstream')); +}); + +function clearDomainStats() { + showConfirm(T().confirm_clear_title, T().confirm_clear_domains, function (ok) { + if (!ok) return; + $.ajax({ + url: appendToken('/api/logs/domains/clear'), method: 'POST', + success: function (r) { + if (r.status !== 'success') { toast(r.message || '', 'error'); return; } + toast(r.message || T().domain_cleared, 'success'); + _expandedUpstreams = {}; + loadDomainStats(); + }, + error: function () { toast(T().clear_logs_failed, 'error'); } + }); + }, { danger: true }); +} + + +function checkVer() { + onLangRender(checkVer); + $.get(appendToken('/api/version'), function (d) { + if (d.status === 'success') { + $('#hdr-ver').text(d.current_version || 'ProxyCat'); + if (d.is_latest) $('#ver-stat').html(' ' + T().latest_version + ''); + else $('#ver-stat').html(' ' + T().new_version_available + ': ' + (d.latest_version || '') + ''); + } + }); +} + + +var _ads = []; +var _adIndex = 0; +var _adCountdown = null; +var _adDismissed = false; + +function loadAds() { + onLangRender(loadAds); + $.get(appendToken('/api/ads'), function (d) { + if (d.status !== 'success' || !d.ads || !d.ads.length) return; + _ads = d.ads; + _adIndex = 0; + _adDismissed = !!d.dismissed; + if (_adDismissed) { + showReopenBtn(); + } else { + showAdBar(); + renderAdPair(0); + startAdRotation(); + } + }).fail(function () { + }); +} + +function renderAdCard(slot, ad) { + var s = String(slot); + if (!ad) { + $('#ad-card-' + s).hide(); + return; + } + $('#ad-card-' + s).show(); + $('#ad-title-' + s).text(ad.title || ''); + $('#ad-body-' + s).text(ad.body || ''); + + if (ad.image_url) { + $('#ad-img-' + s).attr('src', ad.image_url).show() + .off('click').on('click', function (e) { + e.stopPropagation(); + openImgLightbox(ad.image_url); + }); + } else { + $('#ad-img-' + s).hide().attr('src', '').off('click'); + } + + if (ad.link_url) { + $('#ad-link-' + s).attr('href', ad.link_url).show(); + $('#ad-link-text-' + s).text(ad.link_text || T().ad_link_default); + } else { + $('#ad-link-' + s).hide(); + } +} + +function renderAdPair(startIdx) { + if (!_ads.length) return; + _adIndex = startIdx; + var left = _ads[startIdx] || null; + var right = (_ads.length > startIdx + 1) ? _ads[startIdx + 1] : null; + + renderAdCard(0, left); + renderAdCard(1, right); + + var totalPairs = Math.ceil(_ads.length / 2); + if (totalPairs > 1) { + var curPair = Math.floor(startIdx / 2); + var dots = ''; + for (var i = 0; i < totalPairs; i++) { + dots += ''; + } + $('#ad-pagination').html(dots).show(); + $('#ad-pagination .ad-dot').off('click').on('click', function () { + var i = parseInt($(this).data('index')); + if (i !== _adIndex) { + resetAdCountdown(); + renderAdPair(i); + startAdRotation(); + } + }); + } else { + $('#ad-pagination').empty().hide(); + } + + var dt = 15; + if (left) dt = left.display_time || 15; + if (right) dt = Math.max(dt, right.display_time || 15); + updateAdTimer(dt); +} + +function updateAdTimer(seconds) { + $('#ad-timer').text(seconds + 's'); +} + +function startAdRotation() { + resetAdCountdown(); + if (!_ads.length) return; + var left = _ads[_adIndex] || null; + var right = (_ads.length > _adIndex + 1) ? _ads[_adIndex + 1] : null; + var remaining = 15; + if (left) remaining = left.display_time || 15; + if (right) remaining = Math.max(remaining, right.display_time || 15); + _adCountdown = setInterval(function () { + remaining--; + if (remaining <= 0) { + clearInterval(_adCountdown); + var next = (_adIndex + 2) % _ads.length; + renderAdPair(next); + startAdRotation(); + } else { + updateAdTimer(remaining); + } + }, 1000); +} + +function resetAdCountdown() { + clearInterval(_adCountdown); +} + +function showAdBar() { + $('#ad-bar').removeClass('hidden').show(); + $('#ad-reopen-btn').hide(); + _adDismissed = false; + $.post(appendToken('/api/ads/reopen')); +} + +function hideAdBar() { + $('#ad-bar').addClass('hidden'); + setTimeout(function () { + if (_adDismissed) { + $('#ad-bar').hide(); + showReopenBtn(); + } + }, 260); +} + +function showReopenBtn() { + $('#ad-reopen-btn').show(); +} + + +function openImgLightbox(src) { + if (!src) return; + $('#img-lightbox-img').attr('src', src); + $('#img-lightbox').addClass('show'); +} + +function closeImgLightbox() { + $('#img-lightbox').removeClass('show'); +} + +$(document).on('keydown', function (e) { + if (e.key === 'Escape' && $('#img-lightbox').hasClass('show')) { + closeImgLightbox(); + } +}); + +function closeAd() { + resetAdCountdown(); + _adDismissed = true; + $.post(appendToken('/api/ads/dismiss')); + hideAdBar(); +} + +function reopenAd() { + if (!_ads.length) { + loadAds(); + return; + } + _adIndex = 0; + renderAdPair(0); + showAdBar(); + startAdRotation(); +} + + +function scrollToTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + + +var POLL_FAIL_THRESHOLD = 3; +var _pollFailCount = 0; + +function setConnBanner(lost) { + var el = $('#conn-banner'); + if (!lost) { + el.removeClass('show'); + return; + } + $('#conn-banner-text').text(T().conn_lost); + el.addClass('show'); +} + +var _activeAjax = 0; + +function syncGlobalProgress() { + $('#global-progress').toggleClass('on', _activeAjax > 0); +} + +function retryConnection() { + _pollFailCount = 0; + setConnBanner(false); + updateStatusAndGauge(); + startGlobalPolling(); + if ($('#tab-pool').hasClass('show')) startPoolPolling(); + if ($('#tab-logs').hasClass('show')) _startLogRefresh(); +} + +$(function () { + initTheme(); initLang(); loadConfig(); loadLocalProxies(); loadIpLists(); loadBypassWhitelist(); loadUsers(); checkVer(); initSidebar(); loadAds(); + + restoreActiveView(); + if (typeof initFieldTips === 'function') initFieldTips(); + if (typeof initTableDensity === 'function') initTableDensity(); + if (typeof initProxyTableKeyboard === 'function') initProxyTableKeyboard(); + if (typeof initAccessRecordsTable === 'function') initAccessRecordsTable(); + if (typeof restoreActiveTasks === 'function') restoreActiveTasks(); + + var $scrollBtn = $('#scroll-top-btn'); + $(window).on('scroll', function () { + $scrollBtn.toggleClass('visible', window.scrollY > window.innerHeight * 0.6); + }); + + startGlobalPolling(); + + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + pauseGlobalPolling(); + } else { + startGlobalPolling(); + if ($('#tab-logs').hasClass('show')) _startLogRefresh(); + if ($('#tab-pool').hasClass('show')) startPoolPolling(); + } + }); + + var _POLLED_PATHS = ['/api/status', '/api/logs', '/api/logs/stats', + '/api/logs/domains', '/api/logs/records', + '/api/pool/status']; + function isBackgroundPoll(settings) { + if (!settings || !settings.url) return false; + var path = String(settings.url).split('?')[0]; + if (_POLLED_PATHS.indexOf(path) >= 0) return true; + return /^\/api\/pool\/tasks\/[^/]+$/.test(path); + } + + $(document).ajaxSend(function (event, xhr, settings) { + if (isBackgroundPoll(settings)) return; + _activeAjax++; + syncGlobalProgress(); + }); + $(document).ajaxComplete(function (event, xhr, settings) { + if (isBackgroundPoll(settings)) return; + _activeAjax = Math.max(0, _activeAjax - 1); + syncGlobalProgress(); + }); + $(document).ajaxError(function (event, xhr, settings) { + if (isBackgroundPoll(settings)) { + _pollFailCount++; + if (_pollFailCount >= POLL_FAIL_THRESHOLD) setConnBanner(true); + return; + } + var path = settings && settings.url ? String(settings.url).split('?')[0] : ''; + if (path.indexOf('/api/pool/') === 0 || path === '/api/users') return; + var msg = T().request_failed + T().sep_colon + jqErrorText(xhr, T().network_error); + toast(msg, 'error'); + }); + $(document).ajaxSuccess(function (event, xhr, settings) { + if (!isBackgroundPoll(settings)) return; + _pollFailCount = 0; + setConnBanner(false); + }); + +}); diff --git a/web/static/js/bootstrap.bundle.min.js b/web/static/js/bootstrap.bundle.min.js deleted file mode 100644 index cc0a255..0000000 --- a/web/static/js/bootstrap.bundle.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Bootstrap v5.1.3 (https://getbootstrap.com/) - * Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=(t,e,i)=>{Object.keys(i).forEach((n=>{const s=i[n],r=e[n],a=r&&o(r)?"element":null==(l=r)?`${l}`:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(a))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)}))},l=t=>!(!o(t)||0===t.getClientRects().length)&&"visible"===getComputedStyle(t).getPropertyValue("visibility"),c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",(()=>{p.forEach((t=>t()))})),p.push(e)):e()},_=t=>{"function"==typeof t&&t()},b=(e,i,n=!0)=>{if(!n)return void _(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),_(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},v=(t,e,i,n)=>{let s=t.indexOf(e);if(-1===s)return t[!i&&n?t.length-1:0];const o=t.length;return s+=i?1:-1,n&&(s=(s+o)%o),t[Math.max(0,Math.min(s,o-1))]},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,E=/::\d+$/,A={};let T=1;const O={mouseenter:"mouseover",mouseleave:"mouseout"},C=/^(mouseenter|mouseleave)/i,k=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function x(t){const e=L(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function D(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;sfunction(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=S(e,i,n),l=x(t),c=l[a]||(l[a]={}),h=D(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=L(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&j.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&j.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function I(t,e,i,n,s){const o=D(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function P(t){return t=t.replace(w,""),O[t]||t}const j={on(t,e,i,n){N(t,e,i,n,!1)},one(t,e,i,n){N(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=x(t),c=e.startsWith(".");if(void 0!==o){if(!l||!l[r])return;return void I(t,l,r,o,s?i:null)}c&&Object.keys(l).forEach((i=>{!function(t,e,i,n){const s=e[i]||{};Object.keys(s).forEach((o=>{if(o.includes(n)){const n=s[o];I(t,e,i,n.originalHandler,n.delegationSelector)}}))}(t,l,i,e.slice(1))}));const h=l[r]||{};Object.keys(h).forEach((i=>{const n=i.replace(E,"");if(!a||e.includes(n)){const e=h[i];I(t,l,r,e.originalHandler,e.delegationSelector)}}))},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=f(),s=P(e),o=e!==s,r=k.has(s);let a,l=!0,c=!0,h=!1,d=null;return o&&n&&(a=n.Event(e,i),n(t).trigger(a),l=!a.isPropagationStopped(),c=!a.isImmediatePropagationStopped(),h=a.isDefaultPrevented()),r?(d=document.createEvent("HTMLEvents"),d.initEvent(s,l,!0)):d=new CustomEvent(e,{bubbles:l,cancelable:!0}),void 0!==i&&Object.keys(i).forEach((t=>{Object.defineProperty(d,t,{get:()=>i[t]})})),h&&d.preventDefault(),c&&t.dispatchEvent(d),d.defaultPrevented&&void 0!==a&&a.preventDefault(),d}},M=new Map,H={set(t,e,i){M.has(t)||M.set(t,new Map);const n=M.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>M.has(t)&&M.get(t).get(e)||null,remove(t,e){if(!M.has(t))return;const i=M.get(t);i.delete(e),0===i.size&&M.delete(t)}};class B{constructor(t){(t=r(t))&&(this._element=t,H.set(this._element,this.constructor.DATA_KEY,this))}dispose(){H.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY),Object.getOwnPropertyNames(this).forEach((t=>{this[t]=null}))}_queueCallback(t,e,i=!0){b(t,e,i)}static getInstance(t){return H.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.1.3"}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}}const R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),c(this))return;const o=n(this)||this.closest(`.${s}`);t.getOrCreateInstance(o)[e]()}))};class W extends B{static get NAME(){return"alert"}close(){if(j.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=W.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(W,"close"),g(W);const $='[data-bs-toggle="button"]';class z extends B{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=z.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}function q(t){return"true"===t||"false"!==t&&(t===Number(t).toString()?Number(t):""===t||"null"===t?null:t)}function F(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}j.on(document,"click.bs.button.data-api",$,(t=>{t.preventDefault();const e=t.target.closest($);z.getOrCreateInstance(e).toggle()})),g(z);const U={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={};return Object.keys(t.dataset).filter((t=>t.startsWith("bs"))).forEach((i=>{let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),e[n]=q(t.dataset[i])})),e},getDataAttribute:(t,e)=>q(t.getAttribute(`data-bs-${F(e)}`)),offset(t){const e=t.getBoundingClientRect();return{top:e.top+window.pageYOffset,left:e.left+window.pageXOffset}},position:t=>({top:t.offsetTop,left:t.offsetLeft})},V={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode;for(;n&&n.nodeType===Node.ELEMENT_NODE&&3!==n.nodeType;)n.matches(e)&&i.push(n),n=n.parentNode;return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(", ");return this.find(e,t).filter((t=>!c(t)&&l(t)))}},K="carousel",X={interval:5e3,keyboard:!0,slide:!1,pause:"hover",wrap:!0,touch:!0},Y={interval:"(number|boolean)",keyboard:"boolean",slide:"(boolean|string)",pause:"(string|boolean)",wrap:"boolean",touch:"boolean"},Q="next",G="prev",Z="left",J="right",tt={ArrowLeft:J,ArrowRight:Z},et="slid.bs.carousel",it="active",nt=".active.carousel-item";class st extends B{constructor(t,e){super(t),this._items=null,this._interval=null,this._activeElement=null,this._isPaused=!1,this._isSliding=!1,this.touchTimeout=null,this.touchStartX=0,this.touchDeltaX=0,this._config=this._getConfig(e),this._indicatorsElement=V.findOne(".carousel-indicators",this._element),this._touchSupported="ontouchstart"in document.documentElement||navigator.maxTouchPoints>0,this._pointerEvent=Boolean(window.PointerEvent),this._addEventListeners()}static get Default(){return X}static get NAME(){return K}next(){this._slide(Q)}nextWhenVisible(){!document.hidden&&l(this._element)&&this.next()}prev(){this._slide(G)}pause(t){t||(this._isPaused=!0),V.findOne(".carousel-item-next, .carousel-item-prev",this._element)&&(s(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null}cycle(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config&&this._config.interval&&!this._isPaused&&(this._updateInterval(),this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))}to(t){this._activeElement=V.findOne(nt,this._element);const e=this._getItemIndex(this._activeElement);if(t>this._items.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,et,(()=>this.to(t)));if(e===t)return this.pause(),void this.cycle();const i=t>e?Q:G;this._slide(i,this._items[t])}_getConfig(t){return t={...X,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(K,t,Y),t}_handleSwipe(){const t=Math.abs(this.touchDeltaX);if(t<=40)return;const e=t/this.touchDeltaX;this.touchDeltaX=0,e&&this._slide(e>0?J:Z)}_addEventListeners(){this._config.keyboard&&j.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,"mouseenter.bs.carousel",(t=>this.pause(t))),j.on(this._element,"mouseleave.bs.carousel",(t=>this.cycle(t)))),this._config.touch&&this._touchSupported&&this._addTouchEventListeners()}_addTouchEventListeners(){const t=t=>this._pointerEvent&&("pen"===t.pointerType||"touch"===t.pointerType),e=e=>{t(e)?this.touchStartX=e.clientX:this._pointerEvent||(this.touchStartX=e.touches[0].clientX)},i=t=>{this.touchDeltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this.touchStartX},n=e=>{t(e)&&(this.touchDeltaX=e.clientX-this.touchStartX),this._handleSwipe(),"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((t=>this.cycle(t)),500+this._config.interval))};V.find(".carousel-item img",this._element).forEach((t=>{j.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()))})),this._pointerEvent?(j.on(this._element,"pointerdown.bs.carousel",(t=>e(t))),j.on(this._element,"pointerup.bs.carousel",(t=>n(t))),this._element.classList.add("pointer-event")):(j.on(this._element,"touchstart.bs.carousel",(t=>e(t))),j.on(this._element,"touchmove.bs.carousel",(t=>i(t))),j.on(this._element,"touchend.bs.carousel",(t=>n(t))))}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=tt[t.key];e&&(t.preventDefault(),this._slide(e))}_getItemIndex(t){return this._items=t&&t.parentNode?V.find(".carousel-item",t.parentNode):[],this._items.indexOf(t)}_getItemByOrder(t,e){const i=t===Q;return v(this._items,e,i,this._config.wrap)}_triggerSlideEvent(t,e){const i=this._getItemIndex(t),n=this._getItemIndex(V.findOne(nt,this._element));return j.trigger(this._element,"slide.bs.carousel",{relatedTarget:t,direction:e,from:n,to:i})}_setActiveIndicatorElement(t){if(this._indicatorsElement){const e=V.findOne(".active",this._indicatorsElement);e.classList.remove(it),e.removeAttribute("aria-current");const i=V.find("[data-bs-target]",this._indicatorsElement);for(let e=0;e{j.trigger(this._element,et,{relatedTarget:o,direction:d,from:s,to:r})};if(this._element.classList.contains("slide")){o.classList.add(h),u(o),n.classList.add(c),o.classList.add(c);const t=()=>{o.classList.remove(c,h),o.classList.add(it),n.classList.remove(it,h,c),this._isSliding=!1,setTimeout(f,0)};this._queueCallback(t,n,!0)}else n.classList.remove(it),o.classList.add(it),this._isSliding=!1,f();a&&this.cycle()}_directionToOrder(t){return[J,Z].includes(t)?m()?t===Z?G:Q:t===Z?Q:G:t}_orderToDirection(t){return[Q,G].includes(t)?m()?t===G?Z:J:t===G?J:Z:t}static carouselInterface(t,e){const i=st.getOrCreateInstance(t,e);let{_config:n}=i;"object"==typeof e&&(n={...n,...e});const s="string"==typeof e?e:n.slide;if("number"==typeof e)i.to(e);else if("string"==typeof s){if(void 0===i[s])throw new TypeError(`No method named "${s}"`);i[s]()}else n.interval&&n.ride&&(i.pause(),i.cycle())}static jQueryInterface(t){return this.each((function(){st.carouselInterface(this,t)}))}static dataApiClickHandler(t){const e=n(this);if(!e||!e.classList.contains("carousel"))return;const i={...U.getDataAttributes(e),...U.getDataAttributes(this)},s=this.getAttribute("data-bs-slide-to");s&&(i.interval=!1),st.carouselInterface(e,i),s&&st.getInstance(e).to(s),t.preventDefault()}}j.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",st.dataApiClickHandler),j.on(window,"load.bs.carousel.data-api",(()=>{const t=V.find('[data-bs-ride="carousel"]');for(let e=0,i=t.length;et===this._element));null!==s&&o.length&&(this._selector=s,this._triggerArray.push(e))}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return rt}static get NAME(){return ot}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t,e=[];if(this._config.parent){const t=V.find(ut,this._config.parent);e=V.find(".collapse.show, .collapse.collapsing",this._config.parent).filter((e=>!t.includes(e)))}const i=V.findOne(this._selector);if(e.length){const n=e.find((t=>i!==t));if(t=n?pt.getInstance(n):null,t&&t._isTransitioning)return}if(j.trigger(this._element,"show.bs.collapse").defaultPrevented)return;e.forEach((e=>{i!==e&&pt.getOrCreateInstance(e,{toggle:!1}).hide(),t||H.set(e,"bs.collapse",null)}));const n=this._getDimension();this._element.classList.remove(ct),this._element.classList.add(ht),this._element.style[n]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const s=`scroll${n[0].toUpperCase()+n.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct,lt),this._element.style[n]="",j.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[n]=`${this._element[s]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,u(this._element),this._element.classList.add(ht),this._element.classList.remove(ct,lt);const e=this._triggerArray.length;for(let t=0;t{this._isTransitioning=!1,this._element.classList.remove(ht),this._element.classList.add(ct),j.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(lt)}_getConfig(t){return(t={...rt,...U.getDataAttributes(this._element),...t}).toggle=Boolean(t.toggle),t.parent=r(t.parent),a(ot,t,at),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=V.find(ut,this._config.parent);V.find(ft,this._config.parent).filter((e=>!t.includes(e))).forEach((t=>{const e=n(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}))}_addAriaAndCollapsedClass(t,e){t.length&&t.forEach((t=>{e?t.classList.remove(dt):t.classList.add(dt),t.setAttribute("aria-expanded",e)}))}static jQueryInterface(t){return this.each((function(){const e={};"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1);const i=pt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,"click.bs.collapse.data-api",ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();const e=i(this);V.find(e).forEach((t=>{pt.getOrCreateInstance(t,{toggle:!1}).toggle()}))})),g(pt);var mt="top",gt="bottom",_t="right",bt="left",vt="auto",yt=[mt,gt,_t,bt],wt="start",Et="end",At="clippingParents",Tt="viewport",Ot="popper",Ct="reference",kt=yt.reduce((function(t,e){return t.concat([e+"-"+wt,e+"-"+Et])}),[]),Lt=[].concat(yt,[vt]).reduce((function(t,e){return t.concat([e,e+"-"+wt,e+"-"+Et])}),[]),xt="beforeRead",Dt="read",St="afterRead",Nt="beforeMain",It="main",Pt="afterMain",jt="beforeWrite",Mt="write",Ht="afterWrite",Bt=[xt,Dt,St,Nt,It,Pt,jt,Mt,Ht];function Rt(t){return t?(t.nodeName||"").toLowerCase():null}function Wt(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function $t(t){return t instanceof Wt(t).Element||t instanceof Element}function zt(t){return t instanceof Wt(t).HTMLElement||t instanceof HTMLElement}function qt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof Wt(t).ShadowRoot||t instanceof ShadowRoot)}const Ft={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];zt(s)&&Rt(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});zt(n)&&Rt(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function Ut(t){return t.split("-")[0]}function Vt(t,e){var i=t.getBoundingClientRect();return{width:i.width/1,height:i.height/1,top:i.top/1,right:i.right/1,bottom:i.bottom/1,left:i.left/1,x:i.left/1,y:i.top/1}}function Kt(t){var e=Vt(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Xt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&qt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Yt(t){return Wt(t).getComputedStyle(t)}function Qt(t){return["table","td","th"].indexOf(Rt(t))>=0}function Gt(t){return(($t(t)?t.ownerDocument:t.document)||window.document).documentElement}function Zt(t){return"html"===Rt(t)?t:t.assignedSlot||t.parentNode||(qt(t)?t.host:null)||Gt(t)}function Jt(t){return zt(t)&&"fixed"!==Yt(t).position?t.offsetParent:null}function te(t){for(var e=Wt(t),i=Jt(t);i&&Qt(i)&&"static"===Yt(i).position;)i=Jt(i);return i&&("html"===Rt(i)||"body"===Rt(i)&&"static"===Yt(i).position)?e:i||function(t){var e=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1!==navigator.userAgent.indexOf("Trident")&&zt(t)&&"fixed"===Yt(t).position)return null;for(var i=Zt(t);zt(i)&&["html","body"].indexOf(Rt(i))<0;){var n=Yt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function ee(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}var ie=Math.max,ne=Math.min,se=Math.round;function oe(t,e,i){return ie(t,ne(e,i))}function re(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function ae(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const le={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=Ut(i.placement),l=ee(a),c=[bt,_t].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return re("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:ae(t,yt))}(s.padding,i),d=Kt(o),u="y"===l?mt:bt,f="y"===l?gt:_t,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=te(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,E=oe(v,w,y),A=l;i.modifiersData[n]=((e={})[A]=E,e.centerOffset=E-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Xt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function ce(t){return t.split("-")[1]}var he={top:"auto",right:"auto",bottom:"auto",left:"auto"};function de(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=!0===h?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:se(se(e*n)/n)||0,y:se(se(i*n)/n)||0}}(r):"function"==typeof h?h(r):r,u=d.x,f=void 0===u?0:u,p=d.y,m=void 0===p?0:p,g=r.hasOwnProperty("x"),_=r.hasOwnProperty("y"),b=bt,v=mt,y=window;if(c){var w=te(i),E="clientHeight",A="clientWidth";w===Wt(i)&&"static"!==Yt(w=Gt(i)).position&&"absolute"===a&&(E="scrollHeight",A="scrollWidth"),w=w,s!==mt&&(s!==bt&&s!==_t||o!==Et)||(v=gt,m-=w[E]-n.height,m*=l?1:-1),s!==bt&&(s!==mt&&s!==gt||o!==Et)||(b=_t,f-=w[A]-n.width,f*=l?1:-1)}var T,O=Object.assign({position:a},c&&he);return l?Object.assign({},O,((T={})[v]=_?"0":"",T[b]=g?"0":"",T.transform=(y.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",T)):Object.assign({},O,((e={})[v]=_?m+"px":"",e[b]=g?f+"px":"",e.transform="",e))}const ue={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:Ut(e.placement),variation:ce(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,de(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,de(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var fe={passive:!0};const pe={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=Wt(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,fe)})),a&&l.addEventListener("resize",i.update,fe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,fe)})),a&&l.removeEventListener("resize",i.update,fe)}},data:{}};var me={left:"right",right:"left",bottom:"top",top:"bottom"};function ge(t){return t.replace(/left|right|bottom|top/g,(function(t){return me[t]}))}var _e={start:"end",end:"start"};function be(t){return t.replace(/start|end/g,(function(t){return _e[t]}))}function ve(t){var e=Wt(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function ye(t){return Vt(Gt(t)).left+ve(t).scrollLeft}function we(t){var e=Yt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ee(t){return["html","body","#document"].indexOf(Rt(t))>=0?t.ownerDocument.body:zt(t)&&we(t)?t:Ee(Zt(t))}function Ae(t,e){var i;void 0===e&&(e=[]);var n=Ee(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=Wt(n),r=s?[o].concat(o.visualViewport||[],we(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Ae(Zt(r)))}function Te(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Oe(t,e){return e===Tt?Te(function(t){var e=Wt(t),i=Gt(t),n=e.visualViewport,s=i.clientWidth,o=i.clientHeight,r=0,a=0;return n&&(s=n.width,o=n.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(r=n.offsetLeft,a=n.offsetTop)),{width:s,height:o,x:r+ye(t),y:a}}(t)):zt(e)?function(t){var e=Vt(t);return e.top=e.top+t.clientTop,e.left=e.left+t.clientLeft,e.bottom=e.top+t.clientHeight,e.right=e.left+t.clientWidth,e.width=t.clientWidth,e.height=t.clientHeight,e.x=e.left,e.y=e.top,e}(e):Te(function(t){var e,i=Gt(t),n=ve(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ie(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ie(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+ye(t),l=-n.scrollTop;return"rtl"===Yt(s||i).direction&&(a+=ie(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Gt(t)))}function Ce(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?Ut(s):null,r=s?ce(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case mt:e={x:a,y:i.y-n.height};break;case gt:e={x:a,y:i.y+i.height};break;case _t:e={x:i.x+i.width,y:l};break;case bt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?ee(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case wt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Et:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ke(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.boundary,r=void 0===o?At:o,a=i.rootBoundary,l=void 0===a?Tt:a,c=i.elementContext,h=void 0===c?Ot:c,d=i.altBoundary,u=void 0!==d&&d,f=i.padding,p=void 0===f?0:f,m=re("number"!=typeof p?p:ae(p,yt)),g=h===Ot?Ct:Ot,_=t.rects.popper,b=t.elements[u?g:h],v=function(t,e,i){var n="clippingParents"===e?function(t){var e=Ae(Zt(t)),i=["absolute","fixed"].indexOf(Yt(t).position)>=0&&zt(t)?te(t):t;return $t(i)?e.filter((function(t){return $t(t)&&Xt(t,i)&&"body"!==Rt(t)})):[]}(t):[].concat(e),s=[].concat(n,[i]),o=s[0],r=s.reduce((function(e,i){var n=Oe(t,i);return e.top=ie(n.top,e.top),e.right=ne(n.right,e.right),e.bottom=ne(n.bottom,e.bottom),e.left=ie(n.left,e.left),e}),Oe(t,o));return r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}($t(b)?b:b.contextElement||Gt(t.elements.popper),r,l),y=Vt(t.elements.reference),w=Ce({reference:y,element:_,strategy:"absolute",placement:s}),E=Te(Object.assign({},_,w)),A=h===Ot?E:y,T={top:v.top-A.top+m.top,bottom:A.bottom-v.bottom+m.bottom,left:v.left-A.left+m.left,right:A.right-v.right+m.right},O=t.modifiersData.offset;if(h===Ot&&O){var C=O[s];Object.keys(T).forEach((function(t){var e=[_t,gt].indexOf(t)>=0?1:-1,i=[mt,gt].indexOf(t)>=0?"y":"x";T[t]+=C[i]*e}))}return T}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?Lt:l,h=ce(n),d=h?a?kt:kt.filter((function(t){return ce(t)===h})):yt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ke(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[Ut(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const xe={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=Ut(g),b=l||(_!==g&&p?function(t){if(Ut(t)===vt)return[];var e=ge(t);return[be(t),e,be(e)]}(g):[ge(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(Ut(i)===vt?Le(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,E=new Map,A=!0,T=v[0],O=0;O=0,D=x?"width":"height",S=ke(e,{placement:C,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),N=x?L?_t:bt:L?gt:mt;y[D]>w[D]&&(N=ge(N));var I=ge(N),P=[];if(o&&P.push(S[k]<=0),a&&P.push(S[N]<=0,S[I]<=0),P.every((function(t){return t}))){T=C,A=!1;break}E.set(C,P)}if(A)for(var j=function(t){var e=v.find((function(e){var i=E.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==j(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function De(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Se(t){return[mt,_t,gt,bt].some((function(e){return t[e]>=0}))}const Ne={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=De(r,n),c=De(a,s,o),h=Se(l),d=Se(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},Ie={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=Lt.reduce((function(t,i){return t[i]=function(t,e,i){var n=Ut(t),s=[bt,mt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[bt,_t].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},Pe={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=Ce({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},je={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=Ut(e.placement),b=ce(e.placement),v=!b,y=ee(_),w="x"===y?"y":"x",E=e.modifiersData.popperOffsets,A=e.rects.reference,T=e.rects.popper,O="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,C={x:0,y:0};if(E){if(o||a){var k="y"===y?mt:bt,L="y"===y?gt:_t,x="y"===y?"height":"width",D=E[y],S=E[y]+g[k],N=E[y]-g[L],I=f?-T[x]/2:0,P=b===wt?A[x]:T[x],j=b===wt?-T[x]:-A[x],M=e.elements.arrow,H=f&&M?Kt(M):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},R=B[k],W=B[L],$=oe(0,A[x],H[x]),z=v?A[x]/2-I-$-R-O:P-$-R-O,q=v?-A[x]/2+I+$+W+O:j+$+W+O,F=e.elements.arrow&&te(e.elements.arrow),U=F?"y"===y?F.clientTop||0:F.clientLeft||0:0,V=e.modifiersData.offset?e.modifiersData.offset[e.placement][y]:0,K=E[y]+z-V-U,X=E[y]+q-V;if(o){var Y=oe(f?ne(S,K):S,D,f?ie(N,X):N);E[y]=Y,C[y]=Y-D}if(a){var Q="x"===y?mt:bt,G="x"===y?gt:_t,Z=E[w],J=Z+g[Q],tt=Z-g[G],et=oe(f?ne(J,K):J,Z,f?ie(tt,X):tt);E[w]=et,C[w]=et-Z}}e.modifiersData[n]=C}},requiresIfExists:["offset"]};function Me(t,e,i){void 0===i&&(i=!1);var n=zt(e);zt(e)&&function(t){var e=t.getBoundingClientRect();e.width,t.offsetWidth,e.height,t.offsetHeight}(e);var s,o,r=Gt(e),a=Vt(t),l={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(n||!n&&!i)&&(("body"!==Rt(e)||we(r))&&(l=(s=e)!==Wt(s)&&zt(s)?{scrollLeft:(o=s).scrollLeft,scrollTop:o.scrollTop}:ve(s)),zt(e)?((c=Vt(e)).x+=e.clientLeft,c.y+=e.clientTop):r&&(c.x=ye(r))),{x:a.left+l.scrollLeft-c.x,y:a.top+l.scrollTop-c.y,width:a.width,height:a.height}}function He(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var Be={placement:"bottom",modifiers:[],strategy:"absolute"};function Re(){for(var t=arguments.length,e=new Array(t),i=0;ij.on(t,"mouseover",d))),this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(Je),this._element.classList.add(Je),j.trigger(this._element,"shown.bs.dropdown",t)}hide(){if(c(this._element)||!this._isShown(this._menu))return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){j.trigger(this._element,"hide.bs.dropdown",t).defaultPrevented||("ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._popper&&this._popper.destroy(),this._menu.classList.remove(Je),this._element.classList.remove(Je),this._element.setAttribute("aria-expanded","false"),U.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,"hidden.bs.dropdown",t))}_getConfig(t){if(t={...this.constructor.Default,...U.getDataAttributes(this._element),...t},a(Ue,t,this.constructor.DefaultType),"object"==typeof t.reference&&!o(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Ue.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(t){if(void 0===Fe)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let e=this._element;"parent"===this._config.reference?e=t:o(this._config.reference)?e=r(this._config.reference):"object"==typeof this._config.reference&&(e=this._config.reference);const i=this._getPopperConfig(),n=i.modifiers.find((t=>"applyStyles"===t.name&&!1===t.enabled));this._popper=qe(e,this._menu,i),n&&U.setDataAttribute(this._menu,"popper","static")}_isShown(t=this._element){return t.classList.contains(Je)}_getMenuElement(){return V.next(this._element,ei)[0]}_getPlacement(){const t=this._element.parentNode;if(t.classList.contains("dropend"))return ri;if(t.classList.contains("dropstart"))return ai;const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?ni:ii:e?oi:si}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return"static"===this._config.display&&(t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,..."function"==typeof this._config.popperConfig?this._config.popperConfig(t):this._config.popperConfig}}_selectMenuItem({key:t,target:e}){const i=V.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter(l);i.length&&v(i,e,t===Ye,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(t&&(2===t.button||"keyup"===t.type&&"Tab"!==t.key))return;const e=V.find(ti);for(let i=0,n=e.length;ie+t)),this._setElementAttributes(di,"paddingRight",(e=>e+t)),this._setElementAttributes(ui,"marginRight",(e=>e-t))}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t)[e];t.style[e]=`${i(Number.parseFloat(s))}px`}))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,"paddingRight"),this._resetElementAttributes(di,"paddingRight"),this._resetElementAttributes(ui,"marginRight")}_saveInitialAttribute(t,e){const i=t.style[e];i&&U.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=U.getDataAttribute(t,e);void 0===i?t.style.removeProperty(e):(U.removeDataAttribute(t,e),t.style[e]=i)}))}_applyManipulationCallback(t,e){o(t)?e(t):V.find(t,this._element).forEach(e)}isOverflowing(){return this.getWidth()>0}}const pi={className:"modal-backdrop",isVisible:!0,isAnimated:!1,rootElement:"body",clickCallback:null},mi={className:"string",isVisible:"boolean",isAnimated:"boolean",rootElement:"(element|string)",clickCallback:"(function|null)"},gi="show",_i="mousedown.bs.backdrop";class bi{constructor(t){this._config=this._getConfig(t),this._isAppended=!1,this._element=null}show(t){this._config.isVisible?(this._append(),this._config.isAnimated&&u(this._getElement()),this._getElement().classList.add(gi),this._emulateAnimation((()=>{_(t)}))):_(t)}hide(t){this._config.isVisible?(this._getElement().classList.remove(gi),this._emulateAnimation((()=>{this.dispose(),_(t)}))):_(t)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_getConfig(t){return(t={...pi,..."object"==typeof t?t:{}}).rootElement=r(t.rootElement),a("backdrop",t,mi),t}_append(){this._isAppended||(this._config.rootElement.append(this._getElement()),j.on(this._getElement(),_i,(()=>{_(this._config.clickCallback)})),this._isAppended=!0)}dispose(){this._isAppended&&(j.off(this._element,_i),this._element.remove(),this._isAppended=!1)}_emulateAnimation(t){b(t,this._getElement(),this._config.isAnimated)}}const vi={trapElement:null,autofocus:!0},yi={trapElement:"element",autofocus:"boolean"},wi=".bs.focustrap",Ei="backward";class Ai{constructor(t){this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}activate(){const{trapElement:t,autofocus:e}=this._config;this._isActive||(e&&t.focus(),j.off(document,wi),j.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),j.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,wi))}_handleFocusin(t){const{target:e}=t,{trapElement:i}=this._config;if(e===document||e===i||i.contains(e))return;const n=V.focusableChildren(i);0===n.length?i.focus():this._lastTabNavDirection===Ei?n[n.length-1].focus():n[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Ei:"forward")}_getConfig(t){return t={...vi,..."object"==typeof t?t:{}},a("focustrap",t,yi),t}}const Ti="modal",Oi="Escape",Ci={backdrop:!0,keyboard:!0,focus:!0},ki={backdrop:"(boolean|string)",keyboard:"boolean",focus:"boolean"},Li="hidden.bs.modal",xi="show.bs.modal",Di="resize.bs.modal",Si="click.dismiss.bs.modal",Ni="keydown.dismiss.bs.modal",Ii="mousedown.dismiss.bs.modal",Pi="modal-open",ji="show",Mi="modal-static";class Hi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._dialog=V.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._ignoreBackdropClick=!1,this._isTransitioning=!1,this._scrollBar=new fi}static get Default(){return Ci}static get NAME(){return Ti}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,xi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isAnimated()&&(this._isTransitioning=!0),this._scrollBar.hide(),document.body.classList.add(Pi),this._adjustDialog(),this._setEscapeEvent(),this._setResizeEvent(),j.on(this._dialog,Ii,(()=>{j.one(this._element,"mouseup.dismiss.bs.modal",(t=>{t.target===this._element&&(this._ignoreBackdropClick=!0)}))})),this._showBackdrop((()=>this._showElement(t))))}hide(){if(!this._isShown||this._isTransitioning)return;if(j.trigger(this._element,"hide.bs.modal").defaultPrevented)return;this._isShown=!1;const t=this._isAnimated();t&&(this._isTransitioning=!0),this._setEscapeEvent(),this._setResizeEvent(),this._focustrap.deactivate(),this._element.classList.remove(ji),j.off(this._element,Si),j.off(this._dialog,Ii),this._queueCallback((()=>this._hideModal()),this._element,t)}dispose(){[window,this._dialog].forEach((t=>j.off(t,".bs.modal"))),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new bi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_getConfig(t){return t={...Ci,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Ti,t,ki),t}_showElement(t){const e=this._isAnimated(),i=V.findOne(".modal-body",this._dialog);this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0,i&&(i.scrollTop=0),e&&u(this._element),this._element.classList.add(ji),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,e)}_setEscapeEvent(){this._isShown?j.on(this._element,Ni,(t=>{this._config.keyboard&&t.key===Oi?(t.preventDefault(),this.hide()):this._config.keyboard||t.key!==Oi||this._triggerBackdropTransition()})):j.off(this._element,Ni)}_setResizeEvent(){this._isShown?j.on(window,Di,(()=>this._adjustDialog())):j.off(window,Di)}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Pi),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,Li)}))}_showBackdrop(t){j.on(this._element,Si,(t=>{this._ignoreBackdropClick?this._ignoreBackdropClick=!1:t.target===t.currentTarget&&(!0===this._config.backdrop?this.hide():"static"===this._config.backdrop&&this._triggerBackdropTransition())})),this._backdrop.show(t)}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const{classList:t,scrollHeight:e,style:i}=this._element,n=e>document.documentElement.clientHeight;!n&&"hidden"===i.overflowY||t.contains(Mi)||(n||(i.overflowY="hidden"),t.add(Mi),this._queueCallback((()=>{t.remove(Mi),n||this._queueCallback((()=>{i.overflowY=""}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;(!i&&t&&!m()||i&&!t&&m())&&(this._element.style.paddingLeft=`${e}px`),(i&&!t&&!m()||!i&&t&&m())&&(this._element.style.paddingRight=`${e}px`)}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Hi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=n(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,xi,(t=>{t.defaultPrevented||j.one(e,Li,(()=>{l(this)&&this.focus()}))}));const i=V.findOne(".modal.show");i&&Hi.getInstance(i).hide(),Hi.getOrCreateInstance(e).toggle(this)})),R(Hi),g(Hi);const Bi="offcanvas",Ri={backdrop:!0,keyboard:!0,scroll:!1},Wi={backdrop:"boolean",keyboard:"boolean",scroll:"boolean"},$i="show",zi=".offcanvas.show",qi="hidden.bs.offcanvas";class Fi extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get NAME(){return Bi}static get Default(){return Ri}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._element.style.visibility="visible",this._backdrop.show(),this._config.scroll||(new fi).hide(),this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add($i),this._queueCallback((()=>{this._config.scroll||this._focustrap.activate(),j.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.remove($i),this._backdrop.hide(),this._queueCallback((()=>{this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._element.style.visibility="hidden",this._config.scroll||(new fi).reset(),j.trigger(this._element,qi)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_getConfig(t){return t={...Ri,...U.getDataAttributes(this._element),..."object"==typeof t?t:{}},a(Bi,t,Wi),t}_initializeBackDrop(){return new bi({className:"offcanvas-backdrop",isVisible:this._config.backdrop,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:()=>this.hide()})}_initializeFocusTrap(){return new Ai({trapElement:this._element})}_addEventListeners(){j.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{this._config.keyboard&&"Escape"===t.key&&this.hide()}))}static jQueryInterface(t){return this.each((function(){const e=Fi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=n(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this))return;j.one(e,qi,(()=>{l(this)&&this.focus()}));const i=V.findOne(zi);i&&i!==e&&Fi.getInstance(i).hide(),Fi.getOrCreateInstance(e).toggle(this)})),j.on(window,"load.bs.offcanvas.data-api",(()=>V.find(zi).forEach((t=>Fi.getOrCreateInstance(t).show())))),R(Fi),g(Fi);const Ui=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Vi=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,Ki=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,Xi=(t,e)=>{const i=t.nodeName.toLowerCase();if(e.includes(i))return!Ui.has(i)||Boolean(Vi.test(t.nodeValue)||Ki.test(t.nodeValue));const n=e.filter((t=>t instanceof RegExp));for(let t=0,e=n.length;t{Xi(t,r)||i.removeAttribute(t.nodeName)}))}return n.body.innerHTML}const Qi="tooltip",Gi=new Set(["sanitize","allowList","sanitizeFn"]),Zi={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(array|string|function)",container:"(string|element|boolean)",fallbackPlacements:"array",boundary:"(string|element)",customClass:"(string|function)",sanitize:"boolean",sanitizeFn:"(null|function)",allowList:"object",popperConfig:"(null|object|function)"},Ji={AUTO:"auto",TOP:"top",RIGHT:m()?"left":"right",BOTTOM:"bottom",LEFT:m()?"right":"left"},tn={animation:!0,template:'',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:[0,0],container:!1,fallbackPlacements:["top","right","bottom","left"],boundary:"clippingParents",customClass:"",sanitize:!0,sanitizeFn:null,allowList:{"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},popperConfig:null},en={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"},nn="fade",sn="show",on="show",rn="out",an=".tooltip-inner",ln=".modal",cn="hide.bs.modal",hn="hover",dn="focus";class un extends B{constructor(t,e){if(void 0===Fe)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t),this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this._config=this._getConfig(e),this.tip=null,this._setListeners()}static get Default(){return tn}static get NAME(){return Qi}static get Event(){return en}static get DefaultType(){return Zi}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(t){if(this._isEnabled)if(t){const e=this._initializeOnDelegatedTarget(t);e._activeTrigger.click=!e._activeTrigger.click,e._isWithActiveTrigger()?e._enter(null,e):e._leave(null,e)}else{if(this.getTipElement().classList.contains(sn))return void this._leave(null,this);this._enter(null,this)}}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ln),cn,this._hideModalHandler),this.tip&&this.tip.remove(),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this.isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.Event.SHOW),e=h(this._element),i=null===e?this._element.ownerDocument.documentElement.contains(this._element):e.contains(this._element);if(t.defaultPrevented||!i)return;"tooltip"===this.constructor.NAME&&this.tip&&this.getTitle()!==this.tip.querySelector(an).innerHTML&&(this._disposePopper(),this.tip.remove(),this.tip=null);const n=this.getTipElement(),s=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME);n.setAttribute("id",s),this._element.setAttribute("aria-describedby",s),this._config.animation&&n.classList.add(nn);const o="function"==typeof this._config.placement?this._config.placement.call(this,n,this._element):this._config.placement,r=this._getAttachment(o);this._addAttachmentClass(r);const{container:a}=this._config;H.set(n,this.constructor.DATA_KEY,this),this._element.ownerDocument.documentElement.contains(this.tip)||(a.append(n),j.trigger(this._element,this.constructor.Event.INSERTED)),this._popper?this._popper.update():this._popper=qe(this._element,n,this._getPopperConfig(r)),n.classList.add(sn);const l=this._resolvePossibleFunction(this._config.customClass);l&&n.classList.add(...l.split(" ")),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>{j.on(t,"mouseover",d)}));const c=this.tip.classList.contains(nn);this._queueCallback((()=>{const t=this._hoverState;this._hoverState=null,j.trigger(this._element,this.constructor.Event.SHOWN),t===rn&&this._leave(null,this)}),this.tip,c)}hide(){if(!this._popper)return;const t=this.getTipElement();if(j.trigger(this._element,this.constructor.Event.HIDE).defaultPrevented)return;t.classList.remove(sn),"ontouchstart"in document.documentElement&&[].concat(...document.body.children).forEach((t=>j.off(t,"mouseover",d))),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1;const e=this.tip.classList.contains(nn);this._queueCallback((()=>{this._isWithActiveTrigger()||(this._hoverState!==on&&t.remove(),this._cleanTipClass(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.Event.HIDDEN),this._disposePopper())}),this.tip,e),this._hoverState=""}update(){null!==this._popper&&this._popper.update()}isWithContent(){return Boolean(this.getTitle())}getTipElement(){if(this.tip)return this.tip;const t=document.createElement("div");t.innerHTML=this._config.template;const e=t.children[0];return this.setContent(e),e.classList.remove(nn,sn),this.tip=e,this.tip}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),an)}_sanitizeAndSetContent(t,e,i){const n=V.findOne(i,t);e||!n?this.setElementContent(n,e):n.remove()}setElementContent(t,e){if(null!==t)return o(e)?(e=r(e),void(this._config.html?e.parentNode!==t&&(t.innerHTML="",t.append(e)):t.textContent=e.textContent)):void(this._config.html?(this._config.sanitize&&(e=Yi(e,this._config.allowList,this._config.sanitizeFn)),t.innerHTML=e):t.textContent=e)}getTitle(){const t=this._element.getAttribute("data-bs-original-title")||this._config.title;return this._resolvePossibleFunction(t)}updateAttachment(t){return"right"===t?"end":"left"===t?"start":t}_initializeOnDelegatedTarget(t,e){return e||this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return"function"==typeof t?t.call(this._element):t}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"onChange",enabled:!0,phase:"afterWrite",fn:t=>this._handlePopperPlacementChange(t)}],onFirstUpdate:t=>{t.options.placement!==t.placement&&this._handlePopperPlacementChange(t)}};return{...e,..."function"==typeof this._config.popperConfig?this._config.popperConfig(e):this._config.popperConfig}}_addAttachmentClass(t){this.getTipElement().classList.add(`${this._getBasicClassPrefix()}-${this.updateAttachment(t)}`)}_getAttachment(t){return Ji[t.toUpperCase()]}_setListeners(){this._config.trigger.split(" ").forEach((t=>{if("click"===t)j.on(this._element,this.constructor.Event.CLICK,this._config.selector,(t=>this.toggle(t)));else if("manual"!==t){const e=t===hn?this.constructor.Event.MOUSEENTER:this.constructor.Event.FOCUSIN,i=t===hn?this.constructor.Event.MOUSELEAVE:this.constructor.Event.FOCUSOUT;j.on(this._element,e,this._config.selector,(t=>this._enter(t))),j.on(this._element,i,this._config.selector,(t=>this._leave(t)))}})),this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ln),cn,this._hideModalHandler),this._config.selector?this._config={...this._config,trigger:"manual",selector:""}:this._fixTitle()}_fixTitle(){const t=this._element.getAttribute("title"),e=typeof this._element.getAttribute("data-bs-original-title");(t||"string"!==e)&&(this._element.setAttribute("data-bs-original-title",t||""),!t||this._element.getAttribute("aria-label")||this._element.textContent||this._element.setAttribute("aria-label",t),this._element.setAttribute("title",""))}_enter(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusin"===t.type?dn:hn]=!0),e.getTipElement().classList.contains(sn)||e._hoverState===on?e._hoverState=on:(clearTimeout(e._timeout),e._hoverState=on,e._config.delay&&e._config.delay.show?e._timeout=setTimeout((()=>{e._hoverState===on&&e.show()}),e._config.delay.show):e.show())}_leave(t,e){e=this._initializeOnDelegatedTarget(t,e),t&&(e._activeTrigger["focusout"===t.type?dn:hn]=e._element.contains(t.relatedTarget)),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=rn,e._config.delay&&e._config.delay.hide?e._timeout=setTimeout((()=>{e._hoverState===rn&&e.hide()}),e._config.delay.hide):e.hide())}_isWithActiveTrigger(){for(const t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1}_getConfig(t){const e=U.getDataAttributes(this._element);return Object.keys(e).forEach((t=>{Gi.has(t)&&delete e[t]})),(t={...this.constructor.Default,...e,..."object"==typeof t&&t?t:{}}).container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),a(Qi,t,this.constructor.DefaultType),t.sanitize&&(t.template=Yi(t.template,t.allowList,t.sanitizeFn)),t}_getDelegateConfig(){const t={};for(const e in this._config)this.constructor.Default[e]!==this._config[e]&&(t[e]=this._config[e]);return t}_cleanTipClass(){const t=this.getTipElement(),e=new RegExp(`(^|\\s)${this._getBasicClassPrefix()}\\S+`,"g"),i=t.getAttribute("class").match(e);null!==i&&i.length>0&&i.map((t=>t.trim())).forEach((e=>t.classList.remove(e)))}_getBasicClassPrefix(){return"bs-tooltip"}_handlePopperPlacementChange(t){const{state:e}=t;e&&(this.tip=e.elements.popper,this._cleanTipClass(),this._addAttachmentClass(this._getAttachment(e.placement)))}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null)}static jQueryInterface(t){return this.each((function(){const e=un.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(un);const fn={...un.Default,placement:"right",offset:[0,8],trigger:"click",content:"",template:''},pn={...un.DefaultType,content:"(string|element|function)"},mn={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"};class gn extends un{static get Default(){return fn}static get NAME(){return"popover"}static get Event(){return mn}static get DefaultType(){return pn}isWithContent(){return this.getTitle()||this._getContent()}setContent(t){this._sanitizeAndSetContent(t,this.getTitle(),".popover-header"),this._sanitizeAndSetContent(t,this._getContent(),".popover-body")}_getContent(){return this._resolvePossibleFunction(this._config.content)}_getBasicClassPrefix(){return"bs-popover"}static jQueryInterface(t){return this.each((function(){const e=gn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}g(gn);const _n="scrollspy",bn={offset:10,method:"auto",target:""},vn={offset:"number",method:"string",target:"(string|element)"},yn="active",wn=".nav-link, .list-group-item, .dropdown-item",En="position";class An extends B{constructor(t,e){super(t),this._scrollElement="BODY"===this._element.tagName?window:this._element,this._config=this._getConfig(e),this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,j.on(this._scrollElement,"scroll.bs.scrollspy",(()=>this._process())),this.refresh(),this._process()}static get Default(){return bn}static get NAME(){return _n}refresh(){const t=this._scrollElement===this._scrollElement.window?"offset":En,e="auto"===this._config.method?t:this._config.method,n=e===En?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),V.find(wn,this._config.target).map((t=>{const s=i(t),o=s?V.findOne(s):null;if(o){const t=o.getBoundingClientRect();if(t.width||t.height)return[U[e](o).top+n,s]}return null})).filter((t=>t)).sort(((t,e)=>t[0]-e[0])).forEach((t=>{this._offsets.push(t[0]),this._targets.push(t[1])}))}dispose(){j.off(this._scrollElement,".bs.scrollspy"),super.dispose()}_getConfig(t){return(t={...bn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}}).target=r(t.target)||document.documentElement,a(_n,t,vn),t}_getScrollTop(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop}_getScrollHeight(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)}_getOffsetHeight(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height}_process(){const t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),i=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=i){const t=this._targets[this._targets.length-1];this._activeTarget!==t&&this._activate(t)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(let e=this._offsets.length;e--;)this._activeTarget!==this._targets[e]&&t>=this._offsets[e]&&(void 0===this._offsets[e+1]||t`${e}[data-bs-target="${t}"],${e}[href="${t}"]`)),i=V.findOne(e.join(","),this._config.target);i.classList.add(yn),i.classList.contains("dropdown-item")?V.findOne(".dropdown-toggle",i.closest(".dropdown")).classList.add(yn):V.parents(i,".nav, .list-group").forEach((t=>{V.prev(t,".nav-link, .list-group-item").forEach((t=>t.classList.add(yn))),V.prev(t,".nav-item").forEach((t=>{V.children(t,".nav-link").forEach((t=>t.classList.add(yn)))}))})),j.trigger(this._scrollElement,"activate.bs.scrollspy",{relatedTarget:t})}_clear(){V.find(wn,this._config.target).filter((t=>t.classList.contains(yn))).forEach((t=>t.classList.remove(yn)))}static jQueryInterface(t){return this.each((function(){const e=An.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,"load.bs.scrollspy.data-api",(()=>{V.find('[data-bs-spy="scroll"]').forEach((t=>new An(t)))})),g(An);const Tn="active",On="fade",Cn="show",kn=".active",Ln=":scope > li > .active";class xn extends B{static get NAME(){return"tab"}show(){if(this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE&&this._element.classList.contains(Tn))return;let t;const e=n(this._element),i=this._element.closest(".nav, .list-group");if(i){const e="UL"===i.nodeName||"OL"===i.nodeName?Ln:kn;t=V.find(e,i),t=t[t.length-1]}const s=t?j.trigger(t,"hide.bs.tab",{relatedTarget:this._element}):null;if(j.trigger(this._element,"show.bs.tab",{relatedTarget:t}).defaultPrevented||null!==s&&s.defaultPrevented)return;this._activate(this._element,i);const o=()=>{j.trigger(t,"hidden.bs.tab",{relatedTarget:this._element}),j.trigger(this._element,"shown.bs.tab",{relatedTarget:t})};e?this._activate(e,e.parentNode,o):o()}_activate(t,e,i){const n=(!e||"UL"!==e.nodeName&&"OL"!==e.nodeName?V.children(e,kn):V.find(Ln,e))[0],s=i&&n&&n.classList.contains(On),o=()=>this._transitionComplete(t,n,i);n&&s?(n.classList.remove(Cn),this._queueCallback(o,t,!0)):o()}_transitionComplete(t,e,i){if(e){e.classList.remove(Tn);const t=V.findOne(":scope > .dropdown-menu .active",e.parentNode);t&&t.classList.remove(Tn),"tab"===e.getAttribute("role")&&e.setAttribute("aria-selected",!1)}t.classList.add(Tn),"tab"===t.getAttribute("role")&&t.setAttribute("aria-selected",!0),u(t),t.classList.contains(On)&&t.classList.add(Cn);let n=t.parentNode;if(n&&"LI"===n.nodeName&&(n=n.parentNode),n&&n.classList.contains("dropdown-menu")){const e=t.closest(".dropdown");e&&V.find(".dropdown-toggle",e).forEach((t=>t.classList.add(Tn))),t.setAttribute("aria-expanded",!0)}i&&i()}static jQueryInterface(t){return this.each((function(){const e=xn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,"click.bs.tab.data-api",'[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),c(this)||xn.getOrCreateInstance(this).show()})),g(xn);const Dn="toast",Sn="hide",Nn="show",In="showing",Pn={animation:"boolean",autohide:"boolean",delay:"number"},jn={animation:!0,autohide:!0,delay:5e3};class Mn extends B{constructor(t,e){super(t),this._config=this._getConfig(e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get DefaultType(){return Pn}static get Default(){return jn}static get NAME(){return Dn}show(){j.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(Sn),u(this._element),this._element.classList.add(Nn),this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.remove(In),j.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this._element.classList.contains(Nn)&&(j.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(In),this._queueCallback((()=>{this._element.classList.add(Sn),this._element.classList.remove(In),this._element.classList.remove(Nn),j.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this._element.classList.contains(Nn)&&this._element.classList.remove(Nn),super.dispose()}_getConfig(t){return t={...jn,...U.getDataAttributes(this._element),..."object"==typeof t&&t?t:{}},a(Dn,t,this.constructor.DefaultType),t}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),j.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),j.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Mn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(Mn),g(Mn),{Alert:W,Button:z,Carousel:st,Collapse:pt,Dropdown:hi,Modal:Hi,Offcanvas:Fi,Popover:gn,ScrollSpy:An,Tab:xn,Toast:Mn,Tooltip:un}})); -//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/web/static/js/common.js b/web/static/js/common.js new file mode 100644 index 0000000..eb1957b --- /dev/null +++ b/web/static/js/common.js @@ -0,0 +1,235 @@ +/** + * 模块名称:面板前端共享工具(web/static/js/common.js) + * 功能描述:app.js / pool.js / proxies.js / tables.js / palette.js / i18n.js 共用的前端工具:HTML 转义、 + * plainDoc 星号剥离、{} 占位符填充、时间与体积格式化、token 拼装、剪贴板兜底、localStorage 安全读写、 + * 轮询注册表、表格占位串与 sparklineSvg 迷你趋势线。 + * 职责边界:负责:与具体页面无关的纯工具与字符串生成;不负责:业务状态、接口请求、数据渲染与页面级反馈 + * (toast、按钮 loading、弹窗),这些由 app.js / pool.js / proxies.js 等页面脚本负责。 + * 关键依赖:i18n.js 的 T()(fmtTime 的空值文案与 tableAlertHtml 的重试按钮文案);无第三方组件与外部服务。 + * 加载顺序由 index.html 保证:jquery → i18n → common,common 排在其它页面脚本之前。 + * 已知限制: + * 1. escapeHtml 把 null/undefined 映射为 '',其余走 String()(0 得 "0");非幂等,同一份数据不得重复转义。 + * 2. getToken/appendToken 不做 encodeURIComponent,token 含 & / + 等字符时外发链接会损坏;外发链接的调用方须自行编码。 + * 3. 四个 safe 存储在 localStorage 不可用(隐私模式 / 配额 / 策略)时静默降级:读回 fallback、写返回 false,不抛出;新增读写必须走它们。 + * 4. copyViaTextarea 为剪贴板兜底:非安全上下文无 clipboard API、或 writeText 被拒时都会走它,成败均在 finally 移除 textarea。 + * 5. jqErrorText 取 message、非 0 status 的 statusText、fallback;收尾仍可能取 statusText,x.status 守卫不可删。 + * 6. plainDoc 用于字段文档展示前剥离双星号加粗标记,去掉剥离逻辑会让提示里露出星号。 + * 7. sparklineSvg 的 y 轴按数据自身取极值,只表达趋势形状;自身过滤 null/undefined/NaN,剩余点数不足 2 或全段同值返回空串。 + */ + +function escapeHtml(s) { + return String(s === null || s === undefined ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + + +function plainDoc(text) { + return String(text === null || text === undefined ? '' : text).replace(/\*\*/g, ''); +} + + +function getToken() { + var p = new URLSearchParams(location.search); + return p.get('token') || ''; +} + + +var _pollers = {}; + +function startPolling(key, fn, ms) { + stopPolling(key); + _pollers[key] = setInterval(fn, ms); +} + +function stopPolling(key) { + if (_pollers[key]) { + clearInterval(_pollers[key]); + delete _pollers[key]; + } +} + +function isPolling(key) { + return !!_pollers[key]; +} + + +function emptyRow(colspan, text, rowClass) { + return '' + + escapeHtml(text) + ''; +} + + +function skeletonRows(colspan, count) { + var widths = ['skel-60', 'skel-45', 'skel-80', 'skel-30', 'skel-100']; + var out = ''; + for (var i = 0; i < count; i++) { + out += ''; + for (var c = 0; c < colspan; c++) { + out += ''; + } + out += ''; + } + return out; +} + + +function tableAlertHtml(colspan, text, retryKey) { + return '' + + '
' + + '' + + '' + escapeHtml(text) + '' + + '' + + '
'; +} + + +function skeletonBlock(rows) { + var widths = ['skel-60', 'skel-45', 'skel-80', 'skel-30', 'skel-100']; + var out = ''; +} + +function appendToken(u) { + var t = getToken(); + return t ? u + (u.indexOf('?') >= 0 ? '&' : '?') + 'token=' + t : u; +} + + +function fillTemplate(text) { + var out = String(text || ''); + for (var i = 1; i < arguments.length; i++) { + out = out.replace('{}', arguments[i]); + } + return out; +} + + +function fmtTime(iso) { + if (!iso) return T().never_run; + var d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +function fmtDuration(seconds) { + if (seconds === null || seconds === undefined) return ''; + var s = Math.max(0, Math.round(seconds)); + if (s < 60) return s + 's'; + var m = Math.floor(s / 60); + var rest = s % 60; + if (m < 60) return m + 'm' + (rest ? ' ' + rest + 's' : ''); + return Math.floor(m / 60) + 'h' + (m % 60 ? ' ' + (m % 60) + 'm' : ''); +} + +function fmtLogSize(bytes) { + var n = Number(bytes) || 0; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(1) + ' MB'; +} + + +function copyViaTextarea(text) { + var area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.cssText = 'position:fixed;top:-1000px;left:0;opacity:0'; + + document.body.appendChild(area); + var copied = false; + try { + area.select(); + copied = document.execCommand('copy'); + } catch (e) { + copied = false; + } finally { + document.body.removeChild(area); + } + return copied; +} + + +function safeGetItem(key, fallback) { + if (fallback === undefined) fallback = null; + try { + var v = localStorage.getItem(key); + return v === null || v === undefined ? fallback : v; + } catch (e) { + return fallback; + } +} + +function safeSetItem(key, value) { + try { + localStorage.setItem(key, value); + return true; + } catch (e) { + return false; + } +} + +function safeGetJSON(key, fallback) { + var raw = safeGetItem(key, null); + if (raw === null) return fallback; + try { + return JSON.parse(raw); + } catch (e) { + return fallback; + } +} + +function safeSetJSON(key, value) { + try { + var raw = JSON.stringify(value); + if (raw === undefined) return false; + localStorage.setItem(key, raw); + return true; + } catch (e) { + return false; + } +} + + +function sparklineSvg(values, opts) { + var o = opts || {}; + var width = o.width || 68; + var height = o.height || 22; + var points = (values || []).filter(function (v) { + return v !== null && v !== undefined && !isNaN(v); + }); + if (points.length < 2) return ''; + + var min = Math.min.apply(null, points); + var max = Math.max.apply(null, points); + if (max === min) return ''; + var span = max - min; + var step = width / (points.length - 1); + + var poly = points.map(function (v, i) { + return (i * step).toFixed(1) + ',' + (height - ((v - min) / span) * height).toFixed(1); + }).join(' '); + + return '' + + ''; +} + + +function jqErrorText(x, fallback) { + var r = x && x.responseJSON; + if (r && r.message) return r.message; + if (x && x.status && x.statusText) return x.statusText; + if (fallback) return fallback; + return (x && x.statusText) || ''; +} diff --git a/web/static/js/i18n.js b/web/static/js/i18n.js new file mode 100644 index 0000000..4eb5fc0 --- /dev/null +++ b/web/static/js/i18n.js @@ -0,0 +1,984 @@ +/** + * 模块名称:界面多语言(i18n.js) + * 功能描述:面板的中英文案表与取词入口 T();setLang() 把语言应用到 data-i18n 系列属性、 + * .cn-text / .en-text 显隐与 documentElement.lang;toggleLang() / initLang() 与后端同步,语言存服务端。 + * 职责边界:负责:key 到文案的查表、静态文案替换,以及动态内容重绘入口的登记与回放; + * 不负责:占位符填充与动态内容拼装(由 common.js 的 fillTemplate 和各页面脚本负责)。 + * 关键依赖:jQuery 的 $(setLang 改写 DOM 与显隐切换);common.js 的 appendToken、jqErrorText; + * app.js 的 toast、svcBtns;本文件在 index.html 中先于 common.js 加载,跨文件调用只允许发生在运行期。 + * 已知限制: + * 1. 中英两包的键集合必须完全一致;少一个 key 不报错,只会让该位置在另一种语言下混进中文,增删 key 时两包要一起改。 + * 2. T() 缺 key 时原样返回 key 名,写 T().xxx || “默认值” 也没有兜底;两级枚举的第二级要写 T().xxx[k] || k。 + * 3. data-i18n 用 $(el).text(v) 写入会清空子节点,不能挂在带子元素的标签上;-title / -placeholder 用 attr() 写入。 + * 4. data-i18n 管着的元素不能再被脚本用 .text() / .html() 写,否则切语言时真实状态会被固定文案盖掉。 + * 5. onLangRender 的实参必须是具名函数本身:就地写闭包每次求值都是新对象,去重不命中,登记表会随回放无界增长。 + * 6. 占位符统一用 {},由调用方经 common.js 的 fillTemplate 按序填充;{name} 式具名占位不被识别。 + * 7. en 包的 unit_* 量词为空串是有意为之:英文不加后缀,由 main.css 的 .fg-unit:empty 收起,不要补值。 + */ + +var lang = 'cn'; + +var _tProxy = new Proxy({}, { + get: function (_, key) { + var cur = translations[lang] || translations.cn || {}; + if (cur[key] !== undefined) return cur[key]; + var cn = translations.cn || {}; + return cn[key] !== undefined ? cn[key] : key; + } +}); + +function T() { return _tProxy; } + +function _resolveText(key) { + var cur = translations[lang] || {}; + if (cur[key] !== undefined) return cur[key]; + var cn = translations.cn || {}; + return cn[key]; +} + +var _langRenderers = []; + +function onLangRender(fn) { + if (_langRenderers.indexOf(fn) === -1) _langRenderers.push(fn); +} + +function _replayLangRenderers() { + _langRenderers.slice().forEach(function (fn) { + try { + fn(); + } catch (e) { + if (typeof console !== 'undefined' && console.warn) { + console.warn('[i18n] 切换语言时重绘失败:', fn && fn.name, e); + } + } + }); +} + +function setLang(ln) { + lang = ln; + + $('[data-i18n]').each(function () { + var v = _resolveText($(this).data('i18n')); + if (v !== undefined) $(this).text(v); + }); + $('[data-i18n-title]').each(function () { + var v = _resolveText($(this).data('i18n-title')); + if (v !== undefined) $(this).attr('title', v); + }); + + $('[data-i18n-placeholder]').each(function () { + var v = _resolveText($(this).data('i18n-placeholder')); + if (v !== undefined) $(this).attr('placeholder', v); + }); + + $('.cn-text').toggle(ln === 'cn'); + $('.en-text').toggle(ln === 'en'); + $('#lang-btn i').attr('class', 'fa fa-globe'); + document.documentElement.lang = (ln === 'cn') ? 'zh-CN' : 'en'; + + _replayLangRenderers(); +} + +function toggleLang() { + var nl = lang === 'cn' ? 'en' : 'cn'; + $.ajax({ + url: appendToken('/api/language'), + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ language: nl }), + success: function (r) { + if (r.status === 'success') setLang(nl); + else toast(r.message || T().operation_failed_bare, 'error'); + }, + error: function (x) { + toast(T().request_failed + T().sep_colon + jqErrorText(x, T().network_error), 'error'); + } + }); +} + +function initLang() { + $.get(appendToken('/api/status'), function (d) { + setLang(d.language || 'cn'); + svcBtns(d.service_status); + }); +} + +var translations = { + cn: { + 'sep_colon': ':', 'sep_paren_open': '(', 'sep_paren_close': ')', + 'sep_mid': ' · ', + 'pager_total': '共 {} 条', 'pager_range': '第 {}-{} 条', + 'operation_success': '操作成功', + 'backend_unreachable': '后端连接失败,请检查服务是否运行', + 'conn_lost': '与后端的连接已中断,面板数据可能不再更新', + 'saved_at_hint': '已保存', + 'unit_sec': '秒', 'unit_times': '次', 'unit_days': '天', + 'unit_rows': '条', 'unit_bytes': '字节', 'unit_copies': '份', 'unit_items': '个', + 'conn_retry': '重试', + 'task_dock_title': '后台任务', + 'task_dock_unit': '个进行中', + 'request_failed': '请求失败', + 'network_error': '网络错误', + 'save_failed': '保存失败', + 'copy_failed_manual': '复制失败,请手动复制', + 'panel_ready': 'ProxyCat 面板已就绪', + 'title': 'ProxyCat 控制面板', + 'sb_open': '打开导航', + 'view_sub_config': '代理来源、监听端口、出口轮换、可用性检测与日志', + 'view_sub_pool': '代理清单、插件抓取与批量校验', + 'view_sub_pool_manage': '代理清单、插件抓取与批量校验', + 'view_sub_pool_settings': '验证、清理、写入队列等池内策略', + 'view_sub_pool_db': '数据库优化、备份与归属地库', + 'view_sub_access': '出口认证账号、IP 黑白名单与绕过规则', + 'view_sub_logs': '实时日志、访问统计与访问记录明细', + 'service_control_title': '服务控制', 'proxy_status_title': '代理状态', 'start_service': '启动', 'stop_service': '停止', 'restart_service': '重启', + 'service_status': { running: '运行中', stopped: '已停止' }, + 'pool_interval_zero_notice': '间隔为 0 表示不按时间重取出口:当前这批用完或被停用后才补货', + 'source_error_hint': '最近一次取代理失败:{} —— 详情见「运行日志」页', 'pool_per_request_swap': '按需补货', 'times': '次切换', + 'cfg_group_source': '来源设置', 'cfg_group_listen': '监听端口', + 'cfg_group_exits': '出口轮换', 'cfg_group_check': '可用性检测', + 'cfg_group_perf': '性能与并发', 'cfg_group_logs': '日志与记录', + 'cfg_group_source_sub': '只显示当前来源用得上的字段', + 'cfg_group_listen_sub': '端口被占用时才需要改', + 'cfg_group_exits_sub': '一个出口用多久、同时用几个', + 'cfg_group_check_sub': '什么样的代理能进池', + 'cfg_group_perf_sub': '本机能同时扛多少请求', + 'cfg_group_logs_sub': '记什么、留多久', + 'advanced_config_sub': '平时不用动,改错很难查', + 'local_proxy_file_label': '本地代理文件', 'local_proxy_file_hint': '只填文件名,固定从 config/ 目录读取', + 'local_proxy_file_doc': '保存代理时会写回这个文件;只取文件名,忽略路径部分', + 'local_proxy_list_label': '本地代理列表', + 'local_proxy_list_doc': '每行一个代理,支持 http / https / socks5 / full 四种写法;保存后写回上面的「本地代理文件」', + 'api_proxy_url_label': 'API 取代理地址', 'api_proxy_url_hint': '接口返回代理列表,可带账号密码', + 'api_proxy_url_doc': '代理来源为 API 时从这里取代理;下面的「取 IP 接口地址」一旦填写会覆盖它', + 'proxy_auth_label': '代理认证', 'proxy_auth_ph': '用户名:密码,留空表示不认证', + 'proxy_auth_hint': '按第一个冒号拆分:前面是用户名,后面(可以再带冒号)是密码', + 'proxy_auth_warn': '没有冒号,这样不会启用认证;请写成「用户名:密码」', + 'proxy_auth_doc': '只对 API 来源生效;填好后可在下面「已保存的凭据」里存成一套,换服务器时直接选', + 'pool_remote_url_label': '远程代理池地址', 'pool_remote_url_hint': '留空表示使用 ProxyCat 内置的代理池;填写地址则接入外部代理池的取用接口', + 'pool_remote_url_doc': '填了就用外部代理池的取用接口,不再启动内置代理池', + 'port_label': '代理监听端口', 'port_hint': 'HTTP 与 SOCKS5 共用;保存后会重启服务', + 'port_doc': '取值范围 1-65535。修改后保存会立刻重启代理服务,当前连接会断开', + 'run_mode_label': '运行模式', 'run_mode_hint': '按顺序使用=按清单顺序轮着来;负载均衡=谁压力小先用谁', + 'run_mode_doc': '只对本地代理文件有意义:它决定一个请求该用清单里的哪一条,与「什么时候换出口」是两件事(后者见出口寿命)。按顺序使用**忽略负载**,前一条满了才用下一条;负载均衡把请求摊给压力最小的那一条。切换到 API / 维护池来源后,这一项会换成「更换方式」', + 'rotation_mode_label': '更换方式', 'rotation_mode_hint': '持续更换=一直保持出口池更新;触发更换=只在收到新任务时换', + 'rotation_mode_doc': '只对 API 代理 / 维护池代理来源有意义。持续更换:与有没有流量无关,后台按出口寿命主动换掉到期的并补满,池子一直保持在「在用出口数」这个规模。触发更换:只有收到新任务时才去取货、换出口;一个任务都没有时,到期的出口直接移除、池子会空掉,下一个请求到达时重新取货填满。切换到本地来源后,这一项会换成「运行模式」', + 'switch_interval_label': '出口寿命·按时间', 'switch_interval_hint': '到点就换掉;与按请求数各算各的,谁先到换谁', + 'switch_interval_doc': '每个出口从入池那一刻各算各的,一批同时入池的会同时到期、到期时全部更换 —— 不是每隔这么久只换一个。空闲时到点不换,下一个请求先换再服务。它与「按请求数」是两条各自独立的规则,两个都填就都生效,谁先到谁换。0 表示不按时间计。三种来源都按寿命轮换;换成「持续更换 / 触发更换」只决定换的**时机**,不是换不换', + 'request_interval_label': '出口寿命·按次数', 'request_interval_hint': '服务满这么多次就换掉;与按时间各算各的,谁先到换谁', + 'request_interval_doc': '数的是「分配出去几次」:一次请求用到这个出口算一次;重试时换到另一个出口,会在那个出口上再算一次。它与「按时间」是两条各自独立的规则,两个都填就都生效,谁先到谁换。0 表示不按请求数计。三种来源都按寿命轮换;换成「持续更换 / 触发更换」只决定换的**时机**,不是换不换', + 'exit_count_label': '在用出口数', 'exit_count_hint': '池里维持这么多个在用:持续更换空闲也保持,触发更换与本地来源空闲时会掉空;调小会当场收回多余的', + 'exit_count_doc': '这是池子的目标容量:持续更换(continuous)模式下没有流量时也补满(会持续调用取代理接口,默认寿命 300 秒时约每小时十几次);触发更换(request)与本地来源由请求驱动,空闲时到期的出口被移除而不是换新,池子可能空掉,下一个请求到达时才重新取货填满。高峰期自动扩容期间会临时多于它,多出来的出口用满自己的寿命后才逐个撤下。API 来源取回几条由接口决定,这里只决定用几条;本地来源把它当上限,文件不够就用多少算多少。失效的单独换掉,不整批换', + 'exit_wait_label': '无可用出口时排队', 'exit_wait_hint': '等不到就回 503;0 表示不等', + 'exit_wait_doc': '一个可用出口都没有时(刚启动、换来源、出口全被停用、取代理失败),请求排队等多久;等到有出口就照常服务', + 'test_target_label': '测试目标地址', 'test_target_hint': '检测出口存活时请求的地址', + 'test_target_doc': '代理池的验证目标地址也由它派生', + 'startup_check_label': '启动时检测全部代理', 'startup_check_hint': '启动后先跑一遍可用性检测', + 'startup_check_doc': '主要对本地代理列表有意义:逐个探一遍、把不通的排除掉。API / 维护池来源本来就一条条校验着进池,不差这一遍', + 'use_check_label': '取用前先校验', 'use_check_hint': '填进池子前先真发一次请求;不合格的丢掉换下一条', + 'use_check_doc': '不是按请求校验:出口在服役期间失效靠失败淘汰兜底。校验是一条条真实请求,所以填充时一次并发探几条(最多 4 条)—— 冷启动不必等「条数 × 单条探测超时」;没通过的那几条结论也会缓存 60 秒,下一次填充直接复用,不白探', + 'max_concurrent_requests_label': '全局并发上限', 'max_concurrent_requests_hint': '超出后排队等待', + 'max_concurrent_requests_doc': '同时处理的客户端请求数上限', + 'per_proxy_concurrency_label': '单出口并发上限', 'per_proxy_concurrency_hint': '0 表示不限;总并发 ≈ 出口数 × 本值', + 'per_proxy_concurrency_doc': '数的是活跃连接,空闲隧道不占额度', + 'auto_expand_enabled_label': '高峰期自动扩容', 'auto_expand_enabled_hint': '并发顶到容量就立刻翻倍到上限,不必等水位', + 'auto_expand_enabled_doc': '两条触发路径。① 立刻:监听端口上此刻挂着的连接数超过「在用出口数 × 单出口额度」时,当场把出口数翻倍并一次补齐 —— 5 个出口 × 100 的额度撞上 1000 并发,不会让多出来的请求干等;同时当场放宽单出口额度(最多 2 倍),这是来源拿不出新出口时唯一还能扩容的手段。② 兜底:后台按「八成的出口占满并发额度」持续 1 秒逐格加出口。高峰过去、整池利用率持续 30 秒低于一半后停止扩容,多出来的出口用满寿命再逐个撤下。扩容量有硬上限:出口数最多到「在用出口数」的两倍,两个上限都到顶时会写一条限频告警 —— 要继续扩容得调大「在用出口数」这个基线', + 'auto_expand_note': '扩容只在「单出口并发上限」不为 0 时有意义:填 0 时不扩,但已经张开的膨胀状态照常收回', + 'max_pool_size_label': '上游连接缓存上限', 'max_pool_size_hint': '调低会让各出口的连接互相淘汰,复用变差', + 'max_pool_size_doc': '缓存多少个可复用的上游客户端;实际下限是出口数 × 2,低于它时各出口的客户端会互相淘汰', + 'check_concurrency_label': '批量检测并发数', 'check_concurrency_hint': '同时验证多少个代理', + 'check_concurrency_doc': '取值范围 1-1000。每次验证连的是不同主机,可以放心调高', + 'client_max_keepalive_label': '每出口保活连接数', 'client_max_keepalive_hint': '不要调大:空闲连接越多,每个请求越慢', + 'client_max_keepalive_doc': 'httpx 分配请求时会对空闲连接做一遍全池扫描,空闲连接越多,每个请求花在这上面的开销越大。要让单个出口扛更多并发,调「每出口连接数上限」而不是这一项', + 'client_max_connections_label': '每出口连接数上限', 'client_max_connections_hint': '单个出口同时打开的 HTTP 连接上限', + 'client_max_connections_doc': '单个出口允许同时打开的 HTTP 连接数;要让某个出口扛更多并发,调这一项', + 'client_keepalive_expiry_label': '空闲回收', 'client_keepalive_expiry_hint': '连接与客户端空闲这么久之后回收', + 'client_keepalive_expiry_doc': '空闲超过这么久的保活连接会被关闭、下次请求重新建连;装这些连接的客户端按同一个时间回收(它不该比连接活得短)。调小会让连接复用变差', + 'manual_switch_btn': '刷新出口', + 'theme_toggle_light': '浅色', 'theme_toggle_dark': '深色', + 'proxy_source_mode_local': '本地代理', 'proxy_source_mode_api': 'API代理', 'proxy_source_mode_pool': '维护池代理', + 'api_saved_credentials': '已保存的凭据', 'api_save_credential': '保存当前', 'api_delete_credential': '删除', + 'api_no_selection': '未选择', 'api_enter_name': '请输入凭据名称(如:服务器A)', + 'api_credential_saved': '凭据已保存', 'api_credential_deleted': '凭据已删除', 'api_confirm_delete': '确定删除凭据', + 'cycle_mode': '按顺序使用', 'loadbalance_mode': '负载均衡', + 'mode_request': '触发更换', 'mode_continuous': '持续更换', + 'save_proxy_btn': '保存代理', 'check_proxy_btn': '检测代理', + 'config_tab': '代理配置', 'access_control_tab': '访问控制', 'logs_tab': '运行日志', + 'user_management_title': '用户管理', + 'username_column': '用户名', 'password_column': '密码', + 'add_user_btn': '添加用户', + 'enter_username': '请输入用户名', 'enter_password': '请输入密码', + 'confirm_delete_user': '确定要删除用户 {} 吗?', + 'users_save_success': '用户保存成功', 'users_save_failed': '用户保存失败', + 'users_load_failed': '用户列表读取失败,已保持当前显示', + 'ip_lists': 'IP访问控制', + 'whitelist_title': '白名单', 'blacklist_title': '黑名单', + 'whitelist_first': '白名单优先', 'blacklist_first': '黑名单优先', + 'save_list_btn': '保存名单', + 'ip_list_save_success': 'IP名单保存成功', 'ip_list_save_failed': 'IP名单保存失败', + 'bypass_whitelist_title': '代理绕过白名单', + 'bypass_whitelist_hint': '匹配的域名/IP将直连不经过代理,支持通配符', + 'bypass_save_success': '绕过白名单保存成功', 'bypass_save_failed': '绕过白名单保存失败', + 'save_config': '保存配置', + 'config_save_success': '配置保存成功', 'config_save_failed': '配置保存失败', + 'port_saved_stopped': '端口已保存,启动服务后生效', + 'config_apply_failed': '配置已保存,但代理服务应用失败,请尝试重启服务', + 'pool_config_apply_failed': '池配置已写入文件,但池服务没能启动,暂时没有生效;稍后会自动重试,也可到「代理池」页手动启动', + 'config_field_invalid': '字段「{}」有问题', + 'config_port_restart_title': '保存后自动重启', + 'config_port_restart_body': '修改端口会在保存后自动重启代理服务,当前连接会断开。确定继续吗?', + 'server_dirty_hint': '未保存', + 'advanced_config_title': '高级选项', + 'adv_rare_hint': '这一组平时不用动。改动前请确认你了解它的作用,其中多数要重启才完全生效', + 'adv_group_check': '检测与冷却', 'adv_group_connections': '连接复用', + 'adv_group_logfiles': '日志文件', + 'advanced_group_timeout': '超时与冷却', + 'adv_group_files': '文件与面板', + 'advanced_group_runtime': '运行环境', + 'token_in_file_hint': '访问令牌不在面板里修改:改了会让当前地址立刻失效、把你锁在面板外。需要更换时请编辑 config/config.ini 的 [Server] token 后重启。', + 'svc_confirm_title': '确认操作', + 'svc_stop_confirm_body': '停止服务会断开所有正在使用代理的连接。确定停止吗?', + 'svc_restart_confirm_body': '重启服务会短暂断开所有连接。确定重启吗?', + 'log_level_hides_access_warning': '当前级别会把访问成功记录(INFO)一并过滤掉,访问日志里将只剩失败与错误。要保留成功记录请把级别调回 INFO。', + 'log_level_label': '日志级别', 'log_level_hint': '低于此级别的记录不写入', + 'log_level_doc': 'DEBUG / INFO / WARNING / ERROR / CRITICAL,级别越低记录越详细', + 'log_access_enabled_label': '写访问日志文件', 'log_access_enabled_hint': '只影响 logs/access.log', + 'log_access_enabled_doc': '关闭后不再写访问日志文件;域名统计与访问记录明细都不受影响', + 'records_enabled_label': '保存访问记录明细', 'records_enabled_hint': '在「运行日志 → 访问」里按时间查看与导出', + 'records_enabled_doc': '与访问日志文件、域名统计互不影响,是另存的一份逐条明细', + 'records_real_ip_probe_label': '探测出口真实 IP', 'records_real_ip_probe_hint': '网关型出口会多消耗一次代理流量', + 'mode_idle_drain_note': '没有请求时,到期的出口会被移除、池子会空掉;下一个请求到达时才重新取货填满', + 'records_real_ip_probe_doc': '出口地址是网关时,经它访问一次 IP 回显接口问出真实出口 IP,按地址缓存复用;代理池来源直接用池里已验证的出口 IP,本地代理不探测', + 'domain_stats_enabled_label': '保存域名访问统计', 'domain_stats_enabled_hint': '按「域名 × 出口」汇总,保留时间比明细长', + 'domain_stats_enabled_doc': '与访问记录明细是两份数据:统计按域名与出口聚合、默认保留更久,明细用于按时间查询与导出', + 'records_retention_label': '访问记录保留', 'records_retention_hint': '0 表示不按天数清理', + 'records_retention_doc': '超过该天数的访问明细会被清理;填 0 时只受「条数上限」约束', + 'records_max_rows_label': '访问记录上限', 'records_max_rows_hint': '超出后自动删除最老的记录', + 'records_max_rows_doc': '明细总条数的硬上限,超出后从最老的开始删;0 表示不限', + 'records_flush_label': '访问记录写入间隔', 'records_flush_hint': '内存里的明细每隔这么久写一次库', + 'records_flush_doc': '调小能让明细更快出现在「运行日志 → 访问」里,代价是写库更频繁', + 'records_buffer_label': '访问记录内存缓冲', 'records_buffer_hint': '最多缓存多少条待写库;改动需重启生效', + 'records_buffer_doc': '内存里最多缓存多少条待写库;写库跟不上时调大它', + 'domain_retention_label': '域名统计保留', 'domain_retention_hint': '0 表示永久保留', + 'domain_retention_doc': '超过该天数未被访问的域名记录会被清理;0 表示永久保留', + 'domain_flush_label': '域名统计写入间隔', 'domain_flush_hint': '内存里的统计每隔这么久写一次库', + 'domain_flush_doc': '调小能让统计更快刷新,代价是写库更频繁', + 'log_max_bytes_label': '单个日志上限', 'log_max_bytes_hint': '写满后自动换一个新文件', + 'log_max_bytes_doc': '写满后自动轮转,旧文件按「日志保留份数」保留', + 'log_backup_count_label': '日志保留', 'log_backup_count_hint': '至少 1;0 会让日志永不轮转', + 'log_backup_count_doc': '轮转后保留几个历史日志文件;至少 1', + 'proxy_failure_cooldown_label': '重复失败日志抑制', 'proxy_failure_cooldown_hint': '同一原因在这段时间内只记一条', + 'proxy_failure_cooldown_doc': '窗口过后补记被折叠了多少条;另一类更啰嗦的告警按它的 5 倍(最少 5 秒)折叠。只影响日志,不影响出口调度', + 'buffer_size_label': '转发背压阈值', 'buffer_size_hint': '累计转发这么多字节就暂停等客户端读取', + 'buffer_size_doc': '最小 1024。它不控制切块大小——响应体按上游交付的原生块(通常 64 KiB)转发,隧道中继固定 64 KiB,也不受这一项影响', + 'tunnel_idle_timeout_label': '隧道无数据超时', 'tunnel_idle_timeout_hint': '上游一直不回数据超过这么久就判失效;0 关闭', + 'tunnel_idle_timeout_doc': '客户端发过数据而上游一直没回包,超过这么久就认定这个出口装死;这是发现「收下 CONNECT 后不回数据」的唯一手段', + 'proxy_check_ttl_label': '检测结果缓存', 'proxy_check_ttl_hint': '缓存期内同一出口不重复检测', + 'proxy_check_ttl_doc': '按「出口 + 测试地址」缓存检测结论,缓存期内直接复用', + 'check_cooldown_label': '同一出口检测间隔', 'check_cooldown_hint': '两次存活检测至少隔这么久', + 'check_cooldown_doc': '冷却内不再对该出口发探测请求,也不改变它的结论', + 'switch_cooldown_label': '重取出口最小间隔', 'switch_cooldown_hint': '两次重取出口至少隔这么久', + 'switch_cooldown_doc': '自动补货与手动「刷新出口」共用这一间隔,补货也受它限流', + 'whitelist_file_label': '客户端 IP 白名单文件', 'blacklist_file_label': '客户端 IP 黑名单文件', + 'bypass_file_label': '代理绕过名单文件', 'bypass_file_hint': '命中的目标直连不经过代理,支持通配符', + 'bypass_file_doc': '匹配「域名 / IP」的目标不经过代理直接连接;支持通配符', + 'list_files_hint': '只填文件名,内容在「访问控制」页维护', + 'list_files_doc': '这三个只填文件名,固定从 config/ 目录读取;内容在「访问控制」页的名单编辑区里维护', + 'web_port_label': 'Web 面板端口', 'web_port_hint': '保存后需重启整个应用,不是只重启代理服务', + 'web_port_doc': '取值范围 1-65535。保存后需要重启整个应用才会生效', + 'display_level_label': '命令行显示级别', 'display_level_hint': '只影响命令行运行 ProxyCat.py 时的输出', + 'display_level_doc': '仅命令行(ProxyCat.py)模式生效:0 只显示状态行、1 显示经过与出口清单、2 逐条显示请求', + 'display_level_0': '0 只显示状态行', 'display_level_1': '1 显示经过与出口清单', 'display_level_2': '2 逐条显示请求', + 'db_maintain_title': '数据库维护', 'db_optimize_btn': '优化数据库', + 'db_backup_time': '时间', 'db_backup_size': '大小', + 'db_stat_total': '代理总数', 'db_stat_valid': '有效', 'db_stat_invalid': '失效', 'db_stat_size': '数据库体积', + 'db_backup_list_title': '历史备份', 'db_no_backups': '暂无备份', + 'db_backup_file': '备份文件', 'db_backup_latest': '最新', + 'no_users': '还没有账号,点下面的「添加用户」新建一个', + 'user_show_password': '显示密码', 'user_hide_password': '隐藏密码', + 'db_restore_btn': '恢复', + 'db_restore_confirm_title': '恢复备份', + 'db_restore_confirm_body': '将用该备份覆盖当前数据库。代理池会先停止,恢复完成后自动重启。确定继续吗?', + 'db_restore_stopping': '正在停止代理池...', + 'db_restore_success': '备份恢复成功', + 'db_optimize_confirm_body': '优化会重建数据库索引并压缩文件,期间写入会短暂等待。确定继续吗?', + 'geo_title': '归属地库', + 'geo_update_btn': '更新归属地库', 'geo_recompute_btn': '重算归属地', + 'geo_available': '离线库可用:中国城市级解析用纯真库,其余用 GeoLite2', + 'geo_unavailable': '离线库不可用(见下方清单),归属地查询一律返回「未知」;点「更新归属地库」下载(约 104MB,需数分钟)', + 'geo_pending': '待重算记录 {} 条', 'geo_pending_zero': '没有待重算的记录', + 'geo_file_list_title': '数据文件', 'geo_file_missing': '未下载', + 'geo_file_size': '大小', 'geo_file_time': '修改时间', + 'geo_pool_down': '代理池未运行时读不到归属地库状态(待重算记录数要查库),启动代理池后点「刷新」重试', + 'geo_task_queued': '任务已创建,正在获取进度...', + 'geo_task_running': '正在执行归属地维护任务...', + 'geo_task_lost': '查不到这个任务的进度了(进程可能重启过),点「刷新」核对数据文件', + 'geo_region_untranslated': '该名称暂无当前语言的译名,显示的是原文', + 'log_level_important': '重要', 'log_follow_latest': '跟随最新日志', 'log_follow_btn': '跟随', + 'pool_view_manage': '代理管理', + 'log_level_all': '全部', 'log_level_info': '信息', 'log_level_warning': '警告', + 'log_level_error': '错误', 'log_level_critical': '严重错误', + 'clear_logs_btn': '清除日志', 'confirm_clear_logs': '确定要清除所有日志吗?', + 'logs_cleared': '日志已清除', 'clear_logs_failed': '清除日志失败', + 'export_logs_btn': '导出', 'log_total': '总计', 'no_logs': '无日志记录', + 'log_cat_all': '全部', 'log_cat_main': '主程序', 'log_cat_proxy': '代理', + 'log_cat_access': '访问', 'log_cat_pool': '池', 'log_cat_error': '错误汇总', + 'log_source_live': '实时日志', 'log_source_title': '查看已轮转的历史日志文件', + 'log_file_read_failed': '读取日志文件失败', + 'log_auto_btn': '自动刷新', 'log_auto_refresh_title': '暂停后不再自动刷新,便于停下来阅读', + 'log_auto_on': '已恢复自动刷新', 'log_auto_off': '已暂停刷新', + 'domain_col_success': '成功', 'domain_col_failure': '失败', + 'domain_col_total': '合计', 'domain_col_rate': '成功率', 'domain_col_last': '最后访问', + 'domain_col_reason': '最后失败原因', + 'domain_clear_btn': '清空统计', 'domain_cleared': '已清空域名统计', + 'confirm_clear_domains': '确定要清空域名访问统计吗?', + 'domain_disabled': '域名统计已在配置中关闭', + 'domain_no_data': '暂无域名统计', + 'domain_summary_ok': '成功', 'domain_summary_fail': '失败', + 'access_stats_title': '访问统计(按代理汇总)', + 'access_col_upstream': '出口地址', 'access_col_hosts': '域名数', + 'access_summary': '代理', 'access_summary_hosts': '域名', + + 'access_mode_summary': '按代理汇总', 'access_mode_records': '逐条明细', + 'records_title': '访问记录明细', + 'access_outcome_success': '成功', 'access_outcome_failure': '失败', + 'access_outcome_aborted': '中断', + 'records_upstream_all': '全部代理', 'records_outcome_all': '全部结果', + 'records_range_5m': '最近 5 分钟', 'records_range_1h': '最近 1 小时', + 'records_range_24h': '最近 24 小时', 'records_range_today': '今天', + 'records_since_title': '起始时间(含)', + 'records_until_title': '结束时间(含);留空表示一直查到当前', + 'records_col_time': '时间', 'records_col_client': '客户端', + 'records_col_target': '目标', 'records_col_upstream': '出口地址', + 'records_col_real_ip': '真实IP', + 'records_col_outcome': '结果', 'records_col_status': '状态码', + 'records_col_elapsed': '耗时', 'records_col_reason': '原因', + 'records_page_info': '第 {}/{} 页', + 'records_prev_page': '上一页', 'records_next_page': '下一页', + 'records_export_btn': '导出', 'records_clear_btn': '清空明细', + 'records_auto_title': '翻页时会自动暂停,避免新记录把结果挤得重复', + 'records_export_truncated': '当前范围内共 {} 条,超过单次导出上限,将只导出最新的 {} 条。建议缩小时间范围。', + 'records_summary_range': '区间内', 'records_summary_unit': '条', + 'records_summary_pending': '另有 {} 条待入库', + 'records_summary_dropped': '已丢弃 {} 条', + 'records_disabled': '访问记录已在配置中关闭', + 'records_no_data': '该时间范围内没有访问记录', + 'confirm_clear_records': '确定要清空访问记录明细吗?这与「清空统计」不是同一份数据。', + 'proxy_save_success': '代理保存成功', 'proxy_save_failed': '代理保存失败', + 'proxy_check_result': '代理检测完成,有效代理:{}个', + 'proxy_check_failed': '代理检测失败', + 'switching': '刷新中...', 'switch_failed_network': '网络请求失败,请检查服务是否运行', + 'loading': '加载中...', 'seconds': '秒', + 'latest_version': '已是最新版本', 'new_version_available': '新版本', + 'proxy_mode': { cycle: '按顺序使用', loadbalance: '负载均衡', + continuous: '持续更换', request: '触发更换' }, + 'operation_failed': '操作失败: {}', + 'operation_failed_bare': '操作失败', + 'modal_confirm': '确定', 'modal_cancel': '取消', + 'theme_switched': '主题已切换', + 'language_switched': '语言已切换', 'log_level_filtered': '日志筛选', + 'user_count_label': '个用户', + 'api_credential_switched': '凭据已切换', + 'addr_copied': '地址已复制', + 'log_export_started': '日志导出已开始', + 'confirm_clear_title': '清除日志', 'confirm_delete_title': '确认删除', + 'edit_user_title': '修改密码', 'edit_user_msg': '为用户 {} 修改密码', + 'api_confirm_delete_msg': '确认删除凭据 "{}"?', 'api_name_placeholder': '例:服务器A', + 'ad_label': '推广', 'ad_close': '关闭推广', 'ad_reopen': '显示推广', + 'ad_link_default': '了解更多', + 'scroll_to_top': '回到顶部', + + 'refresh_btn': '刷新', 'items_unit': '项', + 'copy_manually': '请手动复制以下内容', 'no_changes': '没有需要保存的修改', + 'refresh_skipped_dirty': '有未保存的修改,已跳过刷新', + 'click_to_copy': '点击复制', + 'invalid_minutes': '请输入大于 0 的分钟数', 'minutes_unit': '分钟', 'never_run': '从未运行', + + 'upstream_exits': '{} 个出口', + 'upstream_exits_expanding': '{} 个出口(扩容中)', + 'exit_col_url': '出口', 'exit_col_load': '在用/上限', 'exit_col_left': '剩余', + 'exit_served': '已服务 {} 次', 'exit_failures': '失败 {} 次', 'exit_suspect': '连续失败,即将被替换', + 'source_error_ago': '({}前)', + 'exit_load_unlimited': '{}/不限', 'pool_rotate_waiting': '待请求更换', + 'pool_waiting_exits': '等待补货', + + 'pool_tab': '代理池', 'pool_config_tab': '池设置', + 'pool_stat_total': '代理总数', 'pool_stat_valid': '有效代理', + 'pool_stat_invalid': '失效代理', 'pool_stat_rate': '可用率', + 'pool_stopped_hint': '代理池未运行,请点击上方「启动」按钮', + 'pool_start_failed': '代理池未运行', + 'pool_unavailable_hint': '代理池当前不可用,请稍后重试或重启代理池', + 'pool_timeout_hint': '代理池响应超时,请稍后重试', + + 'pool_remote_url_ph': '留空则使用内置代理池', + 'filter_ip_ph': 'IP 模糊搜索', 'filter_min_delay_ph': '最小延迟(ms)', + 'filter_max_delay_ph': '最大延迟(ms)', 'filter_min_health_ph': '最低健康分', + 'log_search_ph': '搜索日志...', 'domain_search_ph': '搜索代理地址...', + 'log_clear_search': '清空搜索', 'no_data': '暂无数据', + 'copy_line': '复制这一行', 'log_line_copied': '已复制这一行', + 'stale_data_note': '正在显示上次成功获取的数据', + 'records_search_ph': '搜索域名 / 客户端 / 原因...', + 'api_credential_none': '-- 选择凭据 --', + 'task_stage_ingest': '直接入库中', + + 'plugin_management_title': '插件管理', + 'plugin_col_name': '插件', 'plugin_col_status': '状态', 'plugin_col_interval': '抓取周期', + 'plugin_col_last_run': '上次运行', 'plugin_col_actions': '操作', + 'plugin_col_reval': '重验证', + 'plugin_settings_title': '插件验证策略', + 'plugin_reval_enabled_label': '定时重新检查该插件抓来的代理', + 'plugin_reval_interval_label': '重验证间隔(分钟)', + 'plugin_reval_interval_hint': '填 0 表示跟随「池设置」里的全局重验证周期', + 'plugin_skip_validation_label': '跳过入库验证,抓到就直接入库', + 'plugin_skip_validation_hint': '适合按请求计费、有效性由供应商保证的代理源:不连代理、不发探测请求。这类记录没有出口 IP、匿名度与健康分,建议同时关闭上面的重验证,否则它仍会被定时检测(并产生费用)。', + 'plugin_reval_off': '不重验证', 'plugin_reval_inherit': '跟随全局', + 'plugin_skip_validation_tag': '免验证入库', + 'plugin_enabled': '已启用', 'plugin_disabled': '已禁用', + 'plugin_run_btn': '抓取', 'plugin_reload_btn': '重载', + 'plugin_set_interval': '设置抓取周期', + 'plugin_interval_hint': '请输入抓取周期(分钟),不影响已经入库代理的重验证周期', + 'plugin_test_url_global': '测试地址:全局', + 'plugin_test_url_custom': '测试地址:已指定', + 'plugin_set_test_url': '设置测试地址', + 'plugin_test_url_hint': '该插件抓来的代理用这个地址做存活检测。留空则继承全局的 [Server] test_url。', + 'no_plugins': '没有加载到任何抓取插件', + + 'validate_valid_btn': '验证全部有效', 'validate_invalid_btn': '验证全部失效', + 'full_check_btn': '全面检测', 'validate_selected_btn': '验证有效性', + 'validate_full_btn': '验证全部内容', + 'task_validate_full': '验证全部内容', + 'reset_columns_btn': '重置列宽', 'reset_columns_hint': '去掉手动调过的列宽,回到随内容自适应', + 'density_btn': '行高', 'density_hint': '在宽松与紧凑两种表格行高之间切换(含日志行)', + 'detail_btn': '详情', 'drawer_close': '关闭', 'yes': '是', 'no': '否', + 'drawer_group_conn': '连接信息', 'drawer_group_quality': '质量指标', + 'drawer_group_time': '时间', 'drawer_group_request': '请求', + 'drawer_group_upstream': '出口', 'drawer_group_result': '结果', + 'copy_addr_btn': '复制地址', 'ctx_copy_curl': '复制 curl 命令', + 'confirm_delete_proxy': '确定要删除代理 {} 吗?该操作不可撤销。', + 'drawer_username': '账号', 'drawer_password': '密码', + 'drawer_region_en': '归属地(原文)', 'drawer_success_rate': '成功率', + 'drawer_avg_delay': '平均延迟', 'drawer_total_checks': '检测次数', + 'drawer_probe_rate': '探活成功率', 'drawer_supports': '支持协议', + 'drawer_favorite': '已收藏', 'drawer_unfavorite': '取消收藏', + 'drawer_quality_at': '质量评估时间', + 'records_kind': '连接类型', 'records_method': '方法', + 'exit_pop_toggle': '展开出口清单', + 'access_hint_text': '按代理汇总的访问统计与逐条访问明细在「访问」分类下', + 'access_hint_btn': '切到「访问」', + 'adv_expand_all': '展开全部高级选项', 'adv_collapse_all': '收起全部高级选项', + 'plugin_col_next_run': '下次运行', + 'cmd_placeholder': '搜索命令…', 'cmd_no_match': '没有匹配的命令', + 'cmd_hint_nav': '选择', 'cmd_hint_run': '执行', 'cmd_hint_close': '关闭', + 'cmd_group_view': '视图', 'cmd_group_config': '设置分组', 'cmd_group_action': '操作', 'cmd_group_panel': '面板', + 'cmd_copy_view_link': '复制当前视图链接', 'cmd_link_copied': '视图链接已复制', + 'theme_toggle_btn': '切换主题', 'lang_toggle_btn': '切换语言', + 'shortcut_title': '快捷键', + 'shortcut_group_panel': '全局', 'shortcut_group_table': '表格内', + 'shortcut_switch_view': '切换到第 1–4 个视图', + 'shortcut_focus_search': '聚焦当前视图的搜索框', + 'shortcut_refresh': '刷新当前视图', + 'shortcut_palette': '打开命令面板', + 'shortcut_help': '显示这份快捷键说明', + 'shortcut_close': '关闭最上层的浮层', + 'shortcut_row_move': '上下移动行焦点', + 'shortcut_row_toggle': '切换该行选中', + 'shortcut_row_extend': '从锚点扩展选中', + 'shortcut_row_all': '选中本页全部', + 'shortcut_row_detail': '打开该行详情', + 'delete_selected_btn': '删除选中', 'delete_invalid_btn': '清除全部失效', + 'task_running': '进行中', 'task_completed': '已完成', 'task_failed': '已失败', + 'task_validate_valid': '验证全部有效代理', 'task_validate_invalid': '验证全部失效代理', + 'task_validate_selected': '验证选中代理', 'task_plugin_run': '插件 {} 抓取', + 'task_full_check': '代理全面检测', 'task_import': '导入代理', + 'task_cancelled': '已取消', 'task_cancel_hint': '中止这个任务', + 'task_stage_prescreen': '初筛端口连通性', 'task_stage_validate': '验证代理', + 'task_rate': '{} 个/秒', 'task_eta': '预计剩余 {}', + 'task_eta_unknown': '正在估算剩余时间', + 'task_first_batch': '{} 个进行中,等首批结果', + 'task_count_saved': '入库 {}', 'task_count_invalid': '不可用 {}', + 'task_count_prescreened': '初筛淘汰 {}', 'task_count_failed': '失败 {}', + 'selected_count': '已选 {} 项', 'select_all_label': '全选本页', + 'confirm_delete_selected': '确认删除选中的 {} 个代理?此操作不可撤销。', + 'confirm_delete_invalid_count': '将删除 {} 个失效代理,此操作不可撤销。', + 'no_invalid_proxies': '当前没有失效代理', + 'page_first': '首页', 'page_prev': '上一页', 'page_next': '下一页', 'page_last': '末页', + + 'proxy_list_title': '代理列表', + 'col_protocol': '协议', 'col_address': '代理地址', 'col_real_ip': '真实IP', + 'col_region': '地区', 'col_delay': '延迟', 'col_health': '健康分', 'col_anonymity': '匿名度', + 'filter_region_ph': '地区(- 前缀表示排除)', + 'col_validated_at': '验证时间', 'col_source': '来源', 'col_actions': '操作', + 'no_proxies': '没有符合条件的代理', + 'favorite_btn': '收藏', + 'page_of': '第 {}/{} 页', + 'anon_elite': '高匿', 'anon_anonymous': '匿名', 'anon_transparent': '透明', + 'anon_unverified': '未验证', + 'anon_fallback_hint': '该等级是拿不到回显证据时的兜底结果,不是测出来的;勾选这些行后点操作条上的「验证全部内容」可重测', + 'anonymity_fallback_hint': '有 {} 条代理的匿名度是推算的(拿不到回显证据,按透明处理);勾选后点操作条上的「验证全部内容」可重测', + 'host_ip_line': '本机公网出口 IP:{}({})', + 'host_ip_line_dual': '本机公网出口 IP:{}({});IPv6:{}', + 'host_ip_manual': '手动指定', 'host_ip_auto': '自动查询', + 'host_ip_missing': '本机公网出口 IP 未获取到,匿名度将无法判定;可在「池设置 → 代理验证」里填写自定义出口 IP(同时也是这次对外查询的开关)', + + 'filter_all_protocol': '全部协议', 'filter_all_status': '全部状态', + 'filter_valid': '仅有效', 'filter_invalid': '仅失效', + 'proxy_invalid_badge': '失效', + 'proxy_invalid_badge_hint': '这条代理当前不可用;可用「验证全部失效」重试,或直接删除', + 'filter_all_favorite': '全部', 'filter_favorite': '仅收藏', + 'filter_all_anonymity': '全部匿名度', 'filter_all_source': '全部来源', + 'advanced_filter_btn': '高级筛选', 'reset_filter_btn': '重置', + + 'import_btn': '导入', 'export_btn': '导出', 'copy_api_link_btn': '复制取用链接', + 'import_title': '导入代理', + 'import_hint': '每行一个代理,支持 http/https/socks5 三种协议,可带账号密码。', + 'import_validate_note': '导入的代理会先逐个验证,只有确认可用的才会入库;归属地在入库后自动异步查询。', + 'import_submit': '开始导入', 'import_empty': '请先粘贴要导入的代理', + 'export_title': '导出代理', 'export_hint': '导出范围与当前的筛选条件一致。', + 'export_fmt_txt': '每行一条,最通用', 'export_fmt_json': '含全部字段', + 'export_fmt_csv': '表格,可直接进 Excel', + 'export_scope_count': '将导出 {} 条', 'import_count': '已识别 {} 条', + 'api_link_copied': '取用链接已复制到剪贴板(按当前筛选条件生成)', + + 'pool_group_basic': '基本', + 'reset_form_btn': '放弃修改', + 'unsaved_hint': '未保存' + }, + en: { + 'sep_colon': ': ', 'sep_paren_open': ' (', 'sep_paren_close': ')', + 'sep_mid': ' · ', + 'pager_total': '{} total', 'pager_range': 'rows {}-{}', + 'operation_success': 'Operation succeeded', + 'backend_unreachable': 'Cannot reach the backend; check that the service is running', + 'conn_lost': 'Connection to the backend was lost; panel data may be stale', + 'saved_at_hint': 'Saved', + 'unit_sec': 's', 'unit_times': '', 'unit_days': 'd', + 'unit_rows': '', 'unit_bytes': 'bytes', 'unit_copies': '', 'unit_items': '', + 'conn_retry': 'Retry', + 'task_dock_title': 'Background tasks', + 'task_dock_unit': 'running', + 'request_failed': 'Request failed', + 'network_error': 'Network error', + 'save_failed': 'Save failed', + 'copy_failed_manual': 'Copy failed, please copy manually', + 'panel_ready': 'ProxyCat panel is ready', + 'title': 'ProxyCat Panel', + 'sb_open': 'Open navigation', + 'view_sub_config': 'Source, listen port, exit rotation, availability checks and logs', + 'view_sub_pool': 'Proxy list, plugin fetching and batch validation', + 'view_sub_pool_manage': 'Proxy list, plugin fetching and batch validation', + 'view_sub_pool_settings': 'Validation, cleanup and write-queue policies', + 'view_sub_pool_db': 'Database optimisation, backups and geo databases', + 'view_sub_access': 'Exit accounts, IP allow/deny lists and bypass rules', + 'view_sub_logs': 'Live logs, access statistics and access records', + 'service_control_title': 'Service', 'proxy_status_title': 'Proxy Status', 'start_service': 'Start', 'stop_service': 'Stop', 'restart_service': 'Restart', + 'service_status': { running: 'Running', stopped: 'Stopped' }, + 'pool_interval_zero_notice': 'Interval 0 = no timed refresh; exits are replenished only when exhausted or retired', 'pool_per_request_swap': 'On demand', 'times': ' reqs', + 'source_error_hint': 'Last proxy fetch failed: {} — see the Logs tab for details', + 'cfg_group_source': 'Source', 'cfg_group_listen': 'Listening ports', + 'cfg_group_exits': 'Exit rotation', 'cfg_group_check': 'Availability checks', + 'cfg_group_perf': 'Performance', 'cfg_group_logs': 'Logging and records', + 'cfg_group_source_sub': 'Only what the current source uses', + 'cfg_group_listen_sub': 'Only when a port is taken', + 'cfg_group_exits_sub': 'How long an exit serves, and how many', + 'cfg_group_check_sub': 'What may enter the pool', + 'cfg_group_perf_sub': 'How much this host can carry', + 'cfg_group_logs_sub': 'What to record, and for how long', + 'advanced_config_sub': 'Rarely needed, hard to get wrong', + 'local_proxy_file_label': 'Local proxy file', 'local_proxy_file_hint': 'File name only; always read from config/', + 'local_proxy_file_doc': 'Saving proxies writes back to this file. Only the file name is used; any path is ignored', + 'local_proxy_list_label': 'Local proxy list', + 'local_proxy_list_doc': 'One proxy per line, in http / https / socks5 / full form; saving writes back to the local proxy file above', + 'api_proxy_url_label': 'Proxy-fetch API URL', 'api_proxy_url_hint': 'Endpoint returning a proxy list, credentials allowed', + 'api_proxy_url_doc': 'Used when the source is API. The "IP endpoint URL" below overrides it once filled in', + 'proxy_auth_label': 'Proxy auth', 'proxy_auth_ph': 'user:password — leave empty for no auth', + 'proxy_auth_hint': 'Split at the first colon: before it is the user name; everything after (more colons allowed) is the password', + 'proxy_auth_warn': 'No colon, so authentication stays off — write it as user:password', + 'proxy_auth_doc': 'API source only. Save a filled-in pair as a credential below to switch servers by picking one', + 'pool_remote_url_label': 'Remote pool URL', 'pool_remote_url_hint': 'Leave empty to use the built-in pool; fill in to use an external pool endpoint', + 'pool_remote_url_doc': 'When set, proxies come from the external pool endpoint and the built-in pool is not started', + 'port_label': 'Proxy listen port', 'port_hint': 'Shared by HTTP and SOCKS5; saving restarts the service', + 'port_doc': 'Range 1-65535. Saving a change restarts the proxy service immediately and drops current connections', + 'run_mode_label': 'Run mode', 'run_mode_hint': 'In order = work down the list; least loaded = the least busy one first', + 'run_mode_doc': 'Local proxy files only: this decides which entry in the list a request uses, which is a different question from when an exit is replaced (see exit lifetime). "In order" ignores load — the next entry is used only once the previous one is full; "least loaded" spreads requests onto the least busy entry. Switching to the API or pool source turns this field into "Rotation"', + 'rotation_mode_label': 'Rotation', 'rotation_mode_hint': 'Continuous = keep the pool refreshed; on request = rotate only when a task arrives', + 'rotation_mode_doc': 'API and pool sources only. Continuous: with or without traffic the background replaces expired exits and tops the pool up, so it always holds the configured number. On request: the pool is fetched and rotated only when a task arrives; with no tasks at all, expired exits are removed outright and the pool empties, then the next request fetches a fresh set. Switching to the local source turns this field into "Run mode"', + 'switch_interval_label': 'Exit lifetime · time', 'switch_interval_hint': 'Replaced once it expires; both limits run independently, whichever comes first wins', + 'switch_interval_doc': 'Each exit is timed from the moment it entered the pool, so exits admitted together expire together and are all replaced at once — not one replacement per interval. When idle, expiry does not replace anything; the next request replaces first, then serves. This and "requests" are two independent rules — set both and both apply, whichever comes first wins. 0 = no time limit. All three sources rotate by lifetime; the continuous/on-demand choice changes *when* a rotation happens, not whether it does', + 'request_interval_label': 'Exit lifetime · count', 'request_interval_hint': 'Replaced after this many requests; both limits run independently, whichever comes first wins', + 'request_interval_doc': 'Counts how many times an exit was handed out: one request using that exit counts once, and a retry that lands on another exit counts again there. This and "time" are two independent rules — set both and both apply, whichever comes first wins. 0 = no request limit. All three sources rotate by lifetime; the continuous/on-demand choice changes *when* a rotation happens, not whether it does', + 'exit_count_label': 'Exits kept in use', 'exit_count_hint': 'Exits kept in use; held while idle only under continuous rotation; lowering it reclaims the surplus at once', + 'exit_count_doc': 'This is the pool target size: under continuous rotation it stays full even with no traffic, so the proxy endpoint is called continuously (with the default 300 s lifetime, roughly a dozen calls an hour); under request-driven rotation and the local source, fetching and rotation are request-driven — exits that expire while idle are removed rather than replaced and the pool may drain, until the next request fetches a fresh set. While auto-expansion is active the pool is temporarily larger, and those extra exits are retired one by one only after their own lifetime ends. For the API source the endpoint decides how many come back — this only decides how many are used. For the local source it is an upper bound: whatever the file holds is used. Dead exits are replaced individually, never in a batch', + 'exit_wait_label': 'Queue when no exit is available', 'exit_wait_hint': 'Returns 503 if it waits in vain; 0 = do not wait', + 'exit_wait_doc': 'When no exit is usable at all (just started, source switched, all exits retired, fetch failed), how long a request waits in the queue. It is served as soon as an exit appears', + 'test_target_label': 'Test target URL', 'test_target_hint': 'Requested when checking whether an exit is alive', + 'test_target_doc': 'The pool derives its own validation target from this value', + 'startup_check_label': 'Check all proxies on startup', 'startup_check_hint': 'Runs one liveness pass right after startup', + 'startup_check_doc': 'Mainly meaningful for a local proxy list: every entry is probed once and dead ones are dropped. The API and pool sources are already validated one by one on the way in, so this pass adds little there', + 'use_check_label': 'Validate before use', 'use_check_hint': 'One real request before an exit joins the pool; unusable ones are dropped for the next candidate', + 'use_check_doc': 'Not a per-request check: an exit that dies mid-service is caught by failure-based retirement. Each validation is a real request, so filling probes a few candidates concurrently (up to 4) — a cold start no longer waits for "candidates × per-probe timeout". The verdicts of the ones that fail are cached for 60 seconds and reused by the next fill', + 'max_concurrent_requests_label': 'Global concurrency cap', 'max_concurrent_requests_hint': 'The rest queue up', + 'max_concurrent_requests_doc': 'How many client requests are handled at the same time', + 'per_proxy_concurrency_label': 'Concurrency per exit', 'per_proxy_concurrency_hint': '0 = unlimited; total ≈ exits × this value', + 'per_proxy_concurrency_doc': 'Counts active connections; idle tunnels do not use up the quota', + 'auto_expand_enabled_label': 'Auto-expand under load', 'auto_expand_enabled_hint': 'Doubles to the cap the moment concurrency reaches capacity — no waiting for a watermark', + 'auto_expand_enabled_doc': 'Two triggers. (1) Immediate: when the number of connections held at the listening port exceeds "exits in use × per-exit limit", the exit count is doubled on the spot and filled in one go — 5 exits × 100 slots will not leave a 1000-request burst waiting; the per-exit limit is relaxed at the same time (at most 2x), which is the only lever left when the source cannot supply new exits. (2) Fallback: the background loop adds one exit at a time once 80% of exits are at their concurrency limit for 1 second. Expansion stops once whole-pool utilisation has stayed below half for 30 seconds, and the extra exits are retired one by one only after their own lifetime ends. There is a hard cap: the exit count never exceeds twice "exits in use", and a rate-limited warning is written once both caps are reached — raising that baseline is the way to go further', + 'auto_expand_note': 'Expansion only matters when "Concurrency per exit" is not 0: with 0 nothing expands, but an existing expansion is still released', + 'max_pool_size_label': 'Upstream client cache', 'max_pool_size_hint': 'Lowering it makes exits evict each other’s clients', + 'max_pool_size_doc': 'How many reusable upstream clients to cache; the effective floor is exit count × 2 — below it clients for different exits evict each other', + 'check_concurrency_label': 'Batch check concurrency', 'check_concurrency_hint': 'How many proxies are validated at once', + 'check_concurrency_doc': 'Range 1-1000. Safe to raise: each validation talks to a different host', + 'client_max_keepalive_label': 'Keepalive connections per exit', 'client_max_keepalive_hint': 'Do not raise: more idle connections slow every request', + 'client_max_keepalive_doc': 'httpx scans the whole pool of idle connections when handing out a request, so more idle connections means more overhead per request. To let one exit carry more concurrency, raise "Connections per exit" instead', + 'client_max_connections_label': 'Connections per exit', 'client_max_connections_hint': 'HTTP connections one exit may open at once', + 'client_max_connections_doc': 'How many HTTP connections one exit may hold open at once; raise this to let a single exit carry more concurrency', + 'client_keepalive_expiry_label': 'Idle recycle', 'client_keepalive_expiry_hint': 'Connections and their client are recycled after sitting idle this long', + 'client_keepalive_expiry_doc': 'Keepalive connections idle for longer than this are closed and the next request opens a new one; the client holding them is recycled on the same clock (it must not outlive its connections). Lowering it hurts connection reuse', + 'manual_switch_btn': 'Refresh exits', + 'theme_toggle_light': 'Light', 'theme_toggle_dark': 'Dark', + 'proxy_source_mode_local': 'Local', 'proxy_source_mode_api': 'API', 'proxy_source_mode_pool': 'Pool', + 'api_saved_credentials': 'Saved credentials', 'api_save_credential': 'Save current', 'api_delete_credential': 'Delete', + 'api_no_selection': 'None', 'api_enter_name': 'Enter credential name (e.g. Server A)', + 'api_credential_saved': 'Credential saved', 'api_credential_deleted': 'Credential deleted', 'api_confirm_delete': 'Delete credential', + 'cycle_mode': 'In order', 'loadbalance_mode': 'Least loaded', + 'mode_request': 'On request', 'mode_continuous': 'Continuous', + 'save_proxy_btn': 'Save', 'check_proxy_btn': 'Check', + 'config_tab': 'Proxy Config', 'access_control_tab': 'Access Control', 'logs_tab': 'Logs', + 'user_management_title': 'Users', + 'username_column': 'Username', 'password_column': 'Password', + 'add_user_btn': 'Add', + 'enter_username': 'Enter username', 'enter_password': 'Enter password', + 'confirm_delete_user': 'Delete user {}?', + 'users_save_success': 'Users saved', 'users_save_failed': 'Users save failed', + 'users_load_failed': 'Could not load the user list; the current view is unchanged', + 'ip_lists': 'IP Access', + 'whitelist_title': 'Whitelist', 'blacklist_title': 'Blacklist', + 'whitelist_first': 'Whitelist first', 'blacklist_first': 'Blacklist first', + 'save_list_btn': 'Save', + 'ip_list_save_success': 'Lists saved', 'ip_list_save_failed': 'Save failed', + 'bypass_whitelist_title': 'Proxy Bypass Whitelist', + 'bypass_whitelist_hint': 'Matched domains/IPs will connect directly without proxy. Supports wildcards', + 'bypass_save_success': 'Bypass whitelist saved', 'bypass_save_failed': 'Bypass save failed', + 'save_config': 'Save', + 'config_save_success': 'Config saved', 'config_save_failed': 'Config save failed', + 'port_saved_stopped': 'Port saved. It will take effect after the service is started', + 'config_apply_failed': 'Config saved, but failed to apply to the proxy service. Try restarting the service', + 'pool_config_apply_failed': 'Pool settings were written to the file but the pool service did not start, so they are not in effect yet. It retries automatically — you can also start it from the Pool tab', + 'config_field_invalid': 'Field "{}" has a problem', + 'config_port_restart_title': 'Restart after save', + 'config_port_restart_body': 'Changing the port restarts the proxy service after saving, which drops current connections. Continue?', + 'server_dirty_hint': 'Unsaved', + 'advanced_config_title': 'Advanced', + 'adv_rare_hint': 'You normally leave these alone. Make sure you know what an option does before changing it; most only take full effect after a restart', + 'adv_group_check': 'Checks and cooldowns', 'adv_group_connections': 'Connection reuse', + 'adv_group_logfiles': 'Log files', + 'advanced_group_timeout': 'Timeouts and cooldowns', + 'adv_group_files': 'Files and panel', + 'advanced_group_runtime': 'Runtime', + 'token_in_file_hint': 'The access token is not editable here: changing it invalidates the current URL immediately and locks you out. Edit the [Server] token in config/config.ini and restart instead.', + 'svc_confirm_title': 'Confirm', + 'svc_stop_confirm_body': 'Stopping the service drops every connection currently using the proxy. Stop it?', + 'svc_restart_confirm_body': 'Restarting the service briefly drops all connections. Restart it?', + 'log_level_hides_access_warning': 'This level also filters out successful access entries (INFO), so the access log will keep only failures and errors. Use INFO to keep successes.', + 'log_level_label': 'Log level', 'log_level_hint': 'Records below this level are not written', + 'log_level_doc': 'DEBUG / INFO / WARNING / ERROR / CRITICAL — lower levels record more detail', + 'log_access_enabled_label': 'Write the access log file', 'log_access_enabled_hint': 'Affects logs/access.log only', + 'log_access_enabled_doc': 'When off, the access log file is not written; domain statistics and access records still work', + 'records_enabled_label': 'Store access records', 'records_enabled_hint': 'Browse them under Logs → Access', + 'records_enabled_doc': 'Independent of the access log file and domain statistics — a separate per-request detail set', + 'records_real_ip_probe_label': 'Probe the real exit IP', 'records_real_ip_probe_hint': 'Costs one extra proxied request per gateway exit', + 'mode_idle_drain_note': 'With no requests, expired exits are removed and the pool empties; the next request fetches a fresh set', + 'records_real_ip_probe_doc': 'When the exit address is a gateway, ask an IP echo endpoint through it once and cache the answer per address. The pool source uses the exit IP already validated in the pool, and local proxies are never probed', + 'domain_stats_enabled_label': 'Keep domain statistics', 'domain_stats_enabled_hint': 'Aggregated per domain and exit, kept longer than records', + 'domain_stats_enabled_doc': 'Two separate datasets: statistics aggregate by domain and exit and are kept longer, while records serve time-range queries and export', + 'records_retention_label': 'Access record retention', 'records_retention_hint': '0 disables the age-based purge', + 'records_retention_doc': 'Records older than this are purged; with 0 only the row limit applies', + 'records_max_rows_label': 'Max access records kept', 'records_max_rows_hint': 'Oldest records are deleted beyond this count', + 'records_max_rows_doc': 'A hard cap on stored records — the oldest are removed first; 0 means unlimited', + 'records_flush_label': 'Access record flush interval', 'records_flush_hint': 'Buffered records are written every this often', + 'records_flush_doc': 'Lowering it makes records show up sooner under Logs → Access, at the cost of more frequent writes', + 'records_buffer_label': 'Access record memory buffer', 'records_buffer_hint': 'How many pending records to hold; needs a restart', + 'records_buffer_doc': 'How many pending records to hold in memory; raise it when writes cannot keep up', + 'domain_retention_label': 'Domain stats retention', 'domain_retention_hint': '0 keeps records forever', + 'domain_retention_doc': 'Domains not seen for longer than this are purged; 0 keeps them forever', + 'domain_flush_label': 'Domain stats flush interval', 'domain_flush_hint': 'Buffered stats are written every this often', + 'domain_flush_doc': 'Lowering it refreshes statistics sooner, at the cost of more frequent writes', + 'log_max_bytes_label': 'Max log size', 'log_max_bytes_hint': 'Rotates to a new file once full', + 'log_max_bytes_doc': 'Once full it rotates, and old files are kept up to the log file count', + 'log_backup_count_label': 'Log files to keep', 'log_backup_count_hint': 'At least 1; 0 makes the log never rotate', + 'log_backup_count_doc': 'How many rotated log files to keep; at least 1', + 'proxy_failure_cooldown_label': 'Suppress repeated failure logs', 'proxy_failure_cooldown_hint': 'One line per reason per window', + 'proxy_failure_cooldown_doc': 'A suppressed-count line follows once the window passes. A chattier warning class collapses at 5× this value (minimum 5s). Logging only — exit scheduling is unaffected', + 'buffer_size_label': 'Forwarding backpressure', 'buffer_size_hint': 'Pause and wait for the client after this much forwarded', + 'buffer_size_doc': 'Minimum 1024. It does not set the chunk size — the response body is forwarded in the upstream’s native chunks (usually 64 KiB), and tunnel relaying is fixed at 64 KiB regardless', + 'tunnel_idle_timeout_label': 'Tunnel silent timeout', 'tunnel_idle_timeout_hint': 'A silent upstream counts as dead; 0 disables', + 'tunnel_idle_timeout_doc': 'If the client sent data and the upstream never replies for this long, the exit is treated as dead. This is the only way to notice an upstream that accepts CONNECT and then goes silent', + 'proxy_check_ttl_label': 'Check result cache', 'proxy_check_ttl_hint': 'An exit is not re-checked while its result is fresh', + 'proxy_check_ttl_doc': 'Caches the verdict per exit and test URL, and reuses it for the whole TTL', + 'check_cooldown_label': 'Re-check gap per exit', 'check_cooldown_hint': 'Minimum gap between two liveness checks', + 'check_cooldown_doc': 'No probe is sent for that exit inside the cooldown, and its verdict does not change', + 'switch_cooldown_label': 'Min gap between exit fetches', 'switch_cooldown_hint': 'Minimum gap between two exit refreshes', + 'switch_cooldown_doc': 'Automatic refills and the manual "Refresh exits" button share this gap; refills are throttled by it too', + 'whitelist_file_label': 'Client IP allowlist file', 'blacklist_file_label': 'Client IP blocklist file', + 'bypass_file_label': 'Proxy bypass list file', 'bypass_file_hint': 'Matching targets connect directly; wildcards allowed', + 'bypass_file_doc': 'Targets matching a domain or IP in this list connect directly without the proxy; wildcards are supported', + 'list_files_hint': 'File name only; edit the contents under Access control', + 'list_files_doc': 'These are file names only, always read from config/; edit the contents in the list editor under Access control', + 'web_port_label': 'Web panel port', 'web_port_hint': 'Needs a full app restart, not just a proxy restart', + 'web_port_doc': 'Range 1-65535. Takes effect only after restarting the whole app', + 'display_level_label': 'CLI display level', 'display_level_hint': 'Affects the ProxyCat.py console output only', + 'display_level_doc': 'CLI (ProxyCat.py) mode only: 0 shows the status line, 1 adds progress and the exit list, 2 logs every request', + 'display_level_0': '0 Status line only', 'display_level_1': '1 Progress and exit list', 'display_level_2': '2 Every request', + 'db_maintain_title': 'Database Maintenance', 'db_optimize_btn': 'Optimize Database', + 'db_backup_time': 'Time', 'db_backup_size': 'Size', + 'db_stat_total': 'Proxies', 'db_stat_valid': 'Valid', 'db_stat_invalid': 'Invalid', 'db_stat_size': 'DB size', + 'db_backup_list_title': 'Backup History', 'db_no_backups': 'No backups yet', + 'db_backup_file': 'Backup file', 'db_backup_latest': 'Latest', + 'no_users': 'No account yet — use “Add user” below to create one', + 'user_show_password': 'Show password', 'user_hide_password': 'Hide password', + 'db_restore_btn': 'Restore', + 'db_restore_confirm_title': 'Restore Backup', + 'db_restore_confirm_body': 'This will overwrite the current database with the backup. The pool will be stopped and restarted automatically. Continue?', + 'db_restore_stopping': 'Stopping pool...', + 'db_restore_success': 'Backup restored', + 'db_optimize_confirm_body': 'Optimization rebuilds indexes and compacts the file. Writes may wait briefly. Continue?', + 'geo_title': 'Geo Databases', + 'geo_update_btn': 'Update Geo Databases', 'geo_recompute_btn': 'Recompute Geo', + 'geo_available': 'Offline databases ready: qqwry for Chinese cities, GeoLite2 for everything else', + 'geo_unavailable': 'Offline databases unavailable (see list below); geo lookups will all return "unknown". Click "Update Geo Databases" to download (~104 MB, takes minutes)', + 'geo_pending': '{} records pending recompute', 'geo_pending_zero': 'No records to recompute', + 'geo_file_list_title': 'Data file', 'geo_file_missing': 'Not downloaded', + 'geo_file_size': 'Size', 'geo_file_time': 'Modified', + 'geo_pool_down': 'Geo database status needs a running pool (the pending count comes from the database). Start the pool and hit Refresh', + 'geo_task_queued': 'Task created, fetching progress...', + 'geo_task_running': 'Running geo maintenance task...', + 'geo_task_lost': 'Task progress is gone (the process may have restarted). Hit Refresh to check the data files', + 'geo_region_untranslated': 'No translated name in this language yet - showing the original text', + 'log_level_important': 'Important', 'log_follow_latest': 'Follow latest logs', 'log_follow_btn': 'Follow', + 'pool_view_manage': 'Proxy Management', + 'log_level_all': 'All', 'log_level_info': 'Info', 'log_level_warning': 'Warning', + 'log_level_error': 'Error', 'log_level_critical': 'Critical', + 'clear_logs_btn': 'Clear', 'confirm_clear_logs': 'Clear all logs?', + 'logs_cleared': 'Logs cleared', 'clear_logs_failed': 'Clear failed', + 'export_logs_btn': 'Export', 'log_total': 'Total', 'no_logs': 'No logs', + 'log_cat_all': 'All', 'log_cat_main': 'Main', 'log_cat_proxy': 'Proxy', + 'log_cat_access': 'Access', 'log_cat_pool': 'Pool', 'log_cat_error': 'Errors', + 'log_source_live': 'Live log', 'log_source_title': 'Browse rotated history log files', + 'log_file_read_failed': 'Failed to read log file', + 'log_auto_btn': 'Auto refresh', 'log_auto_refresh_title': 'Pause to stop auto refreshing so you can read', + 'log_auto_on': 'Auto refresh resumed', 'log_auto_off': 'Auto refresh paused', + 'domain_col_success': 'OK', 'domain_col_failure': 'Fail', + 'domain_col_total': 'Total', 'domain_col_rate': 'Rate', 'domain_col_last': 'Last seen', + 'domain_col_reason': 'Last failure reason', + 'domain_clear_btn': 'Clear stats', 'domain_cleared': 'Domain statistics cleared', + 'confirm_clear_domains': 'Clear all domain access statistics?', + 'domain_disabled': 'Domain statistics is disabled in config', + 'domain_no_data': 'No domain statistics yet', + 'domain_summary_ok': 'OK', 'domain_summary_fail': 'Fail', + 'access_stats_title': 'Access statistics (by proxy)', + 'access_col_upstream': 'Exit address', 'access_col_hosts': 'Domains', + 'access_summary': 'Proxies', 'access_summary_hosts': 'Domains', + + 'access_mode_summary': 'By proxy', 'access_mode_records': 'Per request', + 'records_title': 'Access records', + 'access_outcome_success': 'Success', 'access_outcome_failure': 'Failure', + 'access_outcome_aborted': 'Aborted', + 'records_upstream_all': 'All proxies', 'records_outcome_all': 'All outcomes', + 'records_range_5m': 'Last 5 min', 'records_range_1h': 'Last hour', + 'records_range_24h': 'Last 24 h', 'records_range_today': 'Today', + 'records_since_title': 'Start time (inclusive)', + 'records_until_title': 'End time (inclusive); leave empty to follow the current time', + 'records_col_time': 'Time', 'records_col_client': 'Client', + 'records_col_target': 'Target', 'records_col_upstream': 'Exit address', + 'records_col_real_ip': 'Real IP', + 'records_col_outcome': 'Outcome', 'records_col_status': 'Status', + 'records_col_elapsed': 'Elapsed', 'records_col_reason': 'Reason', + 'records_page_info': 'Page {}/{}', + 'records_prev_page': 'Previous', 'records_next_page': 'Next', + 'records_export_btn': 'Export', 'records_clear_btn': 'Clear records', + 'records_auto_title': 'Paused automatically when paging, so new records cannot shift the page', + 'records_export_truncated': '{} records in range, above the single-export limit; only the newest {} will be exported. Narrow the time range to get the rest.', + 'records_summary_range': 'In range', 'records_summary_unit': 'records', + 'records_summary_pending': '{} pending flush', + 'records_summary_dropped': '{} dropped', + 'records_disabled': 'Access records are disabled in the configuration', + 'records_no_data': 'No access record in this time range', + 'confirm_clear_records': 'Clear the access records? This is a different dataset from "Clear statistics".', + 'proxy_save_success': 'Proxies saved', 'proxy_save_failed': 'Save failed', + 'proxy_check_result': 'Check done, valid: {}', + 'proxy_check_failed': 'Check failed', + 'switching': 'Refreshing...', 'switch_failed_network': 'Network request failed, check service status', + 'loading': 'Loading...', 'seconds': 's', + 'latest_version': 'Up to date', 'new_version_available': 'New version', + 'proxy_mode': { cycle: 'In order', loadbalance: 'Least loaded', + continuous: 'Continuous', request: 'On request' }, + 'operation_failed': 'Failed: {}', + 'operation_failed_bare': 'Operation failed', + 'modal_confirm': 'OK', 'modal_cancel': 'Cancel', + 'theme_switched': 'Theme changed', + 'language_switched': 'Language switched', 'log_level_filtered': 'Log level', + 'user_count_label': 'user(s)', + 'api_credential_switched': 'Credential switched', + 'addr_copied': 'Address copied', + 'log_export_started': 'Log export started', + 'confirm_clear_title': 'Clear logs', 'confirm_delete_title': 'Confirm delete', + 'edit_user_title': 'Change password', 'edit_user_msg': 'Change password for {}', + 'api_confirm_delete_msg': 'Delete credential "{}"?', 'api_name_placeholder': 'e.g. Server A', + 'ad_label': 'AD', 'ad_close': 'Dismiss', 'ad_reopen': 'Show Ads', + 'ad_link_default': 'Learn More', + 'scroll_to_top': 'Back to Top', + + 'refresh_btn': 'Refresh', 'items_unit': 'item(s)', + 'copy_manually': 'Please copy manually', 'no_changes': 'Nothing to save', + 'refresh_skipped_dirty': 'Unsaved changes; refresh skipped', + 'click_to_copy': 'Click to copy', + 'invalid_minutes': 'Enter a number of minutes greater than 0', 'minutes_unit': 'min', 'never_run': 'Never', + + 'upstream_exits': '{} exits', 'upstream_exits_expanding': '{} exits (expanding)', + 'exit_col_url': 'Exit', 'exit_col_load': 'In use / cap', 'exit_col_left': 'Left', + 'exit_served': '{} requests served', 'exit_failures': '{} failures', 'exit_suspect': 'Failing repeatedly, about to be replaced', + 'source_error_ago': ' ({} ago)', + 'exit_load_unlimited': '{}/unlimited', 'pool_rotate_waiting': 'on next request', + 'pool_waiting_exits': 'awaiting exits', + + 'pool_tab': 'Proxy Pool', 'pool_config_tab': 'Pool Settings', + 'pool_stat_total': 'Total', 'pool_stat_valid': 'Valid', + 'pool_stat_invalid': 'Invalid', 'pool_stat_rate': 'Availability', + 'pool_stopped_hint': 'Pool is not running. Click Start above to launch it.', + 'pool_start_failed': 'Pool is not running', + 'pool_unavailable_hint': 'Pool is currently unavailable. Retry later or restart it.', + 'pool_timeout_hint': 'Pool request timed out. Please retry.', + + 'pool_remote_url_ph': 'Leave empty to use the built-in pool', + 'filter_ip_ph': 'Fuzzy IP search', 'filter_min_delay_ph': 'Min delay (ms)', + 'filter_max_delay_ph': 'Max delay (ms)', 'filter_min_health_ph': 'Min health score', + 'log_search_ph': 'Search logs...', 'domain_search_ph': 'Search proxy address...', + 'log_clear_search': 'Clear search', 'no_data': 'No data', + 'copy_line': 'Copy this line', 'log_line_copied': 'Line copied', + 'stale_data_note': 'Showing the data from the last successful load', + 'records_search_ph': 'Search host / client / reason...', + 'api_credential_none': '-- Select a credential --', + 'task_stage_ingest': 'Storing directly', + + 'plugin_management_title': 'Plugins', + 'plugin_col_name': 'Plugin', 'plugin_col_status': 'Status', 'plugin_col_interval': 'Interval', + 'plugin_col_last_run': 'Last run', 'plugin_col_actions': 'Actions', + 'plugin_col_reval': 'Re-validation', + 'plugin_settings_title': 'Plugin validation policy', + 'plugin_reval_enabled_label': 'Periodically re-check proxies from this plugin', + 'plugin_reval_interval_label': 'Re-validation interval (minutes)', + 'plugin_reval_interval_hint': '0 inherits the global re-validation interval from Pool settings', + 'plugin_skip_validation_label': 'Skip ingest validation and store fetched proxies directly', + 'plugin_skip_validation_hint': 'For sources billed per request whose validity is guaranteed by the provider: no connection to the proxy, no probe request. Such records have no exit IP, anonymity or health score. Consider turning re-validation off above too, otherwise they will still be checked periodically (and cost money).', + 'plugin_reval_off': 'Off', 'plugin_reval_inherit': 'Global', + 'plugin_skip_validation_tag': 'unverified', + 'plugin_enabled': 'Enabled', 'plugin_disabled': 'Disabled', + 'plugin_run_btn': 'Fetch', 'plugin_reload_btn': 'Reload', + 'plugin_set_interval': 'Set fetch interval', + 'plugin_interval_hint': 'Fetch interval in minutes. Does not affect re-validation of stored proxies.', + 'plugin_test_url_global': 'Test URL: global', + 'plugin_test_url_custom': 'Test URL: custom', + 'plugin_set_test_url': 'Set test URL', + 'plugin_test_url_hint': 'Proxies from this plugin are liveness-checked against this URL. Leave empty to inherit the global [Server] test_url.', + 'no_plugins': 'No fetch plugin loaded', + + 'validate_valid_btn': 'Validate valid', 'validate_invalid_btn': 'Validate invalid', + 'full_check_btn': 'Full check', 'validate_selected_btn': 'Check liveness', + 'validate_full_btn': 'Full re-check', + 'task_validate_full': 'Full re-check', + 'reset_columns_btn': 'Reset columns', 'reset_columns_hint': 'Drop manual column widths and fit to content again', + 'density_btn': 'Row height', 'density_hint': 'Switch row height between cosy and compact (log lines included)', + 'detail_btn': 'Details', 'drawer_close': 'Close', 'yes': 'Yes', 'no': 'No', + 'drawer_group_conn': 'Connection', 'drawer_group_quality': 'Quality', + 'drawer_group_time': 'Timestamps', 'drawer_group_request': 'Request', + 'drawer_group_upstream': 'Upstream', 'drawer_group_result': 'Result', + 'copy_addr_btn': 'Copy address', 'ctx_copy_curl': 'Copy as curl command', + 'confirm_delete_proxy': 'Delete proxy {}? This cannot be undone.', + 'drawer_username': 'Username', 'drawer_password': 'Password', + 'drawer_region_en': 'Region (original)', 'drawer_success_rate': 'Success rate', + 'drawer_avg_delay': 'Average delay', 'drawer_total_checks': 'Checks', + 'drawer_probe_rate': 'Probe success rate', 'drawer_supports': 'Protocols', + 'drawer_favorite': 'Favourited', 'drawer_unfavorite': 'Remove favourite', + 'drawer_quality_at': 'Quality assessed at', + 'records_kind': 'Kind', 'records_method': 'Method', + 'exit_pop_toggle': 'Show the exit list', + 'access_hint_text': 'Per-proxy access statistics and the per-request detail list live under the “Access” category', + 'access_hint_btn': 'Go to “Access”', + 'adv_expand_all': 'Expand all advanced options', 'adv_collapse_all': 'Collapse all advanced options', + 'plugin_col_next_run': 'Next run', + 'cmd_placeholder': 'Search commands…', 'cmd_no_match': 'No matching command', + 'cmd_hint_nav': 'select', 'cmd_hint_run': 'run', 'cmd_hint_close': 'close', + 'cmd_group_view': 'View', 'cmd_group_config': 'Settings', 'cmd_group_action': 'Action', 'cmd_group_panel': 'Panel', + 'cmd_copy_view_link': 'Copy link to this view', 'cmd_link_copied': 'View link copied', + 'theme_toggle_btn': 'Toggle theme', 'lang_toggle_btn': 'Toggle language', + 'shortcut_title': 'Keyboard shortcuts', + 'shortcut_group_panel': 'Global', 'shortcut_group_table': 'In tables', + 'shortcut_switch_view': 'Switch to view 1–4', + 'shortcut_focus_search': 'Focus the search box of this view', + 'shortcut_refresh': 'Refresh this view', + 'shortcut_palette': 'Open the command palette', + 'shortcut_help': 'Show this shortcut list', + 'shortcut_close': 'Close the topmost overlay', + 'shortcut_row_move': 'Move row focus up / down', + 'shortcut_row_toggle': 'Toggle selection of that row', + 'shortcut_row_extend': 'Extend selection from the anchor', + 'shortcut_row_all': 'Select everything on this page', + 'shortcut_row_detail': 'Open that row’s details', + 'delete_selected_btn': 'Delete selected', 'delete_invalid_btn': 'Delete all invalid', + 'task_running': 'Running', 'task_completed': 'Completed', 'task_failed': 'Failed', + 'task_validate_valid': 'Validating all valid proxies', 'task_validate_invalid': 'Validating all invalid proxies', + 'task_validate_selected': 'Validating selected proxies', 'task_plugin_run': 'Plugin {} fetch', + 'task_full_check': 'Full proxy check', 'task_import': 'Importing proxies', + 'task_cancelled': 'Cancelled', 'task_cancel_hint': 'Stop this task', + 'task_stage_prescreen': 'Checking port reachability', 'task_stage_validate': 'Validating proxies', + 'task_rate': '{} per second', 'task_eta': '{} left', + 'task_eta_unknown': 'Estimating time left', + 'task_first_batch': '{} in flight, waiting for first results', + 'task_count_saved': '{} saved', 'task_count_invalid': '{} unusable', + 'task_count_prescreened': '{} unreachable', 'task_count_failed': '{} failed', + 'selected_count': '{} selected', 'select_all_label': 'Select page', + 'confirm_delete_selected': 'Delete {} selected proxies? This cannot be undone.', + 'confirm_delete_invalid_count': 'This will delete {} invalid proxies. It cannot be undone.', + 'no_invalid_proxies': 'No invalid proxy to delete', + 'page_first': 'First page', 'page_prev': 'Previous page', 'page_next': 'Next page', 'page_last': 'Last page', + + 'proxy_list_title': 'Proxies', + 'col_protocol': 'Protocol', 'col_address': 'Address', 'col_real_ip': 'Real IP', + 'col_region': 'Region', 'col_delay': 'Delay', 'col_health': 'Health', 'col_anonymity': 'Anonymity', + 'filter_region_ph': 'Region (- prefix to exclude)', + 'col_validated_at': 'Validated', 'col_source': 'Source', 'col_actions': 'Actions', + 'no_proxies': 'No proxy matches the filters', + 'favorite_btn': 'Favorite', + 'page_of': 'Page {}/{}', + 'anon_elite': 'Elite', 'anon_anonymous': 'Anonymous', 'anon_transparent': 'Transparent', + 'anon_unverified': 'Unverified', + 'anon_fallback_hint': 'This level is a fallback because no echo evidence could be obtained; select these rows and use "Full re-check" on the action bar to retry', + 'anonymity_fallback_hint': '{} proxies have an inferred anonymity level (no echo evidence, treated as transparent); select them and use "Full re-check" on the action bar to retry', + 'host_ip_line': 'Host public IP: {} ({})', + 'host_ip_line_dual': 'Host public IP: {} ({}); IPv6: {}', + 'host_ip_manual': 'manual', 'host_ip_auto': 'auto-detected', + 'host_ip_missing': 'Host public IP not detected; anonymity cannot be judged. Set a custom exit IP under Pool settings → Proxy validation (that also removes this external lookup).', + + 'filter_all_protocol': 'All protocols', 'filter_all_status': 'All status', + 'filter_valid': 'Valid only', 'filter_invalid': 'Invalid only', + 'proxy_invalid_badge': 'Invalid', + 'proxy_invalid_badge_hint': 'This proxy is currently unusable. Retry with "Validate invalid" or delete it.', + 'filter_all_favorite': 'All', 'filter_favorite': 'Favorites only', + 'filter_all_anonymity': 'All anonymity', 'filter_all_source': 'All sources', + 'advanced_filter_btn': 'Advanced', 'reset_filter_btn': 'Reset', + + 'import_btn': 'Import', 'export_btn': 'Export', 'copy_api_link_btn': 'Copy API link', + 'import_title': 'Import proxies', + 'import_hint': 'One proxy per line. Supports http/https/socks5, with optional credentials.', + 'import_validate_note': 'Each imported proxy is validated first; only working ones are saved. Location is looked up asynchronously afterwards.', + 'import_submit': 'Import', 'import_empty': 'Paste some proxies first', + 'export_title': 'Export proxies', 'export_hint': 'Exports using the current filters.', + 'export_fmt_txt': 'One per line, most portable', 'export_fmt_json': 'All fields', + 'export_fmt_csv': 'Spreadsheet-friendly', + 'export_scope_count': '{} proxies will be exported', 'import_count': '{} proxies detected', + 'api_link_copied': 'API link copied (built from the current filters)', + + 'pool_group_basic': 'General', + 'reset_form_btn': 'Discard changes', + 'unsaved_hint': 'Unsaved' + } +}; diff --git a/web/static/js/palette.js b/web/static/js/palette.js new file mode 100644 index 0000000..a9d8b01 --- /dev/null +++ b/web/static/js/palette.js @@ -0,0 +1,273 @@ +/** + * 模块名称:命令面板与全局快捷键(palette.js) + * 功能描述:Ctrl/⌘+K 命令面板的取词、过滤、渲染与执行,把跳转、服务启停、批量校验、 + * 导入导出、切主题切语言等动作收成可搜索入口,并提供全局快捷键分发、说明弹窗与直达链接复制。 + * 职责边界:负责:命令面板与快捷键说明弹窗的渲染、过滤、键盘导航与全局快捷键分发,以及当前视图直达链接的复制。 + * 不负责:表格键盘操作(tables.js)与命令背后的业务动作(由 app.js / pool.js / proxies.js / i18n.js 提供)。 + * 关键依赖:jQuery;i18n.js 的 T() / onLangRender / toggleLang;common.js 的 escapeHtml / getToken; + * 运行期调用 app.js / pool.js / proxies.js 的全局函数。 + * 已知限制: + * 1. 普通单键快捷键在输入态让路(isTypingTarget),新增键位无需各自判断;Ctrl/⌘+K 为全局例外,命令面板打开时搜索框内的 Esc/方向键/Enter 由面板接管。 + * 2. 单键快捷键(1-4、r、/、?、Esc)只在无 Ctrl / ⌘ / Alt 修饰时生效。 + * 3. Esc 只接管命令面板与快捷键说明弹窗两层,右键菜单、抽屉等浮层由各自脚本处理。 + * 4. 过滤是大小写不敏感的子串匹配(同时匹配文案与命令 id),不做拼音或模糊评分。 + * 5. 关闭面板时必须把焦点从搜索框 blur 掉,否则隐藏的输入框仍占着 document.activeElement 吞掉单键快捷键。 + * 6. 「刷新当前页」遇到未保存的服务器表单修改会跳过并提示,不会用 loadConfig() 整份重写表单。 + * 7. 命令文案在打开面板时按当前语言重新取词(_commandEntries),不在加载期固化,否则切换语言后仍显示旧文案。 + */ +var _paletteEntries = []; +var _paletteFocus = -1; + +function _commandDefs() { + return [ + { id: 'view-config', group: 'cmd_group_view', label: 'config_tab', icon: 'fa-sliders', + run: function () { switchTab('tab-config'); } }, + { id: 'view-pool-manage', group: 'cmd_group_view', label: 'pool_view_manage', icon: 'fa-list', + run: function () { switchTab('tab-pool', { view: 'pool-view-manage' }); } }, + { id: 'view-pool-settings', group: 'cmd_group_view', label: 'pool_config_tab', icon: 'fa-cogs', + run: function () { switchTab('tab-pool', { view: 'pool-view-settings' }); } }, + { id: 'view-pool-db', group: 'cmd_group_view', label: 'db_maintain_title', icon: 'fa-database', + run: function () { switchTab('tab-pool', { view: 'pool-view-db' }); } }, + { id: 'view-access', group: 'cmd_group_view', label: 'access_control_tab', icon: 'fa-shield', + run: function () { switchTab('tab-access'); } }, + { id: 'view-logs', group: 'cmd_group_view', label: 'logs_tab', icon: 'fa-file-text', + run: function () { switchTab('tab-logs'); } }, + + { id: 'go-source', group: 'cmd_group_config', label: 'cfg_group_source', icon: 'fa-plug', + run: function () { switchTab('tab-config', { group: 'source' }); } }, + { id: 'go-listen', group: 'cmd_group_config', label: 'cfg_group_listen', icon: 'fa-server', + run: function () { switchTab('tab-config', { group: 'listen' }); } }, + { id: 'go-exits', group: 'cmd_group_config', label: 'cfg_group_exits', icon: 'fa-random', + run: function () { switchTab('tab-config', { group: 'exits' }); } }, + { id: 'go-check', group: 'cmd_group_config', label: 'cfg_group_check', icon: 'fa-check-circle-o', + run: function () { switchTab('tab-config', { group: 'check' }); } }, + { id: 'go-perf', group: 'cmd_group_config', label: 'cfg_group_perf', icon: 'fa-tachometer', + run: function () { switchTab('tab-config', { group: 'perf' }); } }, + { id: 'go-logs-cfg', group: 'cmd_group_config', label: 'cfg_group_logs', icon: 'fa-file-text-o', + run: function () { switchTab('tab-config', { group: 'logs' }); } }, + { id: 'go-advanced', group: 'cmd_group_config', label: 'advanced_config_title', icon: 'fa-cog', + run: function () { switchTab('tab-config', { group: 'advanced' }); } }, + + { id: 'svc-start', group: 'cmd_group_action', label: 'start_service', icon: 'fa-play', + run: function () { controlService('start'); } }, + { id: 'svc-stop', group: 'cmd_group_action', label: 'stop_service', icon: 'fa-stop', + run: function () { controlService('stop'); } }, + { id: 'svc-restart', group: 'cmd_group_action', label: 'restart_service', icon: 'fa-refresh', + run: function () { controlService('restart'); } }, + { id: 'switch-proxy', group: 'cmd_group_action', label: 'manual_switch_btn', icon: 'fa-exchange', + run: function () { switchProxy(); } }, + { id: 'validate-valid', group: 'cmd_group_action', label: 'validate_valid_btn', icon: 'fa-check-circle', + run: function () { validateAllProxies(true); } }, + { id: 'validate-invalid', group: 'cmd_group_action', label: 'validate_invalid_btn', icon: 'fa-question-circle', + run: function () { validateAllProxies(false); } }, + { id: 'full-check', group: 'cmd_group_action', label: 'full_check_btn', icon: 'fa-globe', + run: function () { updateAllGeo(); } }, + { id: 'import', group: 'cmd_group_action', label: 'import_btn', icon: 'fa-upload', + run: function () { openImportModal(); } }, + { id: 'export', group: 'cmd_group_action', label: 'export_btn', icon: 'fa-download', + run: function () { openExportModal(); } }, + { id: 'copy-api-link', group: 'cmd_group_action', label: 'copy_api_link_btn', icon: 'fa-link', + run: function () { copyPoolApiLink(); } }, + { id: 'delete-invalid', group: 'cmd_group_action', label: 'delete_invalid_btn', icon: 'fa-broom', + danger: true, run: function () { deleteAllInvalid(); } }, + + { id: 'toggle-theme', group: 'cmd_group_panel', label: 'theme_toggle_btn', icon: 'fa-moon-o', + run: function () { cycleTheme(); } }, + { id: 'toggle-lang', group: 'cmd_group_panel', label: 'lang_toggle_btn', icon: 'fa-globe', + run: function () { toggleLang(); } }, + { id: 'copy-view-link', group: 'cmd_group_panel', label: 'cmd_copy_view_link', icon: 'fa-share-alt', + run: function () { copyCurrentViewLink(); } }, + { id: 'shortcut-help', group: 'cmd_group_panel', label: 'shortcut_title', icon: 'fa-keyboard-o', + run: function () { showShortcutHelp(); } } + ]; +} + +function _commandEntries() { + return _commandDefs().map(function (c) { + return { + id: c.id, icon: c.icon, danger: c.danger, run: c.run, + label: T()[c.label], + group: T()[c.group] + }; + }); +} + +function isCommandPaletteOpen() { + return $('#cmd-palette').hasClass('show'); +} + +function openCommandPalette() { + _paletteEntries = _commandEntries(); + _paletteFocus = -1; + $('#palette-input').val(''); + renderPaletteList(''); + $('#cmd-palette').addClass('show'); + setTimeout(function () { $('#palette-input').trigger('focus'); }, 60); +} + +function closeCommandPalette() { + $('#cmd-palette').removeClass('show'); + $('#palette-input').trigger('blur'); + _paletteFocus = -1; +} + +function _highlight(label, query) { + if (!query) return escapeHtml(label); + var at = label.toLowerCase().indexOf(query.toLowerCase()); + if (at < 0) return escapeHtml(label); + return escapeHtml(label.slice(0, at)) + + '' + escapeHtml(label.slice(at, at + query.length)) + '' + + escapeHtml(label.slice(at + query.length)); +} + +function _paletteMatches(e, q) { + if (!q) return true; + var needle = q.toLowerCase(); + return e.label.toLowerCase().indexOf(needle) >= 0 || + e.id.toLowerCase().indexOf(needle) >= 0; +} + +function renderPaletteList(query) { + var q = (query || '').trim(); + var list = $('#palette-list').empty(); + var matched = _paletteEntries.filter(function (e) { return _paletteMatches(e, q); }); + + if (!matched.length) { + list.append('
' + escapeHtml(T().cmd_no_match) + '
'); + _paletteFocus = -1; + return; + } + + _paletteFocus = 0; + matched.forEach(function (e, i) { + var btn = $('') + .html('' + + '' + _highlight(e.label, q) + '' + + '' + escapeHtml(e.group) + ''); + btn.on('click', function () { + closeCommandPalette(); + if (e.run) e.run(); + }); + list.append(btn); + }); +} + +function movePaletteFocus(delta) { + var items = $('#palette-list .palette-item'); + if (!items.length) return; + _paletteFocus = Math.min(items.length - 1, Math.max(0, _paletteFocus + delta)); + items.removeClass('focus').eq(_paletteFocus).addClass('focus'); + items[_paletteFocus].scrollIntoView({ block: 'nearest' }); +} + +function runFocusedPaletteCommand() { + var focused = $('#palette-list .palette-item.focus'); + if (focused.length) focused.trigger('click'); +} + +function copyCurrentViewLink() { + var token = getToken(); + var url = location.origin + '/web' + (token ? '?token=' + encodeURIComponent(token) : '') + currentHash(); + copyToClipboard(url, function () { + toast(T().cmd_link_copied, 'success'); + }); +} + +var SHORTCUT_ROWS = [ + { keys: ['1', '2', '3', '4'], label: 'shortcut_switch_view' }, + { keys: ['/'], label: 'shortcut_focus_search' }, + { keys: ['r'], label: 'shortcut_refresh' }, + { keys: ['Ctrl', 'K'], label: 'shortcut_palette' }, + { keys: ['?'], label: 'shortcut_help' }, + { keys: ['Esc'], label: 'shortcut_close' } +]; +var SHORTCUT_TABLE_ROWS = [ + { keys: ['↑', '↓'], label: 'shortcut_row_move' }, + { keys: ['Space'], label: 'shortcut_row_toggle' }, + { keys: ['Shift', '↑↓'], label: 'shortcut_row_extend' }, + { keys: ['Ctrl', 'A'], label: 'shortcut_row_all' }, + { keys: ['Enter'], label: 'shortcut_row_detail' } +]; + +function renderShortcutHelp() { + var grid = $('#shortcut-grid').empty(); + grid.append('
' + escapeHtml(T().shortcut_group_panel) + '
'); + SHORTCUT_ROWS.forEach(function (r) { grid.append(_shortcutRow(r)); }); + grid.append('
' + escapeHtml(T().shortcut_group_table) + '
'); + SHORTCUT_TABLE_ROWS.forEach(function (r) { grid.append(_shortcutRow(r)); }); +} + +function _shortcutRow(r) { + var keys = r.keys.map(function (k) { return '' + escapeHtml(k) + ''; }).join(''); + return $('
') + .append($('').html(keys)) + .append($('').text(T()[r.label])); +} + +function showShortcutHelp() { + onLangRender(renderShortcutHelp); + renderShortcutHelp(); + $('#shortcut-modal').addClass('show'); +} + +function isTypingTarget(el) { + if (!el) return false; + var tag = (el.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + return !!(el.isContentEditable || (el.closest && el.closest('[contenteditable="true"]'))); +} + +function focusActiveSearch() { + var $box = $('#tab-logs').hasClass('show') ? $('#log-search') + : ($('#tab-pool').hasClass('show') ? $('#filter-ip') : $()); + if ($box.length && $box.is(':visible')) { $box.trigger('focus'); return true; } + return false; +} + +function refreshActiveView() { + if ($('#tab-logs').hasClass('show')) { updateLogs(true); loadLogFiles(); return; } + if ($('#tab-pool').hasClass('show')) { loadProxies(); loadPlugins(); updatePoolStatus(); return; } + if ($('#tab-access').hasClass('show')) { loadUsers(); loadIpLists(); loadBypassWhitelist(); return; } + if (serverFormDirty()) { toast(T().refresh_skipped_dirty, 'error'); return; } + loadConfig({ serverOnly: true }); +} + +function _onShortcutKeydown(e) { + if (isCommandPaletteOpen()) { + if (e.key === 'Escape') { closeCommandPalette(); e.preventDefault(); return; } + if (e.key === 'ArrowDown') { movePaletteFocus(1); e.preventDefault(); return; } + if (e.key === 'ArrowUp') { movePaletteFocus(-1); e.preventDefault(); return; } + if (e.key === 'Enter') { runFocusedPaletteCommand(); e.preventDefault(); } + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + openCommandPalette(); + return; + } + if (isTypingTarget(e.target)) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === 'Escape' && $('#shortcut-modal').hasClass('show')) { + closeModal('shortcut-modal'); + return; + } + if (e.key === '?') { e.preventDefault(); showShortcutHelp(); return; } + if (e.key === '/') { if (focusActiveSearch()) e.preventDefault(); return; } + if (e.key === 'r') { refreshActiveView(); return; } + + var views = ['tab-config', 'tab-pool', 'tab-access', 'tab-logs']; + var n = parseInt(e.key, 10); + if (n >= 1 && n <= 4) { switchTab(views[n - 1]); } +} + +$(document).on('keydown', _onShortcutKeydown); +$(document).on('input', '#palette-input', function () { renderPaletteList(this.value); }); + +$(document).on('click', '#cmd-palette', function (e) { + if (e.target === this) closeCommandPalette(); +}); +$(document).on('click', '#shortcut-modal', function (e) { + if (e.target === this) closeModal('shortcut-modal'); +}); diff --git a/web/static/js/pool.js b/web/static/js/pool.js new file mode 100644 index 0000000..87825aa --- /dev/null +++ b/web/static/js/pool.js @@ -0,0 +1,1189 @@ +/** + * 模块名称:代理池管理页(pool.js) + * 功能描述:代理池页签的全部前端行为:池状态与启停按钮门控、概览卡与内存趋势线、抓取插件管理、 + * 批量任务提交与进度跟踪、schema 驱动的池参数表单、导入导出、数据库与归属地维护。 + * 职责边界:负责:池自身接口的调用与渲染、启停按钮的禁用门控;不负责:池启停请求的实现 + * (app.js 的 controlPool)、代理列表的筛选排序分页与批量选择(proxies.js)、面板外壳与配置页、日志页(app.js)。 + * 关键依赖:common.js 的请求、存储、格式化、轮询与渲染工具;tables.js 的表格加载/重试/提示; + * app.js 的 toast、btnLoad、弹窗、controlPool、配置页共享组件与视图切换; + * i18n.js 的 T、onLangRender;proxies.js 的 loadProxies、collectFilterParams、 + * updateSelectionUI、totalCount;jQuery。 + * 已知限制: + * 1. POOL_TRUE_WORDS 与后端 modules.TRUE_WORDS 是同一份词表,修改需两边同步。 + * 2. 池表单重绘会覆盖未保存的编辑,需过 poolFormDirty() 守卫;保存只提交与基线不同的键。 + * 3. 池相关控件靠 data-requires-pool 统一禁用,新加的行内控件必须带该属性。 + * 4. poolErrorText 特化 503/504、status 0 视为网络错误,不能与 jqErrorText 合并。 + * 5. 归属地下载任务 progress/total 是字节、重算任务是 IP 条数,均走独立渲染,不能并入批量任务区。 + * 6. 带 visible_when 的字段不得折进高级区,折叠会让用户看不到已启用功能的从属阈值。 + * 7. .settings-nav-item 被代理配置页复用,池页选择器必须限定在 #pool-settings-nav 内。 + * 8. 全部函数为全局作用域,与 app.js / proxies.js 重名会静默覆盖。 + */ + +var _poolSchema = null; +var _pluginRows = {}; +var _activeTasks = {}; + +var POOL_TASK_POLL_MS = 2000; +var POOL_STATUS_POLL_MS = 5000; + +var _poolRunning = false; + +function setPoolRunning(running) { + _poolRunning = running; + $('#tab-pool [data-requires-pool]').prop('disabled', !running); + $('#pool-ctl-dot').attr('class', 'dot ' + (running ? 'on' : 'off')); + $('#pool-ctl-text').text(T().service_status[running ? 'running' : 'stopped'] || (running ? 'running' : 'stopped')); + $('#pool-start').prop('disabled', running); + $('#pool-stop').prop('disabled', !running); + if (typeof updateSelectionUI === 'function') updateSelectionUI(); + syncGeoControls(); +} + +function poolReady() { return _poolRunning; } + + +function poolRequest(method, path, body, onOk, opts) { + var o = opts || {}; + var settings = { url: appendToken('/api/pool/' + path), method: method }; + if (body !== null && body !== undefined) { + settings.contentType = 'application/json'; + settings.data = JSON.stringify(body); + } + return $.ajax(settings) + .done(function (r) { if (onOk) onOk(r); }) + .fail(function (x) { + if (o.silent) return; + toast(poolErrorText(x), 'error'); + }); +} + +function poolErrorText(x) { + if (x && (x.status === 503 || x.status === 504)) { + var m = x.responseJSON && x.responseJSON.message; + if (m) return m; + return x.status === 503 ? T().pool_unavailable_hint : T().pool_timeout_hint; + } + if (x && !x.status) return T().network_error; + return jqErrorText(x, fillTemplate(T().operation_failed, (x && x.status) || '')); +} + + +function startPoolPolling() { + stopPoolPolling(); + updatePoolStatus(); + loadHostIp(); + startPolling('pool', updatePoolStatus, POOL_STATUS_POLL_MS); +} + +function stopPoolPolling() { + stopPolling('pool'); +} + +function updatePoolStatus() { + onLangRender(updatePoolStatus); + $.get(appendToken('/api/pool/status')) + .done(function (d) { + var wasRunning = _poolRunning; + setPoolRunning(!!d.is_running); + renderPoolStats(d.stats); + showPoolNotice(d.is_running, d.last_error); + if (!wasRunning && d.is_running && $('#tab-pool').hasClass('show')) { + loadPlugins(); + loadSources(); + loadProxies(); + loadDbMaintain(); + } + }) + .fail(function () { + setPoolRunning(false); + renderPoolStats(null); + }); +} + +var _poolStatsHistory = []; +var POOL_STATS_HISTORY_MAX = 24; + +function renderPoolStats(stats) { + if (!stats) { + $('#st-total, #st-valid, #st-invalid, #st-rate').text('--'); + $('#st-rate').closest('.stat-card').removeClass('ok warn bad'); + $('#st-invalid').closest('.stat-card').removeClass('bad'); + $('#st-total-spark, #st-valid-spark, #st-rate-spark').empty(); + $('#stats-fallback-hint').hide(); + return; + } + var total = stats.total_proxies || 0; + var valid = stats.valid_proxies || 0; + var rate = total > 0 ? (valid / total) * 100 : null; + + $('#st-total').text(total); + $('#st-valid').text(valid); + var invalid = stats.invalid_proxies || 0; + $('#st-invalid').text(invalid); + $('#st-rate').text(rate === null ? '--' : Math.round(rate) + '%'); + $('#st-rate').closest('.stat-card').removeClass('ok warn bad') + .addClass(rate === null ? '' : (rate >= 70 ? 'ok' : (rate >= 30 ? 'warn' : 'bad'))); + $('#st-invalid').closest('.stat-card').toggleClass('bad', invalid > 0); + + _poolStatsHistory.push({ total: total, valid: valid, rate: rate }); + if (_poolStatsHistory.length > POOL_STATS_HISTORY_MAX) _poolStatsHistory.shift(); + _renderPoolSparks(); + + var fallback = stats.anonymity_fallback_proxies || 0; + $('#stats-fallback-text').text(fillTemplate(T().anonymity_fallback_hint, fallback)); + $('#stats-fallback-hint').toggle(fallback > 0); +} + +function _renderPoolSparks() { + var h = _poolStatsHistory; + $('#st-total-spark').html(sparklineSvg(h.map(function (s) { return s.total; }))); + $('#st-valid-spark').html(sparklineSvg(h.map(function (s) { return s.valid; }))); + $('#st-rate-spark').html(sparklineSvg(h.map(function (s) { return s.rate; }))); +} + +function loadHostIp() { + onLangRender(loadHostIp); + $.ajax({ + url: appendToken('/api/pool/host-ip'), + success: function (d) { + var hint = $('#host-ip-hint'); + if (!d || !d.check_anonymity) { + hint.hide(); + return; + } + + var v4 = d.host_public_ipv4 || ''; + var v6 = d.host_public_ipv6 || ''; + var primary = v4 || v6 || d.host_public_ip || ''; + var srcLabel = d.source === 'configured' ? T().host_ip_manual : T().host_ip_auto; + + var missing = !primary; + $('#host-ip-text').text(missing ? T().host_ip_missing + : (v4 && v6 ? fillTemplate(T().host_ip_line_dual, v4, srcLabel, v6) + : fillTemplate(T().host_ip_line, primary, srcLabel))); + hint.toggleClass('notice-warn', missing); + hint.find('i').attr('class', 'fa ' + (missing ? 'fa-exclamation-triangle' : 'fa-info-circle')); + hint.show(); + }, + error: function () { $('#host-ip-hint').hide(); } + }); +} + +function showPoolNotice(running, lastError) { + var box = $('#pool-notice'); + if (running) { box.hide(); return; } + + var text = lastError + ? T().pool_start_failed + T().sep_colon + lastError + : T().pool_stopped_hint; + $('#pool-notice-text').text(text); + box.show(); +} + + +function loadPlugins() { + onLangRender(loadPlugins); + var btn = $('#btn-reload-plugins'); + btnLoad(btn, true); + + beginTableLoad('#plugin-tbody', 7, 4); + + $.ajax({ url: appendToken('/api/pool/plugins'), timeout: 15000 }) + .done(function (plugins) { + clearTableAlert('#plugin-tbody'); + renderPluginTable(plugins || []); + }) + .fail(function (x) { + showTableAlert('#plugin-tbody', 7, + poolErrorText(x) + T().sep_mid + T().stale_data_note, 'plugins'); + }) + .always(function () { + btnLoad(btn, false); + }); +} + +registerTableRetry('plugins', loadPlugins); + +function renderPluginTable(plugins) { + var tbody = $('#plugin-tbody'); + tbody.empty(); + $('#plugin-count').text(plugins.length + ' ' + T().items_unit).toggle(plugins.length > 0); + + _pluginRows = {}; + plugins.forEach(function (p) { _pluginRows[p.name] = p; }); + + if (!plugins.length) { + tbody.append(emptyRow(7, T().no_plugins)); + return; + } + + var attrs = ' data-requires-pool' + (poolReady() ? '' : ' disabled'); + + plugins.forEach(function (p) { + var name = escapeHtml(p.name); + + tbody.append( + '' + + '
' + name + '
' + + (p.last_error ? '
' + escapeHtml(p.last_error) + '
' : '') + + '' + + '' + + '' + + '' + escapeHtml(fmtTime(p.last_run)) + '' + + '' + + escapeHtml(p.enabled && p.next_run ? fmtTime(p.next_run) : '--') + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + ''); + }); +} + +function _pluginValidationText(p) { + var prefix = p.skip_validation ? T().plugin_skip_validation_tag + ' · ' : ''; + if (!p.reval_enabled) return prefix + T().plugin_reval_off; + if (p.reval_interval_minutes > 0) { + return prefix + p.reval_interval_minutes + ' ' + T().minutes_unit; + } + return prefix + T().plugin_reval_inherit; +} + +function openPluginSettingsModal(name, plugin) { + $('#plugin-settings-name').text(name); + $('#plugin-reval-enabled').prop('checked', plugin.reval_enabled !== false); + $('#plugin-reval-interval').val(plugin.reval_interval_minutes || 0); + $('#plugin-skip-validation').prop('checked', plugin.skip_validation === true); + openModal('plugin-settings-modal'); +} + +function closePluginSettingsModal() { closeModal('plugin-settings-modal'); } + +function savePluginSettings() { + var name = $('#plugin-settings-name').text(); + var minutes = parseInt($('#plugin-reval-interval').val(), 10); + if (isNaN(minutes) || minutes < 0) { + toast(T().invalid_minutes, 'error'); + return; + } + + var btn = $('#btn-plugin-settings-save'); + btnLoad(btn, true); + poolRequest('PUT', 'plugins/' + encodeURIComponent(name) + '/validation', { + reval_enabled: $('#plugin-reval-enabled').prop('checked'), + reval_interval_minutes: minutes, + skip_validation: $('#plugin-skip-validation').prop('checked') + }, function (r) { + toast(r.message || '', 'success'); + closePluginSettingsModal(); + loadPlugins(); + }).always(function () { btnLoad(btn, false); }); +} + +function togglePlugin(name, enabled, el) { + var $box = $(el).prop('disabled', true); + poolRequest('POST', 'plugins/' + encodeURIComponent(name) + '/' + (enabled ? 'enable' : 'disable'), null, + function (r) { + if (r.success === false) { toast(r.message, 'error'); restorePluginToggle($box, enabled); return; } + toast(r.message, 'success'); + loadPlugins(); + }, + { silent: false }) + .fail(function () { restorePluginToggle($box, enabled); }); +} + +function restorePluginToggle($box, attempted) { + $box.prop('checked', !attempted).prop('disabled', false); + $box.siblings('span').text(!attempted ? T().plugin_enabled : T().plugin_disabled); +} + +function setPluginInterval(name, current) { + showModal({ + title: T().plugin_set_interval, + body: T().plugin_interval_hint, + input: true, value: current, showCancel: true, + callback: function (val) { + if (val === null) return; + var minutes = parseInt(val, 10); + if (!minutes || minutes < 1) { toast(T().invalid_minutes, 'error'); return; } + poolRequest('PUT', 'plugins/' + encodeURIComponent(name) + '/interval', { minutes: minutes }, + function (r) { toast(r.message, 'success'); loadPlugins(); }); + } + }); +} + +function setPluginTestUrl(name, current) { + showModal({ + title: T().plugin_set_test_url, + body: T().plugin_test_url_hint, + input: true, value: current, showCancel: true, + callback: function (val) { + if (val === null) return; + poolRequest('PUT', 'plugins/' + encodeURIComponent(name) + '/test-url', { test_url: (val || '').trim() }, + function (r) { toast(r.message, 'success'); loadPlugins(); }); + } + }); +} + +function runPlugin(name, btn) { + btnLoad(btn, true); + poolRequest('POST', 'plugins/' + encodeURIComponent(name) + '/run', null, function (r) { + if (r.task_id) trackTask(r.task_id, fillTemplate(T().task_plugin_run, name)); + }).always(function () { btnLoad(btn, false); }); +} + +function reloadPlugin(name, btn) { + btnLoad(btn, true); + poolRequest('POST', 'plugins/' + encodeURIComponent(name) + '/reload', null, function (r) { + toast(r.message, r.success === false ? 'error' : 'success'); + if (r.success) loadPlugins(); + }).always(function () { btnLoad(btn, false); }); +} + + +function validateAllProxies(onlyValid) { + var path = onlyValid ? 'validate/all-valid' : 'validate/all-invalid'; + var label = onlyValid ? T().task_validate_valid : T().task_validate_invalid; + startBatchTask(path, label, onlyValid ? $('#btn-validate-valid') : $('#btn-validate-invalid')); +} + +function updateAllGeo() { + startBatchTask('update-geo/all', T().task_full_check, $('#btn-full-check')); +} + +function startBatchTask(path, label, btn) { + var b = btn && btn.length ? btn : $(); + btnLoad(b, true); + poolRequest('POST', path, null, function (r) { + toast(r.message, r.success === false ? 'error' : 'success'); + if (r.task_id) trackTask(r.task_id, label); + }).always(function () { btnLoad(b, false); }); +} + +var TASK_STORAGE_KEY = 'proxycat-active-tasks'; + +function trackTask(taskId, label) { + _activeTasks[taskId] = { label: label || T().task_running }; + _persistTasks(); + renderTasks(); + if (!isPolling('task')) { + startPolling('task', pollTasks, POOL_TASK_POLL_MS); + } + pollTasks(); +} + +function _persistTasks() { + safeSetJSON(TASK_STORAGE_KEY, _activeTasks); +} + +function removeSavedTask(id) { + var saved = safeGetJSON(TASK_STORAGE_KEY, null) || {}; + if (saved[id] !== undefined) { + delete saved[id]; + safeSetJSON(TASK_STORAGE_KEY, saved); + } +} + +function restoreActiveTasks() { + var saved = safeGetJSON(TASK_STORAGE_KEY, null); + if (!saved || typeof saved !== 'object') return; + + Object.keys(saved).forEach(function (id) { + $.get(appendToken('/api/pool/tasks/' + id)) + .done(function (task) { + if (task.status === 'running' || task.status === 'queued') { + trackTask(id, saved[id].label || T().task_running); + } else { + removeSavedTask(id); + } + }) + .fail(function () { removeSavedTask(id); }); + }); +} + +function pollTasks() { + var ids = Object.keys(_activeTasks); + if (!ids.length) { + stopPolling('task'); + return; + } + + ids.forEach(function (id) { + $.get(appendToken('/api/pool/tasks/' + id)) + .done(function (d) { onTaskUpdate(id, d); }) + .fail(function () { + delete _activeTasks[id]; + removeSavedTask(id); + renderTasks(); + }); + }); +} + +function onTaskUpdate(id, task) { + var entry = _activeTasks[id]; + if (!entry) return; + + entry.status = task.status; + entry.progress = task.progress || 0; + entry.total = task.total || 0; + entry.message = task.message || ''; + entry.stage = task.stage || ''; + entry.stageDone = task.stage_done || 0; + entry.stageTotal = task.stage_total || 0; + entry.inFlight = task.in_flight || 0; + entry.rate = task.rate || 0; + entry.eta = task.eta_seconds; + entry.counts = { + saved: task.valid_count || 0, + invalid: task.invalid_count || 0, + prescreened: task.prescreened_out || 0, + failed: task.failed_count || 0 + }; + + if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { + delete _activeTasks[id]; + removeSavedTask(id); + if (task.status === 'completed') toast(entry.message || entry.label, 'success'); + else if (task.status === 'cancelled') toast(entry.message || T().task_cancelled, 'success'); + else toast(entry.message || entry.label, 'error'); + loadPlugins(); + loadProxies(); + updatePoolStatus(); + loadHostIp(); + } + renderTasks(); +} + +function taskStageLabel(stage) { + if (stage === 'prescreen') return T().task_stage_prescreen; + if (stage === 'validate') return T().task_stage_validate; + if (stage === 'ingest') return T().task_stage_ingest; + return ''; +} + +function renderTaskDock() { + onLangRender(renderTaskDock); + var ids = Object.keys(_activeTasks); + var dock = $('#task-dock'); + var badge = $('#sb-task-badge'); + + badge.text(ids.length).toggle(ids.length > 0); + if (!ids.length) { dock.removeClass('show'); return; } + + var done = 0, total = 0, msg = ''; + ids.forEach(function (id) { + var t = _activeTasks[id]; + done += t.progress || 0; + total += t.total || 0; + if (!msg) { + var stage = taskStageLabel(t.stage); + msg = (t.label || '') + (stage ? ' ' + stage : ''); + } + }); + $('#task-dock-msg').text(msg || T().task_running); + $('#task-dock-count').text(ids.length + ' ' + T().task_dock_unit); + $('#task-dock-bar').css('width', (total > 0 ? Math.min(100, (done / total) * 100) : 0) + '%'); + dock.addClass('show'); +} + +function gotoTaskView() { + switchTab('tab-pool', { view: 'pool-view-manage' }); +} + +function renderTasks() { + onLangRender(renderTasks); + renderTaskDock(); + var area = $('#task-area'); + var ids = Object.keys(_activeTasks); + area.empty(); + if (!ids.length) return; + + ids.forEach(function (id) { + var t = _activeTasks[id]; + var finished = t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'; + var stateClass = t.status === 'failed' ? ' failed' + : (finished ? ' done' : ''); + var stateText = t.status === 'failed' ? T().task_failed + : (t.status === 'cancelled' ? T().task_cancelled + : (t.status === 'completed' ? T().task_completed : T().task_running)); + + var stageLabel = taskStageLabel(t.stage); + var done = t.stageDone || 0; + var total = t.stageTotal || 0; + var percent = total > 0 ? Math.min(100, Math.round(done / total * 100)) : 0; + + var meta = ''; + if (!finished && stageLabel) { + meta = escapeHtml(stageLabel); + if (total > 0) meta += ' ' + done + '/' + total; + if (t.rate > 0) { + meta += ' · ' + escapeHtml(fillTemplate(T().task_rate, Math.round(t.rate))); + meta += ' · ' + escapeHtml( + t.eta === null || t.eta === undefined + ? T().task_eta_unknown + : fillTemplate(T().task_eta, fmtDuration(t.eta)) + ); + } else if (done === 0 && t.inFlight > 0) { + meta += ' · ' + escapeHtml(fillTemplate(T().task_first_batch, t.inFlight)); + } + } else if (total > 0) { + meta = done + '/' + total; + } + + var counts = ''; + if (t.counts) { + var bits = []; + if (t.counts.saved) bits.push(fillTemplate(T().task_count_saved, t.counts.saved)); + if (t.counts.invalid) bits.push(fillTemplate(T().task_count_invalid, t.counts.invalid)); + if (t.counts.prescreened) bits.push(fillTemplate(T().task_count_prescreened, t.counts.prescreened)); + if (t.counts.failed) bits.push(fillTemplate(T().task_count_failed, t.counts.failed)); + if (bits.length) counts = bits.map(escapeHtml).join(' · '); + } + + area.append( + '
' + + '
' + + '' + + '' + escapeHtml(t.label) + '' + + '' + escapeHtml(stateText) + '' + + (finished ? '' : + '') + + '
' + + '
' + + (meta ? '
' + meta + '
' : '') + + (counts ? '
' + counts + '
' : '') + + '
'); + }); +} + +function cancelTask(id, btn) { + btnLoad($(btn), true); + poolRequest('POST', 'tasks/' + encodeURIComponent(id) + '/cancel', null, + function (r) { + toast(r.message || T().task_cancelled, r.success === false ? 'error' : 'success'); + pollTasks(); + }); +} + + +function loadPoolSchema(onReady) { + if (_poolSchema) { onReady(_poolSchema); return; } + $.get(appendToken('/api/pool/schema'), function (d) { + _poolSchema = d.groups || []; + onReady(_poolSchema); + }).fail(function (x) { toast(poolErrorText(x), 'error'); }); +} + +function refreshPoolFormLanguage() { + _poolSchema = null; + if (!_poolFormBaseline) return; + if (poolFormDirty()) return; + loadPoolSchema(function (groups) { + renderPoolForm(groups, collectPoolConfig()); + }); +} + +function applyPoolConfigToForm(pool, force) { + if (!force && poolFormDirty()) return; + loadPoolSchema(function (groups) { + renderPoolForm(groups, pool || {}); + }); +} + +function discardPoolChanges() { + if (!poolFormDirty()) { toast(T().no_changes, 'error'); return; } + loadConfig({ poolOnly: true, force: true }); +} + + +var _poolFormBaseline = null; + +function markPoolFormClean() { + _poolFormBaseline = collectPoolConfig(); + updatePoolDirtyState(); +} + +function poolFormDirty() { + if (!_poolFormBaseline) return false; + var current = collectPoolConfig(); + return Object.keys(current).some(function (key) { + return String(current[key]) !== String(_poolFormBaseline[key]); + }); +} + +function updatePoolDirtyState() { + var ready = !!_poolFormBaseline; + var dirty = ready && poolFormDirty(); + $('#pool-dirty-hint').toggle(dirty); + $('#btn-save-pool-config').toggleClass('pri', true).toggleClass('dirty', dirty); + + var saved = $('#pool-saved-hint'); + if (dirty) { + saved.hide(); + } else if (ready) { + if (!saved.children().length) saved.html(idleSavedHintHtml()).attr('data-idle-hint', '1'); + saved.show(); + } + setActionsIdle('#btn-save-pool-config', ready && !dirty); + + if (!ready) return; + $('#pool-form .settings-group').each(function () { + var groupDirty = false; + $(this).find('[data-pool-key]').each(function () { + var key = $(this).data('pool-key'); + var val = this.type === 'checkbox' ? (this.checked ? 'true' : 'false') : $(this).val(); + if (String(val) !== String(_poolFormBaseline[key])) groupDirty = true; + }); + var idx = $(this).data('group'); + $('#pool-settings-nav .settings-nav-item[data-group="' + idx + '"] .settings-dirty-dot').toggle(groupDirty); + }); +} + +$(document).on('input change', '#pool-form [data-pool-key]', updatePoolDirtyState); + +$(window).on('beforeunload', function (e) { + if (poolFormDirty()) { + e.preventDefault(); + e.originalEvent.returnValue = ''; + return ''; + } +}); + +var POOL_GROUP_ICONS = { + database: 'fa-database', + validator: 'fa-check-circle', + plugins: 'fa-puzzle-piece', + auto_revalidation: 'fa-refresh', + auto_cleanup: 'fa-recycle', + use_feedback: 'fa-comment-o', + write_queue: 'fa-tasks', + logging: 'fa-file-text', +}; + +function toggleAllAdvanced() { + var box = $('#pool-form details.adv-config'); + if (!box.length) return; + var expand = box.filter(':not([open])').length > 0; + box.prop('open', expand); + renderAdvancedToggle(expand); + if (typeof scheduleStickySync === 'function') scheduleStickySync(); +} + +function renderAdvancedToggle(expanded) { + $('#adv-toggle-icon').attr('class', 'fa ' + (expanded ? 'fa-chevron-up' : 'fa-chevron-down')); + $('#adv-toggle-text').text(expanded ? T().adv_collapse_all : T().adv_expand_all); +} + +function refreshAdvancedToggle() { + onLangRender(refreshAdvancedToggle); + var visible = $('#pool-form details.adv-config').filter(':visible'); + $('#adv-toggle-wrap').toggle(visible.length > 0); + if (!visible.length) return; + renderAdvancedToggle(visible.filter(':not([open])').length === 0); +} + +function renderPoolForm(groups, values) { + onLangRender(refreshPoolFormLanguage); + var container = $('#pool-form'); + var nav = $('#pool-settings-nav'); + container.empty(); + nav.empty(); + + groups.forEach(function (group, index) { + var title = group.title ? group.title : T().pool_group_basic; + var icon = POOL_GROUP_ICONS[group.id] || 'fa-cog'; + var basicBody = ''; + var advancedBody = ''; + var advancedCount = 0; + + group.fields.forEach(function (field) { + var value = values[field.key]; + if (value === undefined) value = ''; + var collapsed = field.tier === 'advanced' && !field.visible_when; + if (collapsed) { advancedBody += renderPoolField(field, value); advancedCount++; } + else basicBody += renderPoolField(field, value); + }); + + var body = basicBody; + if (advancedBody) { + body += '
' + + '' + escapeHtml(T().advanced_config_title) + + ' ' + advancedCount + '' + + '' + + '
' + advancedBody + '
' + + '
'; + } + + nav.append( + '
' + + '' + escapeHtml(title) + '' + + '' + + '
'); + + container.append( + '
' + + '
' + + '
' + + '' + escapeHtml(title) + '' + + (group.sub ? '' + escapeHtml(group.sub) + '' : '') + + '' + group.fields.length + '' + + '
' + + '
' + body + '
' + + '
' + + '
'); + }); + + markPoolFormClean(); + refreshAdvancedToggle(); + if (typeof syncFieldVisibility === 'function') syncFieldVisibility(); + if (typeof scheduleStickySync === 'function') scheduleStickySync(); + if (typeof initFieldTips === 'function') initFieldTips(); +} + +$(document).on('click', '#pool-settings-nav .settings-nav-item', function () { + showSettingsGroup('#pool-settings-nav', $(this).data('group')); + refreshAdvancedToggle(); +}); + +var POOL_TRUE_WORDS = ['1', 'true', 'yes', 'on']; + + +function renderPoolField(field, value) { + var key = escapeHtml(field.key); + var wide = ''; + var control; + + if (field.type === 'bool') { + var checked = POOL_TRUE_WORDS.indexOf(String(value).toLowerCase()) >= 0 ? ' checked' : ''; + control = ''; + } else if (field.type === 'int' || field.type === 'float') { + var step = field.type === 'float' ? ' step="0.1"' : ' step="1"'; + var low = typeof field.low === 'number' ? field.low : 0; + var bounds = ' min="' + low + '"'; + if (typeof field.high === 'number') { + bounds += ' max="' + field.high + '"'; + } + var numberInput = ''; + control = field.unit + ? '
' + numberInput + + '' + escapeHtml(field.unit) + '
' + : numberInput; + } else { + control = ''; + } + + var doc = field.doc ? plainDoc(field.doc) : ''; + var visibleWhen = field.visible_when + ? ' data-visible-when="' + escapeHtml(field.visible_when) + '"' : ''; + return '
' + + '
' + + '' + escapeHtml(field.title || field.key) + '' + + (doc ? '' : '') + + '
' + + control + + (field.hint ? '
' + escapeHtml(field.hint) + '
' : '') + + '
'; +} + +function collectPoolConfig() { + var updates = {}; + $('#pool-form').find('[data-pool-key]').each(function () { + var key = $(this).data('pool-key'); + var value = this.type === 'checkbox' ? (this.checked ? 'true' : 'false') : $(this).val(); + updates[key] = value; + }); + return updates; +} + +function poolConfigChanges() { + var current = collectPoolConfig(); + var changes = {}; + for (var key in current) { + if (String(current[key]) !== String(_poolFormBaseline ? _poolFormBaseline[key] : undefined)) { + changes[key] = current[key]; + } + } + return changes; +} + +function savePoolConfig() { + var updates = poolConfigChanges(); + if (!Object.keys(updates).length) { toast(T().no_changes, 'error'); return; } + submitConfig({ pool: updates }, $('#btn-save-pool-config'), markPoolFormClean); + loadHostIp(); +} + + +function openImportModal() { + $('#import-text').val(''); + updateImportCount(); + openModal('import-modal'); +} + +function updateImportCount() { + var n = $('#import-text').val().split('\n').filter(function (line) { + return line.trim(); + }).length; + $('#import-count').text(n ? fillTemplate(T().import_count, n) : ''); + $('#btn-import-submit').prop('disabled', !n); +} + +$(document).on('input', '#import-text', updateImportCount); + +function closeImportModal() { closeModal('import-modal'); } + +function submitImport() { + var lines = $('#import-text').val().split('\n') + .map(function (s) { return s.trim(); }) + .filter(function (s) { return s; }); + + if (!lines.length) { toast(T().import_empty, 'error'); return; } + + var btn = $('#btn-import-submit'); + btnLoad(btn, true); + + poolRequest('POST', 'import', { proxies: lines }, function (r) { + closeImportModal(); + toast(r.message, r.success === false ? 'error' : 'success'); + if (r.task_id) trackTask(r.task_id, T().task_import); + else loadProxies(); + }).always(function () { + btnLoad(btn, false); + }); +} + +function openExportModal() { + $('#export-count').text(totalCount ? fillTemplate(T().export_scope_count, totalCount) : ''); + openModal('export-modal'); +} +function closeExportModal() { closeModal('export-modal'); } + +function doExport(format) { + closeExportModal(); + var params = $.extend({ format: format }, collectFilterParams()); + var query = $.param(params); + window.location.href = appendToken('/api/pool/export?' + query); + toast(T().log_export_started, 'success'); +} + +function copyPoolApiLink() { + var params = $.extend({ format: 'text' }, collectFilterParams()); + var link = appendToken(location.origin + '/api/pool/random?' + $.param(params)); + + copyToClipboard(link, function () { + toast(T().api_link_copied, 'success'); + }); +} + +$(document).on('click', '#task-area [data-act="task-cancel"]', function () { + cancelTask($(this).attr('data-id'), this); +}); + +$(document).on('click', '#plugin-tbody [data-act]', function () { + var $el = $(this); + var name = $el.closest('tr').attr('data-plugin'); + + switch ($el.attr('data-act')) { + case 'plugin-toggle': + togglePlugin(name, this.checked, this); + break; + case 'plugin-interval': + setPluginInterval(name, parseInt($el.attr('data-minutes'), 10)); + break; + case 'plugin-test-url': + setPluginTestUrl(name, $el.attr('data-url') || ''); + break; + case 'plugin-validation': + openPluginSettingsModal(name, _pluginRows[name] || {}); + break; + case 'plugin-run': + runPlugin(name, $el); + break; + case 'plugin-reload': + reloadPlugin(name, $el); + break; + } +}); + + +function loadDbMaintain() { + onLangRender(loadDbMaintain); + poolRequest('GET', 'database/stats', null, function (d) { + renderDbStats(d.stats || {}); + }, { silent: true }).fail(function () { renderDbStats({}); }); + + $('#db-backup-list').html(skeletonBlock(3)); + $.get(appendToken('/api/pool/backups'), function (d) { + renderBackups(d.backups || []); + }).fail(function () { + $('#db-backup-list').empty().append( + '
' + escapeHtml(T().pool_unavailable_hint) + '
'); + }); +} + +function renderDbStats(s) { + var items = [ + { key: 'db_stat_total', val: s.total_proxies, cls: '' }, + { key: 'db_stat_valid', val: s.valid_proxies, cls: 'ok' }, + { key: 'db_stat_invalid', val: s.invalid_proxies, cls: 'bad' }, + { key: 'db_stat_size', val: s.db_size_mb, unit: 'MB', cls: '', digits: 1 } + ]; + var box = $('#db-stats-text').empty(); + items.forEach(function (it) { + var missing = it.val === undefined || it.val === null; + var shown = missing ? '--' + : (it.digits !== undefined ? Number(it.val).toFixed(it.digits) : it.val); + box.append( + '
' + + '' + escapeHtml(shown) + + (it.unit ? '' + escapeHtml(it.unit) + '' : '') + + '' + + '' + escapeHtml(T()[it.key]) + '' + + '
'); + }); +} + +function renderBackups(backups) { + var box = $('#db-backup-list'); + box.empty(); + if (!backups.length) { + box.append('
' + escapeHtml(T().db_no_backups) + '
'); + return; + } + + var table = $('
'); + var head = $(''); + head.find('tr') + .append('' + escapeHtml(T().db_backup_file) + + ' ' + backups.length + '') + .append('' + escapeHtml(T().db_backup_time) + '') + .append('' + escapeHtml(T().db_backup_size) + '') + .append(''); + table.append(head); + + var tbody = $(''); + backups.forEach(function (b, index) { + var sizeMb = (b.size / 1024 / 1024).toFixed(1); + var row = $(''); + row.append('' + escapeHtml(b.filename) + + (index === 0 ? ' ' + + escapeHtml(T().db_backup_latest) + '' : '') + ''); + row.append('' + escapeHtml(b.created_at) + ''); + row.append('' + sizeMb + ' MB'); + var btn = $(''); + btn.on('click', function () { restoreBackup(b.filename); }); + row.append($('').append(btn)); + tbody.append(row); + }); + table.append(tbody); + box.append(table); +} + +function restoreBackup(filename) { + showConfirm(T().db_restore_confirm_title, T().db_restore_confirm_body, function (ok) { + if (!ok) return; + + var perform = function () { + poolRequest('POST', 'backups/restore', { filename: filename }, function (r) { + toast(r.message || T().db_restore_success, 'success'); + loadDbMaintain(); + if (_poolRunning) controlPool('start'); + else updatePoolStatus(); + }); + }; + + if (_poolRunning) { + toast(T().db_restore_stopping, 'success'); + poolRequest('POST', 'stop', null, function () { perform(); }); + } else { + perform(); + } + }, { danger: true }); +} + +function optimizeDatabase() { + showConfirm(T().db_optimize_btn, T().db_optimize_confirm_body, function (ok) { + if (!ok) return; + btnLoad($('#btn-db-optimize'), true); + poolRequest('POST', 'database/optimize', null, function (r) { + toast(r.message || (T().db_optimize_btn + ' ✓'), 'success'); + loadDbMaintain(); + }).always(function () { btnLoad($('#btn-db-optimize'), false); }); + }); +} + + +var _geoPending = null; + +var _geoTask = null; +var _geoSubmitPending = false; + +function loadGeoStatus() { + onLangRender(loadGeoStatus); + poolRequest('GET', 'geo/status', null, renderGeoStatus, { silent: true }) + .fail(renderGeoStatusUnavailable); +} + +function renderGeoStatus(d) { + _geoPending = d.pending_recompute || 0; + + var pending = _geoPending > 0 + ? fillTemplate(T().geo_pending, _geoPending) + : T().geo_pending_zero; + var head = d.available ? T().geo_available : T().geo_unavailable; + $('#geo-status-text').text(head + ' · ' + pending); + + renderGeoFiles(d.files || []); + syncGeoControls(); +} + +function renderGeoStatusUnavailable() { + _geoPending = null; + $('#geo-status-text').text(T().geo_pool_down); + syncGeoControls(); +} + +function renderGeoFiles(files) { + var box = $('#geo-file-list'); + box.empty(); + if (!files.length) { + box.append('
' + escapeHtml(T().no_data) + '
'); + return; + } + + var table = $('
'); + var head = $(''); + head.find('tr') + .append('' + escapeHtml(T().geo_file_list_title) + '') + .append('' + escapeHtml(T().geo_file_size) + '') + .append('' + escapeHtml(T().geo_file_time) + ''); + table.append(head); + + var tbody = $(''); + files.forEach(function (f) { + var row = $(''); + row.append('' + escapeHtml(f.filename) + ''); + row.append(f.exists + ? '' + escapeHtml(fmtLogSize(f.size)) + '' + : '' + escapeHtml(T().geo_file_missing) + ''); + row.append('' + escapeHtml(f.modified_at || '--') + ''); + tbody.append(row); + }); + table.append(tbody); + box.append(table); +} + +function syncGeoControls() { + var ready = poolReady(); + var busy = !!_geoTask; + $('#btn-geo-update').prop('disabled', !ready || busy); + $('#btn-geo-recompute').prop('disabled', !ready || busy || _geoPending === 0); +} + +function startGeoUpdate() { + submitGeoTask('geo/update'); +} + +function startGeoRecompute() { + submitGeoTask('geo/recompute'); +} + +function submitGeoTask(path) { + if (_geoTask || _geoSubmitPending) return; + _geoSubmitPending = true; + + var btn = $(path.indexOf('recompute') >= 0 ? '#btn-geo-recompute' : '#btn-geo-update'); + btnLoad(btn, true); + poolRequest('POST', path, null, function (r) { + toast(r.message, 'success'); + if (!r.task_id) return; + _geoTask = { id: r.task_id }; + renderGeoTask(T().geo_task_queued, 'fa-spinner fa-spin'); + syncGeoControls(); + startGeoPolling(); + }).always(function () { + _geoSubmitPending = false; + btnLoad(btn, false); + syncGeoControls(); + }); +} + +function startGeoPolling() { + if (isPolling('geo')) return; + startPolling('geo', pollGeoTask, POOL_TASK_POLL_MS); + pollGeoTask(); +} + +function stopGeoPolling() { + stopPolling('geo'); +} + +function pollGeoTask() { + if (!_geoTask) { stopGeoPolling(); return; } + $.get(appendToken('/api/pool/tasks/' + _geoTask.id)) + .done(onGeoTaskUpdate) + .fail(function () { finishGeoTask(T().geo_task_lost, false); }); +} + +function onGeoTaskUpdate(task) { + if (task.status === 'running' || task.status === 'queued') { + renderGeoTask(task.message || T().geo_task_running, 'fa-spinner fa-spin'); + return; + } + var done = task.status === 'completed'; + finishGeoTask(task.message || (done ? T().task_completed : T().task_failed), done); +} + +function finishGeoTask(message, ok) { + stopGeoPolling(); + _geoTask = null; + renderGeoTask(message, ok ? 'fa-check-circle' : 'fa-exclamation-circle'); + toast(message, ok ? 'success' : 'error'); + syncGeoControls(); + loadGeoStatus(); +} + +function renderGeoTask(message, iconClass) { + $('#geo-progress-icon').attr('class', 'fa ' + iconClass); + $('#geo-progress-msg').text(message || ''); + $('#geo-progress-text').attr('title', message || '').show(); +} + + +var POOL_VIEW_STORAGE_KEY = 'proxycat-pool-view'; + +function switchPoolView(viewId) { + if (!$('#' + viewId).length) return; + $('.pool-view').removeClass('show'); + $('#' + viewId).addClass('show'); + safeSetItem(POOL_VIEW_STORAGE_KEY, viewId); + if (viewId === 'pool-view-db') { loadDbMaintain(); loadGeoStatus(); } + if (viewId === 'pool-view-settings') refreshAdvancedToggle(); + if (typeof scheduleStickySync === 'function') scheduleStickySync(); + syncSidebarActive('tab-pool', viewId); + renderViewHead(); + writeHash(); +} diff --git a/web/static/js/proxies.js b/web/static/js/proxies.js new file mode 100644 index 0000000..54f36fc --- /dev/null +++ b/web/static/js/proxies.js @@ -0,0 +1,919 @@ +/** + * 模块名称:池代理列表(proxies.js) + * 功能描述:代理列表的数据面:查询与渲染、筛选与高级筛选、排序与分页、行选中、批量验证与删除、 + * 单条收藏,以及行详情抽屉、右键菜单、表格键盘漫游、列宽调整与表格密度的接线。 + * 职责边界:负责:代理列表的查询、呈现与交互,以及列表侧发起的批量验证与删除;不负责:池状态、抓取插件与 + * 任务中心(提交后任务的进度与结果展示,pool.js),面板外壳与配置/日志页(app.js),浮层与表格通用组件(tables.js)。 + * 关键依赖:jQuery(全局 $ 及各 Ajax 与工具方法);common.js(转义、模板、存储、空行、时间格式化与 token 拼接); + * app.js(toast / 确认框 / 按钮加载态 / 剪贴板);i18n.js(T / lang / onLangRender); + * pool.js(poolRequest / poolErrorText / poolReady / trackTask / updatePoolStatus); + * tables.js(表格加载态与重试、行抽屉、右键菜单、键盘漫游)。 + * 已知限制: + * 1) 全部为全局变量与全局函数,与 app.js、pool.js 共用命名空间,重名会静默覆盖。 + * 2) 行选中:单击单选、Ctrl/⌘ 跳行多选、Shift 从锚点扩选、拖拽按起始行整段加选或取消,补发的 click 被抑制。 + * 3) 翻页、改筛选、改每页条数都会清空选中:选中集合按 id 记,跨页保留会删掉不可见的行。 + * 4) 列宽拖拽开始时必须锁死全部列宽并切 table-layout: fixed,只锁被拖的列会让其余列重排。 + * 5) 筛选存档只在页面加载后首次打开池页签时恢复一次,否则页签切换会重置未提交的条件。 + * 6) 详情抽屉与右键菜单的数据取自 pageProxies,仅对当前页的行有效,翻页即失效。 + * 7) 行点击与拖选必须限定选择器 tr[data-id]:骨架行与失败提示行没有 data-index,匹配它们会把 lastCheckedIndex 写成 NaN。 + */ + +var currentFilters = {}; +var sortField = 'health_score'; +var sortOrder = 'desc'; +var currentPage = 1; +var pageSize = 50; +var totalCount = 0; +var pageProxies = []; +var selectedIds = {}; +var lastCheckedIndex = -1; +var _filtersRestored = false; + +var FILTER_DEBOUNCE_MS = 300; + +var FILTER_STORAGE_KEY = 'proxycat-pool-filters-v3'; + +var FILTER_FIELDS = [ + ['#filter-protocol', 'protocol'], + ['#filter-status', 'status'], + ['#filter-favorite', 'is_favorite'], + ['#filter-anonymity', 'anonymity_level'], + ['#filter-source', 'source'], + ['#filter-region', 'region'], + ['#filter-ip', 'ip'], + ['#filter-min-delay', 'min_delay'], + ['#filter-max-delay', 'max_delay'], + ['#filter-min-health', 'min_health_score'], +]; + + +function _regionValue(value) { + var text = (value || '').trim(); + return (text && text !== '未知') ? text : ''; +} + +function regionText(p) { + var zh = _regionValue(p.region); + var en = _regionValue(p.region_en); + return lang === 'en' ? (en || zh) : (zh || en); +} + +function regionHint(p) { + var shown = regionText(p); + var other = lang === 'en' ? _regionValue(p.region) : _regionValue(p.region_en); + if (!other || other === shown) return shown; + return shown ? shown + ' / ' + other : other; +} + +var REGION_EN_FIELDS = ['country_en', 'province_en', 'city_en']; +var REGION_ZH_FIELDS = ['country', 'province', 'city']; + +function regionUntranslated(p) { + var shown = regionText(p); + if (!shown) return false; + var mine = lang === 'en' ? REGION_ZH_FIELDS : REGION_EN_FIELDS; + return mine.some(function (field, index) { + var other = lang === 'en' ? REGION_EN_FIELDS[index] : REGION_ZH_FIELDS[index]; + var value = (p[field] || '').trim(); + if (!value || value === '未知') return false; + return value === (p[other] || '').trim(); + }); +} + +function sortColumnForLanguage(field) { + if (field === 'region' && lang === 'en') return 'region_en'; + return field; +} + + +function loadProxies() { + onLangRender(loadProxies); + var params = $.extend({}, collectFilterParams(), { + page: currentPage, + page_size: pageSize, + sort_by: sortColumnForLanguage(sortField), + sort_order: sortOrder + }); + + updateSortIcons(); + beginTableLoad('#proxy-tbody', 8, 8); + + $.ajax({ url: appendToken('/api/pool/get?' + $.param(params)), timeout: 15000 }) + .done(function (list) { + clearTableAlert('#proxy-tbody'); + pageProxies = list || []; + renderProxyTable(pageProxies); + loadProxyCount(); + }) + .fail(function (x) { + showTableAlert('#proxy-tbody', 8, + poolErrorText(x) + T().sep_mid + T().stale_data_note, 'proxies'); + }); +} + +registerTableRetry('proxies', loadProxies); + +function loadProxyCount() { + var countParams = $.extend({}, collectFilterParams()); + $.get(appendToken('/api/pool/count?' + $.param(countParams))) + .done(function (d) { + totalCount = d.count || 0; + renderPager(); + }); +} + +function renderProxyTable(proxies) { + var tbody = $('#proxy-tbody'); + tbody.empty(); + $('#proxy-count-badge').text(totalCount + ' ' + T().items_unit).toggle(totalCount > 0); + + if (!proxies.length) { + tbody.append(emptyRow(8, T().no_proxies)); + updateSelectionUI(); + return; + } + + proxies.forEach(function (p, index) { + tbody.append(renderProxyRow(p, index)); + }); + + updateSelectionUI(); +} + +function renderProxyRow(p, index) { + var id = p.id; + var classes = []; + if (selectedIds[id]) classes.push('selected'); + if (p.is_valid === false) classes.push('invalid'); + var rowClass = classes.length ? ' class="' + classes.join(' ') + '"' : ''; + + var tooltip = T().col_source + T().sep_colon + (p.source_plugin || '--') + + '\n' + T().col_validated_at + T().sep_colon + (p.validated_at || '--'); + + var untranslated = regionUntranslated(p); + var regionNote = untranslated ? ' · ' + T().geo_region_untranslated : ''; + var regionMark = untranslated ? '*' : ''; + + var exitDiffers = p.exit_ip_differs === true; + var realIpCell = p.real_ip + ? '' + + '' + escapeHtml(p.real_ip) + '' + + '' + : '--'; + + return '' + + '' + protocolBadge(p.protocol) + invalidBadge(p) + '' + + '' + + '' + escapeHtml(p.ip) + ':' + p.port + '' + + realIpCell + + '' + + '' + escapeHtml(regionText(p) || '--') + '' + regionMark + '' + + '' + delayBadge(p.delay_ms) + '' + + '' + healthBadge(p.health_score) + '' + + '' + anonymityBadge(p.anonymity_level, p.anonymity_fallback) + '' + + '' + + '' + + '' + + '' + + ''; +} + +function copyHint(value) { + return T().click_to_copy + T().sep_colon + value; +} + +$(document).on('click', '#proxy-tbody [data-act]', function (e) { + var $el = $(this); + var $row = $el.closest('tr'); + var id = parseInt($row.attr('data-id'), 10); + + switch ($el.attr('data-act')) { + case 'copy': + copyProxy($el.attr('data-copy')); + break; + case 'favorite': + toggleFavorite(id); + break; + case 'detail': + openProxyDrawer(id); + break; + } +}); + +$(document).on('contextmenu', '#proxy-tbody tr[data-id]', function (e) { + var id = parseInt($(this).attr('data-id'), 10); + if (!proxyById(id)) return; + e.preventDefault(); + openProxyRowMenu(id, e.clientX, e.clientY); +}); + +$(document).on('click', '#proxy-tbody tr[data-id]', function (e) { + if (_dragMoved) { _dragMoved = false; return; } + if ($(e.target).closest('[data-act]').length) return; + var sel = window.getSelection && window.getSelection(); + if (sel && sel.toString().length > 0) return; + + var $row = $(this); + toggleRow( + parseInt($row.attr('data-id'), 10), + parseInt($row.attr('data-index'), 10), + e + ); +}); + +var _dragSelecting = false; +var _dragMoved = false; +var _dragStartIndex = -1; +var _dragStartOn = false; + +$(document).on('mousedown', '#proxy-tbody tr[data-id]', function (e) { + if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey) return; + if ($(e.target).closest('[data-act]').length) return; + + _dragSelecting = true; + _dragMoved = false; + _dragStartIndex = parseInt($(this).attr('data-index'), 10); + var startId = parseInt($(this).attr('data-id'), 10); + _dragStartOn = !selectedIds[startId]; + e.preventDefault(); +}); + +$(document).on('mouseenter', '#proxy-tbody tr[data-id]', function () { + if (!_dragSelecting) return; + _dragMoved = true; + + var index = parseInt($(this).attr('data-index'), 10); + var lo = Math.min(_dragStartIndex, index), hi = Math.max(_dragStartIndex, index); + for (var i = lo; i <= hi; i++) { + var p = pageProxies[i]; + if (!p) continue; + if (_dragStartOn) selectedIds[p.id] = true; + else delete selectedIds[p.id]; + } + lastCheckedIndex = index; + syncRowSelection(); +}); + +$(document).on('mouseup', function () { + _dragSelecting = false; +}); + + +function protocolBadge(protocol) { + var text = (protocol || '').toUpperCase(); + return '' + escapeHtml(text) + ''; +} + +function invalidBadge(p) { + if (p.is_valid) return ''; + return '' + + escapeHtml(T().proxy_invalid_badge) + ''; +} + +function delayBadge(delay) { + if (delay === null || delay === undefined) return '--'; + var ms = Math.round(delay); + var cls = ms < 500 ? 'fast' : (ms < 2000 ? 'mid' : 'slow'); + return '' + ms + 'ms'; +} + +function healthBadge(score) { + if (!score) return '--'; + var cls = score >= 70 ? 'fast' : (score >= 40 ? 'mid' : 'slow'); + return '' + Math.round(score) + ''; +} + +function anonymityBadge(level, fallback) { + var names = { + elite: [T().anon_elite, 'badge-elite'], + anonymous: [T().anon_anonymous, 'badge-anonymous'], + transparent: [T().anon_transparent, 'badge-transparent'], + unverified: [T().anon_unverified, 'badge-unverified'] + }; + var entry = names[level]; + if (!entry) { + entry = [level || '--', '']; + } + var cls = 'badge ' + entry[1] + (fallback ? ' badge-fallback' : ''); + var hint = fallback ? ' title="' + escapeHtml(T().anon_fallback_hint) + '"' : ''; + return '' + escapeHtml(entry[0]) + ''; +} + + +function proxyById(id) { + for (var i = 0; i < pageProxies.length; i++) { + if (pageProxies[i].id === id) return pageProxies[i]; + } + return null; +} + +function _text(v) { + return (v === undefined || v === null || v === '') ? '' : String(v); +} + +function _rate(v) { + if (v === undefined || v === null || v === '') return ''; + return (Number(v) * 100).toFixed(1) + '%'; +} + +function openProxyDrawer(id) { + var p = proxyById(id); + if (!p) return; + + var regionEn = [p.country_en, p.province_en, p.city_en].filter(Boolean).join(' / '); + var supports = [p.supports_https ? 'HTTPS' : '', p.supports_http ? 'HTTP' : ''] + .filter(Boolean).join(' · '); + + openRowDrawer({ + title: p.ip + ':' + p.port, + subtitle: (p.protocol || '').toUpperCase() + + (p.source_plugin ? ' · ' + p.source_plugin : ''), + fields: drawerSections([ + [T().drawer_group_conn, [ + { label: T().col_address, value: p.proxy_url, mono: true, copy: true }, + { label: T().col_real_ip, value: _text(p.real_ip), mono: true }, + { label: T().drawer_username, value: _text(p.username), mono: true, copy: true }, + { label: T().drawer_password, value: _text(p.password), mono: true }, + { label: T().col_region, value: _text(regionText(p)) }, + { label: T().drawer_region_en, value: regionEn }, + { label: T().drawer_supports, value: supports }, + { label: T().col_source, value: _text(p.source_plugin) } + ]], + [T().drawer_group_quality, [ + { label: T().col_delay, value: p.delay_ms === null || p.delay_ms === undefined ? '' : Math.round(p.delay_ms) + ' ms', mono: true }, + { label: T().col_health, value: p.health_score === null || p.health_score === undefined ? '' : String(Math.round(p.health_score)), mono: true }, + { label: T().col_anonymity, html: anonymityBadge(p.anonymity_level, p.anonymity_fallback) }, + { label: T().drawer_success_rate, value: _rate(p.success_rate), mono: true }, + { label: T().drawer_avg_delay, value: p.avg_delay_ms ? Math.round(p.avg_delay_ms) + ' ms' : '', mono: true }, + { label: T().drawer_probe_rate, value: _rate(p.probe_success_rate), mono: true }, + { label: T().drawer_total_checks, value: p.total_checks ? String(p.total_checks) : '', mono: true }, + { label: T().drawer_favorite, value: p.is_favorite ? T().yes : T().no } + ]], + [T().drawer_group_time, [ + { label: T().col_validated_at, value: p.validated_at ? fmtTime(p.validated_at) : '' }, + { label: T().drawer_quality_at, value: p.quality_assessed_at ? fmtTime(p.quality_assessed_at) : '' } + ]] + ]), + actions: [ + { label: T().validate_selected_btn, icon: 'fa-check', + onClick: function () { validateProxyRow(id, 'liveness'); } }, + { label: T().validate_full_btn, icon: 'fa-stethoscope', + onClick: function () { validateProxyRow(id, 'full'); } }, + { label: p.is_favorite ? T().drawer_unfavorite : T().favorite_btn, + icon: p.is_favorite ? 'fa-star' : 'fa-star-o', + onClick: function () { toggleFavorite(id); } }, + { label: T().delete_selected_btn, icon: 'fa-trash', danger: true, + onClick: function () { deleteProxyRow(id); } } + ] + }); +} + +function openProxyRowMenu(id, x, y) { + var p = proxyById(id); + if (!p) return; + + openContextMenu(x, y, [ + { label: T().detail_btn, icon: 'fa-ellipsis-h', + onClick: function () { openProxyDrawer(id); } }, + { label: T().copy_addr_btn, icon: 'fa-copy', + onClick: function () { copyProxy(p.proxy_url); } }, + { label: T().ctx_copy_curl, icon: 'fa-terminal', + onClick: function () { copyToClipboard(copyCurlCommand(p.proxy_url)); } }, + { separator: true }, + { label: p.is_favorite ? T().drawer_unfavorite : T().favorite_btn, + icon: p.is_favorite ? 'fa-star' : 'fa-star-o', + onClick: function () { toggleFavorite(id); } }, + { label: T().validate_selected_btn, icon: 'fa-check', + onClick: function () { validateProxyRow(id, 'liveness'); } }, + { label: T().validate_full_btn, icon: 'fa-stethoscope', + onClick: function () { validateProxyRow(id, 'full'); } }, + { separator: true }, + { label: T().delete_selected_btn, icon: 'fa-trash', danger: true, + onClick: function () { deleteProxyRow(id); } } + ]); +} + +function copyCurlCommand(proxyUrl) { + var target = $('input[name="test_url"]').val() || 'https://www.baidu.com'; + return 'curl -x ' + proxyUrl + ' -sS -o /dev/null -w "%{http_code}\\n" ' + target; +} + +function validateProxyRow(id, mode) { + if (!poolReady()) return; + poolRequest('POST', 'proxies/batch/validate', { proxy_ids: [id], mode: mode }, function (r) { + toast(r.message, 'success'); + if (r.task_id) trackTask(r.task_id, T().task_validate_selected); + }); +} + +function deleteProxyRow(id) { + var p = proxyById(id); + showConfirm(T().delete_selected_btn, + fillTemplate(T().confirm_delete_proxy, p ? (p.ip + ':' + p.port) : id), + function (ok) { + if (!ok) return; + poolRequest('DELETE', 'proxies/batch/delete', { proxy_ids: [id] }, function (r) { + toast(r.message, 'success'); + delete selectedIds[id]; + loadProxies(); + updatePoolStatus(); + }); + }, { danger: true }); +} + +function initProxyTableKeyboard() { + attachTableKeyboard('#proxy-table', { + onToggle: function (i) { + var p = pageProxies[i]; + if (p) toggleRow(p.id, i, {}); + }, + onExtend: function (i) { + if (lastCheckedIndex < 0) { lastCheckedIndex = i; return; } + selectRange(Math.min(lastCheckedIndex, i), Math.max(lastCheckedIndex, i), true); + }, + onSelectAll: function () { + toggleSelectAll(true); + $('#select-all').prop('checked', true); + }, + onActivate: function (i) { + if (pageProxies[i]) openProxyDrawer(pageProxies[i].id); + }, + onClear: function () { + selectedIds = {}; + lastCheckedIndex = -1; + syncRowSelection(); + updateSelectionUI(); + } + }); +} + + +var COL_WIDTH_STORAGE_KEY = 'proxycat-pool-cols-v1'; + +var _colDrag = null; + +var COL_MIN_WIDTH = 48; +var COL_MAX_WIDTH = 900; + +function lockColumnWidths(table) { + var cells = table.tHead.rows[0].cells; + if (!table.classList.contains('cols-locked')) { + var widths = []; + for (var i = 0; i < cells.length; i++) { + widths.push(cells[i].getBoundingClientRect().width); + } + if (!widths[0]) return cells; + for (var j = 0; j < cells.length; j++) { + cells[j].style.width = widths[j] + 'px'; + } + table.classList.add('cols-locked'); + } + return cells; +} + +function saveColumnWidths() { + var table = document.getElementById('proxy-table'); + if (!table || !table.tHead) return; + + var cells = table.tHead.rows[0].cells; + var widths = {}; + for (var i = 0; i < cells.length; i++) { + widths[cells[i].getAttribute('data-col')] = Math.round(cells[i].getBoundingClientRect().width); + } + safeSetJSON(COL_WIDTH_STORAGE_KEY, widths); +} + +function restoreColumnWidths() { + var table = document.getElementById('proxy-table'); + var saved = safeGetJSON(COL_WIDTH_STORAGE_KEY, null); + if (!table || !table.tHead || !saved) return; + + var cells = table.tHead.rows[0].cells; + var applied = false; + for (var i = 0; i < cells.length; i++) { + var width = saved[cells[i].getAttribute('data-col')]; + if (typeof width === 'number' && width >= COL_MIN_WIDTH) { + cells[i].style.width = width + 'px'; + applied = true; + } + } + if (applied) table.classList.add('cols-locked'); +} + +var DENSITY_STORAGE_KEY = 'proxycat-density'; + +function setTableDensity(mode) { + var compact = mode === 'compact'; + $('body').attr('data-density', compact ? 'compact' : 'cozy'); + safeSetItem(DENSITY_STORAGE_KEY, compact ? 'compact' : 'cozy'); + $('#density-btn i').attr('class', compact ? 'fa fa-th-large' : 'fa fa-th-list'); +} + +function toggleTableDensity() { + setTableDensity($('body').attr('data-density') === 'compact' ? 'cozy' : 'compact'); +} + +function initTableDensity() { + setTableDensity(safeGetItem(DENSITY_STORAGE_KEY) || 'cozy'); +} + + +function resetColumnWidths() { + var table = document.getElementById('proxy-table'); + if (table && table.tHead) { + var cells = table.tHead.rows[0].cells; + for (var i = 0; i < cells.length; i++) { + cells[i].style.width = ''; + } + table.classList.remove('cols-locked'); + } + safeSetJSON(COL_WIDTH_STORAGE_KEY, null); +} + +$('#proxy-table .col-resize').on('mousedown', function (e) { + e.preventDefault(); + e.stopPropagation(); + + var table = document.getElementById('proxy-table'); + var cells = lockColumnWidths(table); + var index = $(this).closest('th').index(); + + _colDrag = { + cell: cells[index], + startX: e.pageX, + startWidth: cells[index].getBoundingClientRect().width + }; + $(this).addClass('dragging'); + $('body').addClass('col-resizing'); +}); + +$('#proxy-table .col-resize').on('click', function (e) { + e.stopPropagation(); +}); + +$(document).on('mousemove', function (e) { + if (!_colDrag) return; + if (e.buttons === 0) { endColumnDrag(); return; } + var width = _colDrag.startWidth + (e.pageX - _colDrag.startX); + width = Math.min(COL_MAX_WIDTH, Math.max(COL_MIN_WIDTH, width)); + _colDrag.cell.style.width = width + 'px'; +}); + +$(document).on('mouseup', endColumnDrag); + +function endColumnDrag() { + if (!_colDrag) return; + _colDrag = null; + $('#proxy-table .col-resize').removeClass('dragging'); + $('body').removeClass('col-resizing'); + saveColumnWidths(); +} + +restoreColumnWidths(); + + +function collectFilterParams() { + currentFilters = {}; + + var protocol = $('#filter-protocol').val(); + var status = $('#filter-status').val(); + var favorite = $('#filter-favorite').val(); + var anonymity = $('#filter-anonymity').val(); + var source = $('#filter-source').val(); + var region = $('#filter-region').val(); + var ip = $('#filter-ip').val(); + var minDelay = $('#filter-min-delay').val(); + var maxDelay = $('#filter-max-delay').val(); + var minHealth = $('#filter-min-health').val(); + + if (protocol) currentFilters.protocol = protocol; + if (status) currentFilters.status = status; + if (favorite) currentFilters.is_favorite = favorite; + if (anonymity) currentFilters.anonymity_level = anonymity; + if (source) currentFilters.source = source; + if (region) currentFilters.region = region; + if (ip) currentFilters.ip = ip; + if (minDelay !== '') currentFilters.min_delay = minDelay; + if (maxDelay !== '') currentFilters.max_delay = maxDelay; + if (minHealth !== '') currentFilters.min_health_score = minHealth; + + return currentFilters; +} + +function applyFilters() { + currentPage = 1; + selectedIds = {}; + updateSelectionUI(); + saveFiltersToStorage(); + loadProxies(); +} + + +function saveFiltersToStorage() { + safeSetJSON(FILTER_STORAGE_KEY, { + filters: collectFilterParams(), + sortField: sortField, + sortOrder: sortOrder, + pageSize: pageSize + }); +} + +function readSavedFilters() { + var saved = safeGetJSON(FILTER_STORAGE_KEY, null); + return saved && typeof saved === 'object' ? saved : null; +} + +function restoreFiltersFromStorage() { + if (_filtersRestored) return; + _filtersRestored = true; + + var saved = readSavedFilters(); + if (saved && saved.pageSize) pageSize = parseInt(saved.pageSize, 10) || pageSize; + $('#page-size').val(pageSize); + if (!saved) return; + + var filters = saved.filters || {}; + FILTER_FIELDS.forEach(function (entry) { + if (entry[1] === 'source') return; + if (filters[entry[1]] !== undefined) $(entry[0]).val(filters[entry[1]]); + }); + + if (saved.sortField) { sortField = saved.sortField; sortOrder = saved.sortOrder || 'asc'; } + + var advanced = ['region', 'ip', 'min_delay', 'max_delay', 'min_health_score']; + if (advanced.some(function (k) { return filters[k]; })) { + $('#advanced-filters').show(); + $('#advanced-icon').removeClass('fa-chevron-down').addClass('fa-chevron-up'); + } + + updateSortIcons(); +} + +var _filterTimer = null; + +function onFilterInput() { + clearTimeout(_filterTimer); + _filterTimer = setTimeout(applyFilters, FILTER_DEBOUNCE_MS); +} + +function resetFilters() { + $('#filter-protocol, #filter-status, #filter-favorite, #filter-anonymity, #filter-source').val(''); + $('#filter-region, #filter-ip, #filter-min-delay, #filter-max-delay, #filter-min-health').val(''); + applyFilters(); +} + +function toggleAdvancedFilters() { + var box = $('#advanced-filters'); + var hidden = box.is(':hidden'); + box.toggle(hidden); + $('#advanced-icon').toggleClass('fa-chevron-down', !hidden).toggleClass('fa-chevron-up', hidden); +} + +function loadSources() { + onLangRender(loadSources); + $.get(appendToken('/api/pool/sources'), function (d) { + var select = $('#filter-source'); + var current = select.val(); + select.empty().append(''); + (d.sources || []).forEach(function (name) { + select.append(''); + }); + + var saved = readSavedFilters(); + var savedSource = saved && saved.filters ? saved.filters.source : ''; + select.val(current || savedSource || ''); + }); +} + + +function sortBy(field) { + if (sortField === field) { + sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + sortField = field; + sortOrder = 'asc'; + } + updateSortIcons(); + currentPage = 1; + saveFiltersToStorage(); + loadProxies(); +} + +function updateSortIcons() { + $('.sort-icon').removeClass('active fa-sort-asc fa-sort-desc').addClass('fa-sort'); + var icon = $('#sort-' + sortField); + icon.addClass('active').removeClass('fa-sort') + .addClass(sortOrder === 'asc' ? 'fa-sort-asc' : 'fa-sort-desc'); +} + +function totalPages() { + return Math.max(1, Math.ceil(totalCount / pageSize)); +} + +function gotoPage(page) { + var last = totalPages(); + var target = page === -1 || page === '-1' ? last : parseInt(page, 10); + + if (isNaN(target)) { renderPager(); return; } + target = Math.min(Math.max(1, target), last); + + if (target === currentPage) { renderPager(); return; } + + currentPage = target; + selectedIds = {}; + updateSelectionUI(); + loadProxies(); +} + +function nextPage() { gotoPage(currentPage + 1); } + +function previousPage() { gotoPage(currentPage - 1); } + +function changePageSize() { + pageSize = parseInt($('#page-size').val(), 10) || 50; + currentPage = 1; + selectedIds = {}; + updateSelectionUI(); + saveFiltersToStorage(); + loadProxies(); +} + +function renderPager() { + var last = totalPages(); + var start = totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1; + var end = Math.min(currentPage * pageSize, totalCount); + + var jump = $('#page-jump'); + if (!jump.is(':focus')) jump.val(currentPage).attr('max', last); + + $('#total-count').text(last <= 1 + ? fillTemplate(T().pager_total, totalCount) + : fillTemplate(T().pager_range, start, end) + T().sep_mid + + fillTemplate(T().pager_total, totalCount) + T().sep_mid + + fillTemplate(T().page_of, currentPage, last)); + + $('#prev-btn, #first-btn').prop('disabled', currentPage <= 1); + $('#next-btn, #last-btn').prop('disabled', currentPage >= last); + $('#proxy-count-badge').text(totalCount + ' ' + T().items_unit).toggle(totalCount > 0); +} + + +function toggleRow(id, index, event) { + if (event) event.stopPropagation(); + + if (event && event.shiftKey && lastCheckedIndex >= 0) { + selectRange(lastCheckedIndex, index, !selectedIds[id]); + } else if (event && (event.ctrlKey || event.metaKey)) { + if (selectedIds[id]) delete selectedIds[id]; + else selectedIds[id] = true; + } else if (selectedIds[id]) { + delete selectedIds[id]; + } else { + Object.keys(selectedIds).forEach(function (key) { delete selectedIds[key]; }); + selectedIds[id] = true; + } + + lastCheckedIndex = index; + syncRowSelection(); +} + +function selectRange(from, to, select) { + var lo = Math.min(from, to), hi = Math.max(from, to); + for (var i = lo; i <= hi; i++) { + var p = pageProxies[i]; + if (!p) continue; + if (select) selectedIds[p.id] = true; + else delete selectedIds[p.id]; + } +} + +function toggleSelectAll(checked) { + if (checked) { + pageProxies.forEach(function (p) { selectedIds[p.id] = true; }); + } else { + pageProxies.forEach(function (p) { delete selectedIds[p.id]; }); + } + syncRowSelection(); +} + +function syncRowSelection() { + $('#proxy-tbody tr[data-id]').each(function () { + $(this).toggleClass('selected', !!selectedIds[$(this).data('id')]); + }); + updateSelectionUI(); +} + +function updateSelectionUI() { + var count = Object.keys(selectedIds).length; + $('#selected-count').text(count ? fillTemplate(T().selected_count, count) : ''); + + var barOpen = count > 0; + $('#batch-bar').toggle(barOpen); + $('body').toggleClass('batch-bar-open', barOpen); + + var canAct = count > 0 && poolReady(); + $('#btn-batch-validate, #btn-batch-validate-full, #btn-batch-delete') + .prop('disabled', !canAct); + + var allSelected = pageProxies.length > 0 && pageProxies.every(function (p) { return selectedIds[p.id]; }); + $('#select-all').prop('checked', allSelected); +} + +function validateSelected(mode) { + var ids = Object.keys(selectedIds).map(Number); + if (!ids.length) return; + var btn = $(mode === 'full' ? '#btn-batch-validate-full' : '#btn-batch-validate'); + btnLoad(btn, true); + poolRequest('POST', 'proxies/batch/validate', { proxy_ids: ids, mode: mode }, function (r) { + toast(r.message, r.success === false ? 'error' : 'success'); + if (r.task_id) { + trackTask(r.task_id, mode === 'full' ? T().task_validate_full : T().task_validate_selected); + } + }).always(function () { btnLoad(btn, false); }); +} + +function deleteSelected() { + var ids = Object.keys(selectedIds).map(Number); + if (!ids.length) return; + + showConfirm(T().delete_selected_btn, + fillTemplate(T().confirm_delete_selected, ids.length), function (ok) { + if (!ok) return; + poolRequest('DELETE', 'proxies/batch/delete', { proxy_ids: ids }, function (r) { + toast(r.message, 'success'); + selectedIds = {}; + updateSelectionUI(); + loadProxies(); + updatePoolStatus(); + }); + }, { danger: true }); +} + +function deleteAllInvalid() { + if (!poolReady()) return; + + $.get(appendToken('/api/pool/count?status=invalid')) + .done(function (d) { + var count = d.count || 0; + if (count === 0) { toast(T().no_invalid_proxies, 'success'); return; } + + showConfirm( + T().delete_invalid_btn, + fillTemplate(T().confirm_delete_invalid_count, count), + function (ok) { if (ok) performDeleteInvalid(); }, + { danger: true } + ); + }) + .fail(function (x) { toast(poolErrorText(x), 'error'); }); +} + +function performDeleteInvalid() { + poolRequest('DELETE', 'delete/invalid', null, function (r) { + toast(r.message, 'success'); + selectedIds = {}; + updateSelectionUI(); + loadProxies(); + updatePoolStatus(); + }); +} + + +function toggleFavorite(id) { + var p = proxyById(id); + var next = p ? !p.is_favorite : true; + paintFavorite(id, next); + poolRequest('POST', 'proxies/' + id + '/favorite', null, function (r) { + var on = r.is_favorite === undefined ? next : !!r.is_favorite; + paintFavorite(id, on); + toast(r.message, r.success === false ? 'error' : 'success'); + }).fail(function () { + paintFavorite(id, !next); + }); +} + +function paintFavorite(id, on) { + var p = proxyById(id); + if (p) p.is_favorite = on; + var $btn = $('#proxy-tbody tr[data-id="' + id + '"] [data-act="favorite"]'); + if (!$btn.length) return; + $btn.toggleClass('on', on); + $btn.find('i').toggleClass('fa-star', on).toggleClass('fa-star-o', !on); +} + +function copyProxy(text) { + if (!text) return; + + copyToClipboard(text, function () { + toast(T().addr_copied + T().sep_colon + text, 'success'); + }); +} diff --git a/web/static/js/tables.js b/web/static/js/tables.js new file mode 100644 index 0000000..aa4da59 --- /dev/null +++ b/web/static/js/tables.js @@ -0,0 +1,396 @@ +/** + * 模块名称:浮层与表格增强组件(tables.js) + * 功能描述:与具体页面无关的浮层与表格增强:行详情抽屉、右键菜单、说明气泡、表格漫游键盘,以及加载中/加载失败占位。 + * 职责边界:负责:浮层的摆放与键盘交互、表格占位渲染、说明气泡的可键盘化; + * 不负责:取数、业务字段与表格的筛选排序分页、选中语义,由 proxies.js 与 app.js 负责。 + * 关键依赖:jQuery;common.js 的 escapeHtml、skeletonRows、tableAlertHtml;i18n.js 的 T()、onLangRender; + * app.js 的 _overlayOpen、_overlayClosed;palette.js 的 isCommandPaletteOpen;index.html 的 + * #row-drawer、#drawer-scrim、#ctx-menu、#tip-pop 容器。 + * 已知限制: + * 1. 抽屉、右键菜单、说明气泡均为单例,开新的会顶掉旧的,不排队、不叠加。 + * 2. 说明气泡的文案现读现用;显示期间原生 title 被摘走,此时别处读 title 会读到空。 + * 3. fields 的 value 一律按纯文本转义,html 字段由调用方保证已转义,两者都写时 html 优先。 + * 4. 判断字段是否为空的口径在 drawerSections 与 openRowDrawer 两处必须一致,只给 html 的字段不能被当成空值。 + * 5. 骨架行只在表格没有真实行时铺;键盘漫游只认 tr[data-id],失败提示行不参与。 + * 6. 抽屉不锁 body 滚动;抽屉的 Esc 让给命令面板与右键菜单,右键菜单与说明气泡不避让上层,一次 Esc 可能关掉多层。 + * 7. 右键菜单要先加 show 再量尺寸:display: none 时 outerWidth/Height 为 0,越界翻转失效。 + */ +var _ctxFocus = -1; + +function drawerSections(groups) { + var out = []; + (groups || []).forEach(function (entry) { + var kept = (entry[1] || []).filter(function (f) { + return f.html !== undefined || + (f.value !== undefined && f.value !== null && f.value !== ''); + }); + if (!kept.length) return; + out.push({ section: entry[0] }); + out = out.concat(kept); + }); + return out; +} + +function openRowDrawer(spec) { + var s = spec || {}; + + $('#drawer-title').text(s.title || ''); + $('#drawer-sub').text(s.subtitle || '').toggle(!!s.subtitle); + + var body = $('#drawer-body').empty(); + (s.fields || []).forEach(function (f) { + if (f.section !== undefined) { + body.append($('
').text(f.section)); + return; + } + if (f.html === undefined && + (f.value === undefined || f.value === null || f.value === '')) return; + var inner = f.html !== undefined ? f.html : escapeHtml(f.value) + (f.suffix || ''); + var cls = 'drawer-val' + (f.mono ? ' mono' : '') + (f.copy ? ' copyable' : ''); + var val = $('
').attr('class', cls).html(inner); + if (f.copy) val.attr('data-copy', f.value).attr('title', T().click_to_copy); + body.append( + $('
') + .append($('').text(f.label || '')) + .append(val)); + }); + + var ft = $('#drawer-ft').empty(); + var actions = s.actions || []; + actions.forEach(function (a) { + var btn = $('') + .attr('class', 'btn sm' + (a.danger ? ' bad' : '')) + .html(' ' + escapeHtml(a.label)); + btn.on('click', function () { + closeRowDrawer(); + if (a.onClick) a.onClick(); + }); + ft.append(btn); + }); + ft.toggle(actions.length > 0); + + $('#drawer-scrim').addClass('show'); + $('#row-drawer').addClass('show'); + if (typeof _overlayOpen === 'function') _overlayOpen('#row-drawer', $('#row-drawer .icon-btn'), false); +} + +function closeRowDrawer() { + $('#row-drawer').removeClass('show'); + $('#drawer-scrim').removeClass('show'); + if (typeof _overlayClosed === 'function') _overlayClosed(); +} + +$(document).on('keydown', function (e) { + if (e.key !== 'Escape') return; + if (typeof isCommandPaletteOpen === 'function' && isCommandPaletteOpen()) return; + if (isContextMenuOpen()) return; + if ($('#row-drawer').hasClass('show')) closeRowDrawer(); +}); + +function openContextMenu(x, y, items) { + var menu = $('#ctx-menu').empty(); + _ctxFocus = -1; + + (items || []).forEach(function (it) { + if (!it) return; + if (it.separator) { menu.append('
'); return; } + var btn = $('') + .attr('class', 'ctx-item' + (it.danger ? ' danger' : '')) + .html(' ' + escapeHtml(it.label)); + btn.on('click', function () { + closeContextMenu(); + if (it.onClick) it.onClick(); + }); + menu.append(btn); + }); + + menu.addClass('show'); + var w = menu.outerWidth(), h = menu.outerHeight(); + var left = x, top = y; + if (left + w > window.innerWidth - 8) left = x - w; + if (top + h > window.innerHeight - 8) top = y - h; + menu.css({ + left: Math.max(8, left) + 'px', + top: Math.max(8, top) + 'px' + }); +} + +function closeContextMenu() { + $('#ctx-menu').removeClass('show').empty(); + _ctxFocus = -1; +} + +function isContextMenuOpen() { + return $('#ctx-menu').hasClass('show'); +} + +function moveContextFocus(delta) { + var items = $('#ctx-menu .ctx-item'); + if (!items.length) return; + _ctxFocus = (_ctxFocus + delta + items.length) % items.length; + items.removeClass('focus').eq(_ctxFocus).addClass('focus'); +} + +$(document).on('keydown', function (e) { + if (!isContextMenuOpen()) return; + if (e.key === 'Escape') { closeContextMenu(); e.stopPropagation(); return; } + if (e.key === 'ArrowDown') { moveContextFocus(1); e.preventDefault(); return; } + if (e.key === 'ArrowUp') { moveContextFocus(-1); e.preventDefault(); return; } + if (e.key === 'Enter') { + var focused = $('#ctx-menu .ctx-item.focus'); + if (focused.length) { focused.trigger('click'); e.preventDefault(); } + } +}); + +$(document).on('mousedown', function (e) { + if (isContextMenuOpen() && !$(e.target).closest('#ctx-menu').length) closeContextMenu(); +}); + +function attachTableKeyboard(table, handlers) { + var $t = $(table); + if (!$t.length) return; + + var h = handlers || {}; + $t.attr('tabindex', '0'); + + function rows() { + return $t.find('tbody tr[data-id]'); + } + + function focusRow(index) { + var list = rows(); + if (!list.length) return -1; + var i = Math.min(list.length - 1, Math.max(0, index)); + list.removeClass('row-focus').eq(i).addClass('row-focus'); + list[i].scrollIntoView({ block: 'nearest' }); + return i; + } + + function focusedIndex() { + var list = rows(); + return list.index(list.filter('.row-focus')); + } + + $t.on('keydown', function (e) { + if (e.target !== this) return; + var list = rows(); + if (!list.length) return; + var cur = focusedIndex(); + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + var next = focusRow(cur < 0 + ? (e.key === 'ArrowDown' ? 0 : list.length - 1) + : cur + (e.key === 'ArrowDown' ? 1 : -1)); + if (e.shiftKey && h.onExtend) h.onExtend(next); + return; + } + if (e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + if (cur >= 0 && h.onToggle) h.onToggle(cur); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { + e.preventDefault(); + if (h.onSelectAll) h.onSelectAll(); + return; + } + if (e.key === 'Enter') { + if (cur >= 0 && h.onActivate) { e.preventDefault(); h.onActivate(cur); } + return; + } + if (e.key === 'Escape') { + $t.find('tbody tr').removeClass('row-focus'); + if (h.onClear) h.onClear(); + } + }); + + $t.on('click', 'tbody tr[data-id]', function () { + $t.find('tbody tr').removeClass('row-focus'); + $(this).addClass('row-focus'); + }); +} + + +var _tableRetries = {}; + +function registerTableRetry(key, fn) { _tableRetries[key] = fn; } + +$(document).on('click', '[data-retry]', function () { + var fn = _tableRetries[$(this).attr('data-retry')]; + if (fn) fn(); +}); + +function _tableHasData($tbody) { + return $tbody.children('tr').not('.skel-row, .tbl-alert').length > 0; +} + +function beginTableLoad(tbody, colspan, rows) { + var $t = $(tbody); + if (!$t.length || _tableHasData($t) || $t.find('tr.skel-row').length) return; + $t.html(skeletonRows(colspan, rows || 6)); +} + +function showTableAlert(tbody, colspan, text, retryKey) { + var $t = $(tbody); + if (!$t.length) return; + $t.children('tr').filter(function () { + return $(this).hasClass('skel-row') || $(this).hasClass('tbl-alert') || + $(this).find('td.empty-hint').length > 0; + }).remove(); + $t.prepend(tableAlertHtml(colspan, text, retryKey)); +} + +function clearTableAlert(tbody) { + $(tbody).find('tr.tbl-alert').remove(); +} + + +var TIP_SHOW_DELAY = 380; +var TIP_HIDE_DELAY = 140; + +var _tipTarget = null; +var _tipShown = false; +var _tipShowTimer = null; +var _tipHideTimer = null; + +function _tipElement() { + var el = document.getElementById('tip-pop'); + if (!el) { + el = document.createElement('div'); + el.className = 'tip-pop'; + el.id = 'tip-pop'; + el.setAttribute('role', 'tooltip'); + document.body.appendChild(el); + } + return el; +} + +function _tipTextOf(el) { + var text = el.getAttribute('data-tip'); + if (text === null) text = el.getAttribute('title'); + return text === null ? '' : text; +} + +function _tipTrigger(node) { + if (!node || node.nodeType !== 1 || !node.closest) return null; + if (node.closest('#tip-pop')) return null; + var el = node.closest('[data-tip], [title]'); + if (!el) return null; + return _tipTextOf(el) ? el : null; +} + +function _tipPlace(el, pop) { + var rect = el.getBoundingClientRect(); + var w = pop.offsetWidth, h = pop.offsetHeight; + var gap = 10; + var left = rect.left + rect.width / 2 - w / 2; + var top = rect.bottom + gap; + if (top + h > window.innerHeight - 8 && rect.top - gap - h > 8) top = rect.top - gap - h; + left = Math.max(8, Math.min(left, window.innerWidth - w - 8)); + top = Math.max(8, Math.min(top, window.innerHeight - h - 8)); + pop.style.left = Math.round(left) + 'px'; + pop.style.top = Math.round(top) + 'px'; +} + +function _tipOpen(el) { + var text = _tipTextOf(el); + if (!text || !document.body.contains(el)) return; + if (el.hasAttribute('title')) { + el.setAttribute('data-tip-stash', el.getAttribute('title')); + el.removeAttribute('title'); + } + var pop = _tipElement(); + pop.textContent = text; + pop.classList.add('show'); + _tipPlace(el, pop); + _tipShown = true; +} + +function _tipClose() { + var el = _tipTarget; + if (!el) return; + var pop = document.getElementById('tip-pop'); + if (pop) pop.classList.remove('show'); + if (el.hasAttribute('data-tip-stash') && !el.hasAttribute('title')) { + el.setAttribute('title', el.getAttribute('data-tip-stash')); + } + el.removeAttribute('data-tip-stash'); + _tipTarget = null; + _tipShown = false; +} + +function hideTip() { + clearTimeout(_tipShowTimer); + clearTimeout(_tipHideTimer); + _tipShowTimer = null; + _tipHideTimer = null; + _tipClose(); +} + +function _tipScheduleShow(el, delay) { + _tipTarget = el; + clearTimeout(_tipShowTimer); + _tipShowTimer = setTimeout(function () { + _tipShowTimer = null; + if (_tipTarget === el) _tipOpen(el); + }, delay); +} + +$(document).on('mouseover', function (e) { + var el = _tipTrigger(e.target); + if (el && el === _tipTarget) { clearTimeout(_tipHideTimer); _tipHideTimer = null; return; } + if (!el) return; + hideTip(); + _tipScheduleShow(el, TIP_SHOW_DELAY); +}); + +$(document).on('mouseover', '#tip-pop', function () { + clearTimeout(_tipHideTimer); + _tipHideTimer = null; +}); + +$(document).on('mouseout', function (e) { + if (!_tipTarget) return; + var to = e.relatedTarget; + if (to && _tipTarget.contains(to)) return; + if (to && to.closest && to.closest('#tip-pop')) return; + clearTimeout(_tipShowTimer); + _tipShowTimer = null; + clearTimeout(_tipHideTimer); + _tipHideTimer = setTimeout(hideTip, TIP_HIDE_DELAY); +}); + +$(document).on('focusin', function (e) { + var el = _tipTrigger(e.target); + if (!el || el === _tipTarget) return; + hideTip(); + _tipScheduleShow(el, 0); +}); + +$(document).on('focusout', function (e) { + if (_tipTarget && e.target === _tipTarget) hideTip(); +}); + +$(window).on('scroll resize', hideTip); +$(document).on('mousedown', hideTip); + +$(document).on('keydown', function (e) { + if (e.key === 'Escape' && _tipShown) hideTip(); +}); + +function refreshFieldTips() { + $('.fg-tip').each(function () { + var text = this.getAttribute('title') || this.getAttribute('data-tip') || ''; + if (text) this.setAttribute('aria-label', text); + }); +} + +function initFieldTips() { + $('.fg-tip').each(function () { + this.removeAttribute('aria-hidden'); + if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0'); + }); + refreshFieldTips(); + onLangRender(refreshFieldTips); +} diff --git a/web/templates/index.html b/web/templates/index.html index 1ac7cac..8aadb2d 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,3164 +1,1506 @@ + - ProxyCat 控制面板 - - - - - - - - - - - - - - - + ProxyCat + + + + + -
-
-
-

ProxyCat 控制面板

-
+
+
+ + -
- -
-
- -
-
- - 本地监听地址 -
-
-
-
- - HTTP 代理 -
-
http://127.0.0.1:1080
- -
-
-
- - SOCKS5 代理 -
-
socks5://127.0.0.1:1080
- -
-
-
-
-
+
- -
-
- -
-
- - 关于信息 -
-
-
- -
- 当前版本: - - - -
-
-
- -
- 开源项目求 Star: - ProxyCat -
-
-
- -
- 公众号: - 樱花庄的本间白猫 -
-
-
-
-
-
+
+
+ + ProxyCat 控制面板
- +
-
- -
-
- -
-
- -
-
- - 基本配置 -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
- - -
-
- -
-
- - API代理设置 -
-
-
-
- - -
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
-
-
- - -
-
- -
-
-
用户管理
- -
-
- - - - - - - - - - -
用户名密码操作
-
-
-
-
-
- -
- -
-
- - -
-
- -
-
- - 代理管理 -
-
-
-
-
-
-
- - HTTP代理 -
- -
-
-
-
-
- - HTTPS代理 -
- -
-
-
-
-
- - SOCKS5代理 -
- -
-
-
-
-
-
- - 完整代理地址 -
支持完整格式的代理地址,包含协议和认证信息
-
- -
-
-
-
- - -
用于检测代理有效性的目标地址
-
-
- - -
-
-
-
-
- - -
-
- -
-
- - IP名单 -
-
-
-
-
白名单
- -
-
-
黑名单
- -
-
-
-
- - -
-
- -
-
-
-
-
-
- - -
-
- -
-
- - 运行日志 -
-
-
-
- - -
-
- -
-
- -
-
-
-
-
-
+ -
-
-