Compare commits
11
Commits
v0.3.0
..
4d5caac8d8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d5caac8d8 | ||
|
|
5699aa9dde | ||
|
|
e44c8950ee | ||
|
|
0b6b6b440b | ||
|
|
1adc8139b9 | ||
|
|
a4a0227d5d | ||
|
|
9649eb77c7 | ||
|
|
1ca3144245 | ||
|
|
e6663926c0 | ||
|
|
bac459c5b5 | ||
|
|
84127a93ff |
@@ -9,15 +9,15 @@ edits. **Credentials are always injected — never hardcoded.**
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```
|
```
|
||||||
aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.3.0
|
aioproxies @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v1.0.0
|
||||||
# network helpers (current_ip / reset) need the extra:
|
# network helpers (current_ip / reset) need the extra:
|
||||||
aioproxies[net] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v0.3.0
|
aioproxies[net] @ git+ssh://git@git.rethinkstudios.io/rethink-public/aioproxies.git@v1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The core has no dependencies. The `net` extra adds `aiohttp` for `current_ip` /
|
The core has no dependencies. The `net` extra adds `aiohttp` for `current_ip` /
|
||||||
`reset`.
|
`reset`.
|
||||||
|
|
||||||
Drop the `@v0.3.0` suffix from the line above to install the latest unpinned.
|
Drop the `@v1.0.0` suffix from the line above to install the latest unpinned.
|
||||||
|
|
||||||
## Formatting
|
## Formatting
|
||||||
|
|
||||||
@@ -105,12 +105,14 @@ regardless of source.)
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from aioproxies import AioProxies, ProxiesExhaustedError
|
from aioproxies import AioProxies, ProxiesExhaustedError
|
||||||
|
from aioweb import ExtendedSession
|
||||||
|
|
||||||
pm = AioProxies(proxies=[...], cooldown=5) # 5s reuse spacing; cooldown defaults to 0 (off)
|
pm = AioProxies(proxies=[...], cooldown=5) # 5s reuse spacing; cooldown defaults to 0 (off)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = pm.get() # next usable proxy, aiohttp dict
|
proxy = pm.get() # next usable proxy, aiohttp dict
|
||||||
resp = await session.get(url, proxies=proxy)
|
async with ExtendedSession(proxies=proxy) as session:
|
||||||
|
resp = await session.get(url)
|
||||||
if response_looks_blocked(resp):
|
if response_looks_blocked(resp):
|
||||||
pm.burn(proxy, 600) # time out 10 min ... or pm.burn(proxy) for dead
|
pm.burn(proxy, 600) # time out 10 min ... or pm.burn(proxy) for dead
|
||||||
except ProxiesExhaustedError:
|
except ProxiesExhaustedError:
|
||||||
@@ -149,6 +151,9 @@ key (`host:port:user:pass`, or `host:port` auth-less; the port is normalized so
|
|||||||
differing only by password are distinct slots. `burn` on a proxy not in the pool raises
|
differing only by password are distinct slots. `burn` on a proxy not in the pool raises
|
||||||
`ValueError`; `restore` on a proxy not in the pool logs a warning and no-ops (matching
|
`ValueError`; `restore` on a proxy not in the pool logs a warning and no-ops (matching
|
||||||
`remove`'s contract, as of v0.3.0 — previously it silently did nothing with no signal).
|
`remove`'s contract, as of v0.3.0 — previously it silently did nothing with no signal).
|
||||||
|
A **portless** url (`http://user:pass@host`, no `:port`) parses fine via `to_proxy()`,
|
||||||
|
but keying it (`canonical_key`/`.key()`/`burn`/`add`/`remove`) raises `ValueError`
|
||||||
|
naming the missing port — a proxy needs a port to have a canonical identity.
|
||||||
|
|
||||||
### Cooldown
|
### Cooldown
|
||||||
|
|
||||||
@@ -209,6 +214,22 @@ await reset("https://provider/reset-url") # rotate upstream ip
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.3.2
|
||||||
|
|
||||||
|
- **Portless proxy url now fails loud and legible.** `canonical_key`/`.key()`
|
||||||
|
(and therefore `burn`/`add`/`remove`) on a proxy url with no `:port`
|
||||||
|
(`http://user:pass@host`) used to raise a bare `ValueError("invalid literal
|
||||||
|
for int() with base 10: ''")` from `normalize_port('')` — an unrelated `int()`
|
||||||
|
error that broke `burn()`'s documented "raises ValueError naming the key if
|
||||||
|
not in pool" contract. `normalize_port` now raises `ValueError("missing port:
|
||||||
|
...")` naming the problem instead. `to_proxy()` on a portless url still
|
||||||
|
succeeds (construction tolerates a missing port); only keying it raises.
|
||||||
|
|
||||||
|
### v0.3.1
|
||||||
|
|
||||||
|
- **Docs-only de-bloat pass.** Compressed module/internal docstrings and comments,
|
||||||
|
replaced mojibake em-dashes with plain hyphens in source. No behavior change.
|
||||||
|
|
||||||
### v0.3.0
|
### v0.3.0
|
||||||
|
|
||||||
- **Stored-shape change: int unix deadlines.** `burn(proxy, seconds)` and the
|
- **Stored-shape change: int unix deadlines.** `burn(proxy, seconds)` and the
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "aioproxies"
|
name = "aioproxies"
|
||||||
version = "0.3.0"
|
version = "1.1.0"
|
||||||
description = "proxy parsing, formatting, health, and pool management for aiohttp/aioweb, camoufox, and socks5"
|
description = "proxy parsing, formatting, health, and pool management for aiohttp/aioweb, camoufox, and socks5"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""aioproxies — proxy parsing, formatting, and source management.
|
"""aioproxies - proxy parsing, formatting, and source management. see README for usage."""
|
||||||
|
from importlib.metadata import version, PackageNotFoundError
|
||||||
|
|
||||||
renders proxies for aiohttp/aioweb, camoufox, and socks5; manages session
|
|
||||||
templates (with caller-supplied fields like country/ttl), rotating lists, or a
|
|
||||||
static proxy. credentials are always injected, never hardcoded.
|
|
||||||
"""
|
|
||||||
from .manager import AioProxies, ProxiesExhaustedError, ProxyManager, aioproxies
|
from .manager import AioProxies, ProxiesExhaustedError, ProxyManager, aioproxies
|
||||||
from .proxy import Proxy, canonical_key, parse, to_proxy
|
from .proxy import Proxy, canonical_key, parse, to_proxy
|
||||||
|
|
||||||
@@ -18,4 +15,7 @@ __all__ = [
|
|||||||
"to_proxy",
|
"to_proxy",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
try:
|
||||||
|
__version__ = version("aioproxies")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
__version__ = "0.0.0+unknown"
|
||||||
|
|||||||
+57
-53
@@ -1,31 +1,19 @@
|
|||||||
"""proxy source management: session templates, rotation, static.
|
"""proxy source management: session templates, rotation, static.
|
||||||
|
|
||||||
`AioProxies` is constructed with exactly one source and hands out `Proxy` objects.
|
`AioProxies` takes exactly one source (template / proxies / static) and hands out
|
||||||
no module-level globals (rotation state is per-instance); a missing proxy file
|
`Proxy` objects via `next()`/`get()`. no module-level globals; a missing proxy file
|
||||||
raises, never `sys.exit`.
|
raises, never `sys.exit`. see README for the full source/method reference.
|
||||||
|
|
||||||
sources:
|
proxy health (rotating list source only) is keyed by each proxy's canonical key
|
||||||
- template: a format string. `{session}` is filled with a fresh id on each
|
(`host:port:user:pass`, or `host:port` auth-less; port is normalized so `:080` and
|
||||||
`next()`; other placeholders (`{country}`, `{ttl}`, ...) come from
|
`:80` collapse to one key). on template/static sources health/pool methods are
|
||||||
`next(**fields)`. a bare `{}` is treated as the session slot unless escaped as
|
no-ops that log a warning and return, so generic caller code can call them
|
||||||
`{{}}` (back-compat with simple templates).
|
regardless of source.
|
||||||
- proxies: a list of specs cycled round-robin (deduped by canonical key)
|
|
||||||
- static: one fixed proxy
|
|
||||||
|
|
||||||
proxy health (rotating list source only) — burn/timeout, usage counters, reuse
|
per-proxy `timeout` (the availability field) is shared by cooldown and timed
|
||||||
cooldown, pool edits (replace/add/remove) — keyed by each proxy's canonical key
|
burns: `None`/`0` fine, `-1` dead/permanent, a future unix ts = timed out until
|
||||||
(`host:port:user:pass`, or `host:port` auth-less; port is normalized so `:080`
|
then; durations only ever exist as call arguments, converted to `now + seconds`
|
||||||
and `:80` collapse to one key). on template/static sources these are no-ops that
|
and discarded, never stored raw.
|
||||||
log a warning and return, so generic caller code can call them regardless of
|
|
||||||
source.
|
|
||||||
|
|
||||||
per-proxy state (keyed by canonical key): `uses` is a pure counter, incremented on
|
|
||||||
every handout, never drives selection, survives burns. `timeout` is the
|
|
||||||
availability state — `None`/`0` fine, `-1` dead/permanent (manual restore only), a
|
|
||||||
future unix ts = timed out until then. this core is sync: no timers, lazy expiry
|
|
||||||
checked only against `time.time()` at call time. cooldown and timed burns share
|
|
||||||
this field; durations only ever exist as call arguments, converted to `now +
|
|
||||||
seconds` and discarded — a raw duration is never stored.
|
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
@@ -68,7 +56,7 @@ class AioProxies:
|
|||||||
self.session_len = session_len
|
self.session_len = session_len
|
||||||
self.cooldown = cooldown
|
self.cooldown = cooldown
|
||||||
self._shuffle = shuffle
|
self._shuffle = shuffle
|
||||||
self._static = parse(static) if static is not None else None
|
self._static = to_proxy(static) if static is not None else None
|
||||||
self._proxies = self._dedupe([parse(p) for p in proxies]) if proxies is not None else []
|
self._proxies = self._dedupe([parse(p) for p in proxies]) if proxies is not None else []
|
||||||
if self._proxies and shuffle:
|
if self._proxies and shuffle:
|
||||||
random.shuffle(self._proxies)
|
random.shuffle(self._proxies)
|
||||||
@@ -92,20 +80,14 @@ class AioProxies:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_template(template: str) -> str:
|
def _normalize_template(template: str) -> str:
|
||||||
"""fill a bare `{}` session slot, escape-aware
|
"""fill a bare `{}` session slot with `{session}`; leave escaped `{{}}` untouched"""
|
||||||
|
|
||||||
a lone `{}` is treated as `{session}` (back-compat with simple templates).
|
|
||||||
an escaped literal `{{}}` — str.format's own convention for a literal `{}`
|
|
||||||
in the output — is left untouched so it survives to render as `{}`, not
|
|
||||||
`{session}`.
|
|
||||||
"""
|
|
||||||
return _BARE_SESSION_SLOT.sub("{session}", template)
|
return _BARE_SESSION_SLOT.sub("{session}", template)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_file(cls, path: str, **kwargs) -> "AioProxies":
|
def from_file(cls, path: str, **kwargs) -> "AioProxies":
|
||||||
"""build a rotating manager from a newline-delimited proxy file
|
"""build a rotating manager from a newline-delimited proxy file
|
||||||
|
|
||||||
raises FileNotFoundError if the path is missing — never exits the process.
|
raises FileNotFoundError if the path is missing - never exits the process.
|
||||||
"""
|
"""
|
||||||
with open(path, "r", encoding="utf-8") as fh:
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
lines = [ln.strip() for ln in fh if ln.strip()]
|
lines = [ln.strip() for ln in fh if ln.strip()]
|
||||||
@@ -134,6 +116,17 @@ class AioProxies:
|
|||||||
return False
|
return False
|
||||||
return now >= timeout
|
return now >= timeout
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clear_stale_burn(state: Dict[str, object]) -> None:
|
||||||
|
"""self-clear a lazily-expired timed burn's `burned` flag once found available
|
||||||
|
|
||||||
|
mirrors the lazy expiry already applied to `timeout` itself: a timed burn()
|
||||||
|
that has simply run out its clock should stop upgrading routine cooldown
|
||||||
|
fallthrough logs to warning, without requiring an explicit restore().
|
||||||
|
"""
|
||||||
|
if state["timeout"] not in (None, 0, _DEAD):
|
||||||
|
state["burned"] = False
|
||||||
|
|
||||||
def next(self, **fields: object) -> Proxy:
|
def next(self, **fields: object) -> Proxy:
|
||||||
"""return the next proxy from the configured source
|
"""return the next proxy from the configured source
|
||||||
|
|
||||||
@@ -151,7 +144,7 @@ class AioProxies:
|
|||||||
if self.template is not None:
|
if self.template is not None:
|
||||||
if "session" in fields:
|
if "session" in fields:
|
||||||
# `session` is auto-filled with a fresh id; a caller-supplied one would
|
# `session` is auto-filled with a fresh id; a caller-supplied one would
|
||||||
# collide in str.format with an opaque TypeError — reject it clearly
|
# collide in str.format with an opaque TypeError - reject it clearly
|
||||||
raise ValueError("'session' is filled automatically; do not pass it to next()")
|
raise ValueError("'session' is filled automatically; do not pass it to next()")
|
||||||
try:
|
try:
|
||||||
filled = self.template.format(session=self.session_id(), **fields)
|
filled = self.template.format(session=self.session_id(), **fields)
|
||||||
@@ -161,9 +154,9 @@ class AioProxies:
|
|||||||
) from exc
|
) from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
# a malformed template (e.g. an unmatched '{') makes str.format raise a
|
# a malformed template (e.g. an unmatched '{') makes str.format raise a
|
||||||
# bare ValueError; re-raise naming the cause so it isn't cryptic
|
# bare ValueError - re-raise naming the cause so it isn't cryptic
|
||||||
raise ValueError(f"malformed proxy template {self.template!r}: {exc}") from exc
|
raise ValueError(f"malformed proxy template {self.template!r}: {exc}") from exc
|
||||||
return parse(filled)
|
return to_proxy(filled)
|
||||||
return self._next_from_list()
|
return self._next_from_list()
|
||||||
|
|
||||||
def _next_from_list(self) -> Proxy:
|
def _next_from_list(self) -> Proxy:
|
||||||
@@ -176,9 +169,10 @@ class AioProxies:
|
|||||||
fine: List[int] = []
|
fine: List[int] = []
|
||||||
for offset in range(count):
|
for offset in range(count):
|
||||||
idx = (self._index + offset) % count
|
idx = (self._index + offset) % count
|
||||||
timeout = self._state[self._proxies[idx].key()]["timeout"]
|
state = self._state[self._proxies[idx].key()]
|
||||||
if self._available(timeout, now):
|
if self._available(state["timeout"], now):
|
||||||
fine.append(idx)
|
fine.append(idx)
|
||||||
|
self._clear_stale_burn(state)
|
||||||
|
|
||||||
if fine:
|
if fine:
|
||||||
chosen = fine[0]
|
chosen = fine[0]
|
||||||
@@ -196,18 +190,21 @@ class AioProxies:
|
|||||||
state = self._state[proxy.key()]
|
state = self._state[proxy.key()]
|
||||||
state["uses"] = int(state["uses"]) + 1
|
state["uses"] = int(state["uses"]) + 1
|
||||||
if self.cooldown > 0:
|
if self.cooldown > 0:
|
||||||
state["timeout"] = int(now) + self.cooldown
|
# cooldown only ever EXTENDS availability delay - never shorten a longer
|
||||||
|
# active timeout (a still-running timed burn) down to now+cooldown. _DEAD
|
||||||
|
# (-1) is a permanent burn and must not be resurrected into a future ts.
|
||||||
|
cooled = int(now) + self.cooldown
|
||||||
|
current = state["timeout"]
|
||||||
|
if current == _DEAD:
|
||||||
|
pass
|
||||||
|
elif isinstance(current, (int, float)) and current > cooled:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
state["timeout"] = cooled
|
||||||
return proxy
|
return proxy
|
||||||
|
|
||||||
def _has_burned(self) -> bool:
|
def _has_burned(self) -> bool:
|
||||||
"""whether the empty 'fine' tier reflects a genuine burn(), not just cooldown
|
"""whether any proxy carries a genuine burn (vs just cooldown resting), for log level choice"""
|
||||||
|
|
||||||
tracked directly via each state's `burned` flag (set by burn(), cleared by
|
|
||||||
restore()) so a real burn is never masked by the manager's own cooldown
|
|
||||||
resting on the same `timeout` field, regardless of whether cooldown is on.
|
|
||||||
used to pick warning (real trouble) vs debug (normal cooldown) on the
|
|
||||||
soonest-recovering path.
|
|
||||||
"""
|
|
||||||
return any(state["burned"] for state in self._state.values())
|
return any(state["burned"] for state in self._state.values())
|
||||||
|
|
||||||
def _soonest_recovering(self) -> Optional[int]:
|
def _soonest_recovering(self) -> Optional[int]:
|
||||||
@@ -273,8 +270,11 @@ class AioProxies:
|
|||||||
key = canonical_key(proxy)
|
key = canonical_key(proxy)
|
||||||
if key not in self._state:
|
if key not in self._state:
|
||||||
return False
|
return False
|
||||||
timeout = self._state[key]["timeout"]
|
state = self._state[key]
|
||||||
return not self._available(timeout, time.time())
|
available = self._available(state["timeout"], time.time())
|
||||||
|
if available:
|
||||||
|
self._clear_stale_burn(state)
|
||||||
|
return not available
|
||||||
|
|
||||||
def stats(self) -> List[Dict[str, object]]:
|
def stats(self) -> List[Dict[str, object]]:
|
||||||
"""per-proxy usage + state snapshot (list sources only, else empty)
|
"""per-proxy usage + state snapshot (list sources only, else empty)
|
||||||
@@ -312,17 +312,21 @@ class AioProxies:
|
|||||||
for state in self._state.values():
|
for state in self._state.values():
|
||||||
state["uses"] = 0
|
state["uses"] = 0
|
||||||
|
|
||||||
def replace(self, proxies: List[Union[str, Proxy]], *, keep_state: bool = False) -> None:
|
def replace(self, proxies: List[Union[str, Proxy, Dict[str, str]]], *, keep_state: bool = False) -> None:
|
||||||
"""swap the entire proxy list
|
"""swap the entire proxy list
|
||||||
|
|
||||||
keep_state=False (default) wipes all per-proxy state (fresh batch).
|
keep_state=False (default) wipes all per-proxy state (fresh batch).
|
||||||
keep_state=True preserves uses/timeout for proxies whose canonical key
|
keep_state=True preserves uses/timeout for proxies whose canonical key
|
||||||
survives the swap; new proxies start clean, dropped ones are forgotten.
|
survives the swap; new proxies start clean, dropped ones are forgotten.
|
||||||
resets the rotation index and honors the manager's shuffle setting.
|
resets the rotation index and honors the manager's shuffle setting. raises
|
||||||
|
ValueError on an empty list, matching the constructor's guard - use remove()
|
||||||
|
to drop proxies one at a time if you actually want a smaller pool.
|
||||||
"""
|
"""
|
||||||
if not self._is_list_source():
|
if not self._is_list_source():
|
||||||
self._warn_non_list("replace()")
|
self._warn_non_list("replace()")
|
||||||
return
|
return
|
||||||
|
if len(proxies) == 0:
|
||||||
|
raise ValueError("proxies list is empty; provide at least one proxy")
|
||||||
old_state = self._state
|
old_state = self._state
|
||||||
incoming = [to_proxy(p) for p in proxies]
|
incoming = [to_proxy(p) for p in proxies]
|
||||||
if self._shuffle:
|
if self._shuffle:
|
||||||
@@ -342,7 +346,7 @@ class AioProxies:
|
|||||||
self._state = new_state
|
self._state = new_state
|
||||||
self._index = 0
|
self._index = 0
|
||||||
|
|
||||||
def add(self, proxies: Union[str, Proxy, List[Union[str, Proxy]]]) -> None:
|
def add(self, proxies: Union[str, Proxy, Dict[str, str], List[Union[str, Proxy, Dict[str, str]]]]) -> None:
|
||||||
"""append proxies to the pool, keeping existing state; skip duplicate keys"""
|
"""append proxies to the pool, keeping existing state; skip duplicate keys"""
|
||||||
if not self._is_list_source():
|
if not self._is_list_source():
|
||||||
self._warn_non_list("add()")
|
self._warn_non_list("add()")
|
||||||
@@ -382,6 +386,6 @@ class AioProxies:
|
|||||||
self._index %= len(self._proxies)
|
self._index %= len(self._proxies)
|
||||||
|
|
||||||
|
|
||||||
# name aliases — same class, call it whichever reads best at your call site
|
# name aliases - same class, call it whichever reads best at your call site
|
||||||
ProxyManager = AioProxies
|
ProxyManager = AioProxies
|
||||||
aioproxies = AioProxies
|
aioproxies = AioProxies
|
||||||
|
|||||||
+16
-3
@@ -6,11 +6,24 @@ calling a function without it raises a clear error.
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
from .proxy import Proxy, parse
|
from .proxy import Proxy, to_proxy
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_url(url: str) -> str:
|
||||||
|
"""strip query + fragment from a url for logging - a provider reset url can carry a
|
||||||
|
rotation token in its query/path; this drops the query/fragment (the usual token spot)
|
||||||
|
so the log line can't leak it. falls back to the raw url only if it won't parse"""
|
||||||
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
except ValueError:
|
||||||
|
return url
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@@ -32,7 +45,7 @@ async def current_ip(
|
|||||||
"""
|
"""
|
||||||
if not _HAVE_AIOHTTP:
|
if not _HAVE_AIOHTTP:
|
||||||
raise RuntimeError(_MISSING)
|
raise RuntimeError(_MISSING)
|
||||||
p = parse(proxy)
|
p = to_proxy(proxy)
|
||||||
t = aiohttp.ClientTimeout(total=timeout)
|
t = aiohttp.ClientTimeout(total=timeout)
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession(timeout=t) as session:
|
async with aiohttp.ClientSession(timeout=t) as session:
|
||||||
@@ -55,7 +68,7 @@ async def reset(reset_url: str, *, timeout: float = 15.0) -> bool:
|
|||||||
async with aiohttp.ClientSession(timeout=t) as session:
|
async with aiohttp.ClientSession(timeout=t) as session:
|
||||||
async with session.get(reset_url) as resp:
|
async with session.get(reset_url) as resp:
|
||||||
ok = resp.status == 200
|
ok = resp.status == 200
|
||||||
log.info("proxy reset %s -> %s", reset_url, resp.status)
|
log.info("proxy reset %s -> %s", _safe_url(reset_url), resp.status)
|
||||||
return ok
|
return ok
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("proxy reset failed: %s", exc)
|
log.warning("proxy reset failed: %s", exc)
|
||||||
|
|||||||
+22
-27
@@ -1,15 +1,12 @@
|
|||||||
"""proxy parsing and formatting (pure, no network IO).
|
"""proxy parsing and formatting (pure, no network IO).
|
||||||
|
|
||||||
a `Proxy` holds host/port/optional-auth and renders the shapes different clients
|
`Proxy` holds host/port/optional-auth and renders aiohttp/aioweb, camoufox, socks5,
|
||||||
want: an aiohttp/aioweb proxies dict, a camoufox proxy dict, a socks5 dict, or a
|
and url shapes; auth-less (IP-authenticated) proxies are first-class throughout.
|
||||||
plain url. `parse` accepts the common "host:port" and "host:port:user:pass" string
|
`parse` accepts "host:port" and "host:port:user:pass" - the user field may itself
|
||||||
forms (the user field may itself contain commas, e.g. session-param proxies; the
|
contain commas and the password may itself contain colons.
|
||||||
password field may itself contain colons). auth-less (IP-authenticated) proxies
|
|
||||||
with no creds are first-class — every render shape handles them via `has_auth`.
|
|
||||||
|
|
||||||
`key()` produces a stable canonical identity (`host:port:user:pass`, or
|
`key()` / `canonical_key()` produce a stable canonical identity so burn/remove/stats
|
||||||
`host:port` when auth-less) so burn/remove/stats recognize the same proxy from any
|
recognize the same proxy from any input shape (spec string, dict, or url).
|
||||||
input shape. `canonical_key()` extends that to dict/url forms.
|
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, Optional, Union
|
from typing import Dict, Optional, Union
|
||||||
@@ -22,9 +19,12 @@ SCHEME_SOCKS5 = "socks5"
|
|||||||
def normalize_port(port: Union[str, int]) -> str:
|
def normalize_port(port: Union[str, int]) -> str:
|
||||||
"""canonical port string: parsed to int, rendered without zero-padding
|
"""canonical port string: parsed to int, rendered without zero-padding
|
||||||
|
|
||||||
keeps host:080 and host:80 as one canonical key. raises ValueError on a
|
keeps host:080 and host:80 as one canonical key. raises ValueError naming the
|
||||||
non-integer port rather than silently keying it as-is.
|
missing port if empty/None (e.g. a portless proxy url), or on a non-integer
|
||||||
|
port, rather than silently keying it as-is.
|
||||||
"""
|
"""
|
||||||
|
if port is None or port == "":
|
||||||
|
raise ValueError("missing port: proxy url or spec has no port")
|
||||||
return str(int(port))
|
return str(int(port))
|
||||||
|
|
||||||
|
|
||||||
@@ -97,12 +97,15 @@ class Proxy:
|
|||||||
|
|
||||||
|
|
||||||
def parse(spec: Union[str, Proxy]) -> Proxy:
|
def parse(spec: Union[str, Proxy]) -> Proxy:
|
||||||
"""parse a proxy spec into a Proxy
|
"""parse a colon-delimited proxy spec into a Proxy
|
||||||
|
|
||||||
accepts an existing Proxy (returned as-is) or a colon-delimited string in
|
accepts an existing Proxy (returned as-is) or a colon-delimited string in
|
||||||
`host:port` or `host:port:user:pass` form. the 4-part form splits on the first
|
`host:port` or `host:port:user:pass` form. the 4-part form splits on the first
|
||||||
three colons only, so a password may itself contain colons. raises ValueError
|
three colons only, so a password may itself contain colons. this is spec-string
|
||||||
on anything else rather than guessing.
|
only - it does not understand url shapes (`scheme://user:pass@host:port`); a
|
||||||
|
url has 3+ colons and would be misparsed as a 4-part spec rather than rejected.
|
||||||
|
use `to_proxy()` for url / dict / spec input, or anywhere the shape isn't
|
||||||
|
guaranteed to be a bare spec string.
|
||||||
"""
|
"""
|
||||||
if isinstance(spec, Proxy):
|
if isinstance(spec, Proxy):
|
||||||
return spec
|
return spec
|
||||||
@@ -117,14 +120,7 @@ def parse(spec: Union[str, Proxy]) -> Proxy:
|
|||||||
|
|
||||||
|
|
||||||
def canonical_key(spec: Union[str, Proxy, Dict[str, str]]) -> str:
|
def canonical_key(spec: Union[str, Proxy, Dict[str, str]]) -> str:
|
||||||
"""canonical identity key for any supported proxy shape
|
"""`to_proxy(spec).key()` - the canonical identity key for any supported proxy shape"""
|
||||||
|
|
||||||
accepts a spec string, a Proxy, a url string (`http://user:pass@host:port`,
|
|
||||||
`socks5://...`), an aiohttp dict (`{"http": url, "https": url}`), or a
|
|
||||||
camoufox/socks5 dict (`{"server": ..., "username": ..., "password": ...}`).
|
|
||||||
all forms collapse to the same `host:port:user:pass` (or `host:port` auth-less)
|
|
||||||
key, so burn/remove/stats recognize one proxy regardless of how it was passed.
|
|
||||||
"""
|
|
||||||
return to_proxy(spec).key()
|
return to_proxy(spec).key()
|
||||||
|
|
||||||
|
|
||||||
@@ -132,8 +128,8 @@ def to_proxy(spec: Union[str, Proxy, Dict[str, str]]) -> Proxy:
|
|||||||
"""normalize any supported proxy shape into a Proxy
|
"""normalize any supported proxy shape into a Proxy
|
||||||
|
|
||||||
accepts a spec string, a Proxy, a url string, an aiohttp dict, or a
|
accepts a spec string, a Proxy, a url string, an aiohttp dict, or a
|
||||||
camoufox/socks5 dict — the same shapes `canonical_key` accepts. used both for
|
camoufox/socks5 dict. used both for keying and for adding proxies to a pool
|
||||||
keying and for adding proxies to a pool from any shape.
|
from any shape.
|
||||||
"""
|
"""
|
||||||
if isinstance(spec, Proxy):
|
if isinstance(spec, Proxy):
|
||||||
return spec
|
return spec
|
||||||
@@ -150,9 +146,8 @@ def _proxy_from_dict(spec: Dict[str, str]) -> Proxy:
|
|||||||
"""normalize an aiohttp / camoufox / socks5 dict into a Proxy"""
|
"""normalize an aiohttp / camoufox / socks5 dict into a Proxy"""
|
||||||
if "server" in spec:
|
if "server" in spec:
|
||||||
proxy = _proxy_from_url(spec["server"])
|
proxy = _proxy_from_url(spec["server"])
|
||||||
# prefer explicit dict auth; otherwise fall back to auth embedded in the server
|
# prefer explicit dict auth; fall back to auth embedded in the server URL so
|
||||||
# URL (http://user:pass@host:port) so it isn't silently dropped — which would
|
# it isn't silently dropped, which would key the proxy auth-less
|
||||||
# key the proxy auth-less and collide with a genuinely auth-less one
|
|
||||||
user = spec.get("username") or proxy.user
|
user = spec.get("username") or proxy.user
|
||||||
password = spec.get("password") or proxy.password
|
password = spec.get("password") or proxy.password
|
||||||
if user and password:
|
if user and password:
|
||||||
|
|||||||
Reference in New Issue
Block a user