Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ TWOCAPTCHA_KEY=
# solved from another exit was rejected (measured).
# `tools/browser_profile_client.py connection --mint-session --write-env`
# writes both values together.
# It needs the gateway login and password; the dashboard's colon form
# {host}:{port}:{login}:{password} is accepted too. An IP-whitelisted
# gateway URL with no login cannot be pinned to a session.
AVITO_PROXY=

# A Scraping Browser API CDP endpoint (Playwright and pyppeteer engines;
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,33 @@ entry in a blockquote.

## [Unreleased]

> **`AVITO_PROXY` / `--proxy` accept the dashboard's colon form.**
> `host:port:login:password` (with or without `http://`) is now read as
> `http://login:password@host:port` instead of being refused or misread. A
> 2Captcha gateway URL without a login is now refused when a session has to
> be minted or pinned — `tools/browser_profile_client.py connection
> --mint-session` exits 1 and the scrapers exit 2 — with a message naming the
> required form.

### Fixed

* **Session minting on a gateway URL with no login.** `connection
--mint-session` spliced the new login in front of `http://` when
`AVITO_PROXY` had an empty username, so the rebuilt URL had no host and the
run died with "the URL given is not one" of a 2Captcha gateway — a message
about the host, for a problem with the login. The login is now replaced in
the URL's netloc, and a gateway credential without a login and password is
refused by name.
* **The dashboard's `host:port:login:password` credential.** Read as a URL it
has no username and a "port" of `port:login:password`; it looked like an
IP-whitelisted gateway, and the parser's error about that port quoted the
login. It is now normalised to a URL wherever a proxy is read (`--proxy`,
`AVITO_PROXY`, `--proxy-file` lines, the browser-profile tool), and no
message built from it repeats the credential.
* **Percent-encoded proxy passwords.** The login and password are decoded
before they reach Playwright, pyppeteer, the local forwarder and the
captcha solver's proxy fields; they were passed on still encoded.

## [1.1.0] — 2026-10-15

> **Exit codes and file modes changed.** A run that ends with zero rows for
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,20 @@ For a local browser (no endpoint), `AVITO_PROXY` is the browser's exit; a
gateway login without `-session-` is pinned to one session for the run
automatically.

`AVITO_PROXY` must carry the gateway's login and password, in either form:

```text
http://LOGIN:PASSWORD@HOST:PORT # a URL
HOST:PORT:LOGIN:PASSWORD # as the 2Captcha dashboard exports it
```

The second form may also start with `http://`; it is rewritten to the first,
with the login and password percent-encoded, so a password containing `@`,
`:` or `/` works in either. A gateway URL without a login (the IP-whitelisted
form, `http://HOST:PORT`) is refused with a message saying
so: the `-session-` and `-sessTime-` ids that pin an exit live inside the
login, so such a URL cannot be minted or pinned.

`python3 env_config.py` shows what was picked up, without printing secrets.

---
Expand Down
10 changes: 9 additions & 1 deletion browser_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,11 @@ def parse_args(parser, argv=None, *, cdp_supported: bool = True):
except UsageError as exc:
parser.error(str(exc))

if args.proxy:
# The dashboard's host:port:login:password form becomes a URL here,
# once, so every engine and the solver read the same value.
args.proxy = proxy_pool.normalize_proxy(args.proxy)

if args.cdp_endpoint:
# Never applied to the remote browser — it brings its own exit.
args.solver_proxy = args.proxy
Expand All @@ -1033,7 +1038,10 @@ def parse_args(parser, argv=None, *, cdp_supported: bool = True):
and "-session-" not in args.proxy and not args.proxy_sessions:
# A bare gateway login rotates per request; the solver must share
# the browser's exit, so pin one session for the run.
args.proxy = proxy_pool.mint_sessions(args.proxy, 1)[0]
try:
args.proxy = proxy_pool.mint_sessions(args.proxy, 1)[0]
except proxy_pool.ProxyError as exc:
parser.error(str(exc))
try:
args.pool = proxy_pool.from_args(args)
except proxy_pool.ProxyError as exc:
Expand Down
8 changes: 5 additions & 3 deletions captcha_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
import re
import time
from typing import Optional
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse

import requests

Expand Down Expand Up @@ -103,10 +103,12 @@ def proxy_task_fields(proxy: Optional[str]) -> dict:
if not (p.scheme and p.hostname and port):
return {}
fields = {"proxyType": p.scheme, "proxyAddress": p.hostname, "proxyPort": port}
# Percent-decoded: a password with `@` or `:` in it travels encoded in
# the URL, and the API wants the password itself.
if p.username:
fields["proxyLogin"] = p.username
fields["proxyLogin"] = unquote(p.username)
if p.password:
fields["proxyPassword"] = p.password
fields["proxyPassword"] = unquote(p.password)
return fields


Expand Down
4 changes: 2 additions & 2 deletions proxy_forwarder.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import socket
import threading
from typing import Optional, Tuple
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse

_BUF = 65536

Expand All @@ -47,7 +47,7 @@ def __init__(self, upstream_url: str):
if not (parts.hostname and parts.port):
raise ValueError("upstream proxy needs host and port")
self._upstream: Tuple[str, int] = (parts.hostname, parts.port)
token = "%s:%s" % (parts.username or "", parts.password or "")
token = "%s:%s" % (unquote(parts.username or ""), unquote(parts.password or ""))
self._auth = b"Proxy-Authorization: Basic " + base64.b64encode(token.encode()) + b"\r\n"
self._server: Optional[socket.socket] = None
self._thread: Optional[threading.Thread] = None
Expand Down
108 changes: 88 additions & 20 deletions proxy_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import re
import string
from typing import List, Optional
from urllib.parse import urlparse, urlunparse
from urllib.parse import quote, unquote, urlparse, urlunparse

logger = logging.getLogger("proxy_pool")

Expand All @@ -52,6 +52,38 @@ class ProxyError(ValueError):
"""A proxy list that cannot be used as given."""


# The 2Captcha dashboard hands a proxy out as `host:port:login:password`
# (sometimes with an `http://` in front), not as a URL. Read as a URL that
# string has no username at all and a "port" of `port:login:password` --
# so a gateway credential looked like an IP-whitelisted one, and the
# ValueError urlparse raises on that port quotes the login. Both forms are
# accepted; the colon form is rewritten to scheme://login:password@host:port
# with login and password percent-encoded, and every consumer decodes them.
_COLON_FORM_RE = re.compile(
r"^(?:(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*)://)?"
r"(?P<host>[^:@/\s]+):(?P<port>\d{1,5}):"
r"(?P<login>[^:@/\s]+):(?P<password>\S+)$")


def normalize_proxy(value: Optional[str]) -> Optional[str]:
"""`value` as a proxy URL: the colon form rewritten, anything else as is."""
if value is None:
return None
text = value.strip()
m = _COLON_FORM_RE.match(text)
if not m:
return text
scheme = (m.group("scheme") or "http").lower()
login = quote(m.group("login"), safe="-_.~")
password = quote(m.group("password"), safe="-_.~")
return f"{scheme}://{login}:{password}@{m.group('host')}:{m.group('port')}"


def credentials_of(parsed) -> tuple:
"""(login, password) of a parsed proxy URL, percent-decoded ("" if absent)."""
return unquote(parsed.username or ""), unquote(parsed.password or "")


def parse_proxy_line(line: str, source: str = "<arg>") -> Optional[str]:
"""Validate one proxy URL. Returns it, or None for a blank/comment line.

Expand All @@ -62,6 +94,7 @@ def parse_proxy_line(line: str, source: str = "<arg>") -> Optional[str]:
line = line.strip()
if not line or line.startswith("#"):
return None
line = normalize_proxy(line)

# Every message below reports mask(line), never the line itself. These
# strings hold a password, and an error message is a log: a CI run
Expand Down Expand Up @@ -91,10 +124,9 @@ def parse_proxy_line(line: str, source: str = "<arg>") -> Optional[str]:
parsed.port
except ValueError:
raise ProxyError(
f"{source}: {shown!r} — the port is not a number. If you copied "
f"this from a proxy list file, that format is "
f"scheme://host:port:login:password and this expects a URL: "
f"http://login:password@host:port") from None
f"{source}: {shown!r} — the port is not a number. Write the "
f"proxy as http://login:password@host:port or as "
f"host:port:login:password.") from None
if parsed.scheme == "socks5" and (parsed.username or parsed.password):
raise ProxyError(
f"{source}: {shown!r} — Chromium cannot authenticate a SOCKS5 "
Expand Down Expand Up @@ -135,7 +167,7 @@ def mask(url: Optional[str]) -> str:
if not url:
return "(none)"
try:
parsed = urlparse(url)
parsed = urlparse(normalize_proxy(url))
host = parsed.hostname or "?"
# `parsed.port` raises on a malformed authority; the raw netloc is
# not safe to fall back to, because that is where the password is.
Expand Down Expand Up @@ -179,10 +211,11 @@ def to_playwright(url: Optional[str]) -> Optional[dict]:
parsed = urlparse(url)
port = f":{parsed.port}" if parsed.port else ""
proxy = {"server": f"{parsed.scheme}://{parsed.hostname}{port}"}
if parsed.username:
proxy["username"] = parsed.username
if parsed.password:
proxy["password"] = parsed.password
login, password = credentials_of(parsed)
if login:
proxy["username"] = login
if password:
proxy["password"] = password
return proxy


Expand All @@ -202,7 +235,7 @@ def split_credentials(url: Optional[str]):
port = f":{parsed.port}" if parsed.port else ""
scrubbed = f"{parsed.scheme}://{parsed.hostname}{port}"
if parsed.username or parsed.password:
return scrubbed, (parsed.username or "", parsed.password or "")
return scrubbed, credentials_of(parsed)
return scrubbed, None


Expand Down Expand Up @@ -320,7 +353,7 @@ def is_2captcha_gateway(url: Optional[str]) -> bool:
if not url:
return False
try:
host = (urlparse(url).hostname or "").lower()
host = (urlparse(normalize_proxy(url)).hostname or "").lower()
except ValueError:
return False
return host.endswith(_GATEWAY_SUFFIXES)
Expand All @@ -330,6 +363,45 @@ def _session_id(rng: random.Random) -> str:
return "".join(rng.choice(_SESSION_CHARS) for _ in range(_SESSION_LEN))


def require_gateway_login(url: str, name: str = "the proxy") -> str:
"""`url` normalised, or ProxyError when it carries no login and password.

The session id lives INSIDE the login, so a host-only (IP-whitelisted)
gateway URL has nowhere to put one. Splicing a login into such a URL
used to produce a string with no host at all, refused with a message
that blamed the host. The credential is never part of the message.
"""
url = normalize_proxy(url)
parts = urlparse(url)
if not (parts.username and parts.password):
raise ProxyError(
f"{name} has no login: session minting needs the gateway's "
f"LOGIN:PASSWORD form (http://LOGIN:PASSWORD@<host>{_GATEWAY_SUFFIX}:PORT "
f"or <host>{_GATEWAY_SUFFIX}:PORT:LOGIN:PASSWORD); an IP-whitelisted "
f"gateway URL cannot carry a -session-/-sessTime- id.")
return url


def with_login(url: str, login: str) -> str:
"""The same proxy URL with its login replaced, rebuilt from its parts.

Only the netloc's login changes. A str.replace on the whole URL put the
login in front of the scheme when the old login was empty.
"""
parts = urlparse(normalize_proxy(url))
try:
port = f":{parts.port}" if parts.port else ""
except ValueError:
# The message from urlparse quotes the port text, which can hold
# the login and password -- never pass it on.
raise ProxyError(f"{mask(url)}: the port is not a number") from None
host = parts.hostname or ""
password = parts.password or ""
netloc = f"{login}:{password}@{host}{port}" if password else f"{login}@{host}{port}"
return urlunparse((parts.scheme, netloc, parts.path, parts.params,
parts.query, parts.fragment))


def with_session(url: str, session: str) -> str:
"""The same proxy URL with its login pinned to `session`.

Expand All @@ -339,20 +411,15 @@ def with_session(url: str, session: str) -> str:
nobody thought of, so this replaces the session in place, or appends one
before `-sessTime-` when there is none.
"""
parts = urlparse(url)
login = parts.username or ""
url = require_gateway_login(url)
login = urlparse(url).username or ""
if _SESSION_RE.search(login):
new_login = _SESSION_RE.sub("-session-" + session, login, count=1)
elif _SESSTIME_RE.search(login):
new_login = _SESSTIME_RE.sub("-session-" + session + r"\g<0>", login, count=1)
else:
new_login = login + "-session-" + session
password = parts.password or ""
host = parts.hostname or ""
port = f":{parts.port}" if parts.port else ""
netloc = f"{new_login}:{password}@{host}{port}" if password else f"{new_login}@{host}{port}"
return urlunparse((parts.scheme, netloc, parts.path, parts.params,
parts.query, parts.fragment))
return with_login(url, new_login)


def mint_sessions(url: str, count: int, rng: Optional[random.Random] = None) -> List[str]:
Expand All @@ -369,6 +436,7 @@ def mint_sessions(url: str, count: int, rng: Optional[random.Random] = None) ->
"--proxy-sessions only applies to a 2Captcha proxy gateway "
f"(a host under {_GATEWAY_SUFFIX}); the URL given is not one. "
"Use --proxy-file for proxies from anywhere else.")
url = require_gateway_login(url)
rng = rng or random.Random()
seen, out = set(), []
while len(out) < count:
Expand Down
4 changes: 3 additions & 1 deletion puppeteer_scraper.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from urllib.parse import urlparse

import browser_bridge
import proxy_pool

# Module level, so the offline suite can skip this engine when pyppeteer is
# absent instead of importing cleanly and failing on first launch.
Expand Down Expand Up @@ -74,7 +75,8 @@ def start(self):
options["args"].append("--proxy-server=%s://%s:%s"
% (parts.scheme or "http", parts.hostname, parts.port))
if parts.username:
credentials = {"username": parts.username, "password": parts.password or ""}
login, password = proxy_pool.credentials_of(parts)
credentials = {"username": login, "password": password}
self._browser = self._run(launch(**options))
self._page = self._run(self._browser.newPage())
if credentials:
Expand Down
Loading
Loading