Metadata-Version: 2.5
Name: hcap-replicator
Version: 0.2.0
Summary: Serve a local replica of an hCaptcha widget and capture the token you solve by hand.
Project-URL: Homepage, https://github.com/fdjrr/hcap-replicator
Project-URL: Repository, https://github.com/fdjrr/hcap-replicator
Project-URL: Issues, https://github.com/fdjrr/hcap-replicator/issues
Author: fdjrr
License-Expression: MIT
License-File: LICENSE
Keywords: automation,captcha,hcaptcha,login,replicator,token
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: curl-cffi>=0.7.4
Requires-Dist: drissionpage>=4.1
Requires-Dist: loguru>=0.7.2
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# hcap-replicator

Render a target site's **hCaptcha sitekey as a VISIBLE widget**, take the token
you solve by hand, and drive the site's login API with it.

**Purpose.** The target mounts hCaptcha as `size: "invisible"`. Every challenge
step then runs under hCaptcha's per-step time limit, which is hostile to any
solver that needs more than a second per step:

```
HCAPTCHA: Time limit to answer challenge has expired
```

Rendering the *same sitekey* as `size: "normal"` takes the clock off the solve.
You solve it whenever you like, the token is copied for you, and the CLI hands
it to the login endpoint.

**Ownership / scope.** The account, credentials and target belong to the
operator. Nothing here is pinned to one vendor: the sitekey, the origin and the
login endpoint are configuration (`--sitekey` / `--origin` / `--api-base`, or
`HR_*`), so point them at whatever site you are solving for. The password is read via
`getpass` or `HR_PASSWORD` and is never written to a file, never logged, never
printed. hCaptcha tokens are single-use and short-lived; treat them like a
one-time code.

---

## Install

```bash
uv sync --extra dev
uv run hcap-rep info
```

As a library:

```bash
uv add hcap-replicator                   # from PyPI; from source: ./hcap-replicator
```

Configuration lives in `.env` (see `.env.example`). CLI flags always beat
environment variables, environment beats `.env`.

---

## Library usage

Shaped after `recaptcha-domain-replicator`: a context-manager facade and a
`TokenHandle` you block on.

```python
from hcap_replicator import HcapReplicator

with HcapReplicator(port=0, open_browser=False) as rep:
    _, handle = rep.replicate_captcha(
        website_key="<sitekey, read it off the target page>",
        website_url="https://example.com",
    )
    print("open this:", rep.url)          # point any browser at it
    token = handle.wait(timeout=120)      # resolves when the page posts back
```

- `opener` decides where the page shows up and its return value is what
  `replicate_captcha` reports as the "browser" half — pass your own to drive a
  harness, or leave `open_browser=False` and open `rep.url` yourself.
- The page `POST /__token`s the token back, so no clipboard permission, no
  secure-context requirement, and a headless caller works the same as a human.
- `TokenHandle.wait(timeout)` returns the token or `None`; it never raises.
- `from hcap_replicator import ...` is lazy (PEP 562) — importing the package
  does not start anything.

```python
from hcap_replicator import target
result = target.probe(token)      # no password needed
if result.ok:
    result = target.login(email, password, token)
```

---

## Providers

Two widget families, selected with `--provider` (or `provider=` in code):

| provider | script | this project uses it for |
|---|---|---|
| `hcaptcha` *(default)* | `hcaptcha.com/1/api.js` | the point of the package |
| `recaptcha` | `google.com/recaptcha/api.js` | integrating with [`fdjrr/agent-solver`](https://github.com/fdjrr/agent-solver) |

`--size normal` *(default)* renders a checkbox you can take your time with;
`--size invisible` reproduces what the real login page does and re-introduces
the per-step time limit — useful only for reproducing a failure.

```bash
uv run hcap-rep serve --provider recaptcha --size normal --port 8080
uv run hcap-rep bookmarklet --provider recaptcha
```

The reCAPTCHA page `grecaptcha.execute()`s an invisible widget so a challenge
still appears, and posts the token back to `POST /__token` exactly like the
hCaptcha one.

---

## Workflow

### 1. Solve manually — two paths, try them in this order

**Path A — local page (least work):**

```bash
uv run hcap-rep serve          # prints http://127.0.0.1:8899/, waits, prints the token
```

Open it in any browser, solve the visible widget — the page posts the token
back to the server itself and `serve` prints it when it arrives. `--no-wait`
just starts and prints the URL; `--timeout` bounds the wait.

**Path A alternative — the library**, when a caller owns the flow:

```python
with HcapReplicator(port=0, open_browser=False) as rep:
    _, handle = rep.replicate_captcha(website_key=KEY, website_url=ORIGIN)
    token = handle.wait(timeout=120)
```

**Path B — bookmarklet on the real origin (guaranteed host match):**

```bash
uv run hcap-rep bookmarklet    # prints a javascript: URL
```

Open the real page that mounts the widget — its login page, say — paste the
bookmarklet into the address bar (or the devtools console), solve, copy the
token.

The bookmarklet runs on the *target's* origin, so it cannot post back to a
server of ours — it shows the token and copies it to the clipboard instead.
That is inherent to Path B: whatever page the bookmarklet runs on is the only
origin it can talk to.

Why two: **hCaptcha binds a token to the hostname that minted it**, and the
target's `siteverify` compares it against its own host. Path A mints on
another host and may be refused. There is no way to know without one manual
solve — which is exactly why the CLI ships a `probe` that answers the question
in one request. If Path A comes back `token_rejected`, go straight to Path B.

### 2. Ask the CLI whether the token is any good

```bash
uv run hcap-rep probe --token "$TOKEN"
```

`probe` posts to the real login endpoint with **placeholder credentials**, so it
needs no password. The verdicts:

| verdict | meaning | next |
|---|---|---|
| `token_accepted` | the captcha gate was cleared (server complained about credentials instead) | run `login` |
| `token_rejected` | `siteverify` refused it — wrong host | use Path B |
| `token_missing` | empty/absent field | re-copy the token |
| `cf_blocked` | Cloudflare answered an HTML page for this egress | retry, or `--impersonate` another profile |
| `rate_limited` / `server_error` / `network_error` | transport-side | retry |

`--json` for machine-readable output. Exit code `0` only on `token_accepted`.

### 3. Log in

```bash
export HR_PASSWORD='...'                 # or let it prompt via getpass
uv run hcap-rep login --email you@example.com --token "$TOKEN"
```

Success means `accessToken` came back. Exit code `0`.

---

## Browser

Two ways the replica gets in front of someone:

- **Spawned** — `--spawn-browser` (or `spawn_browser=True`) opens the page in a
  managed Chromium built by DrissionPage: one port per browser so concurrent
  replicas never share one, `--no-sandbox --disable-dev-shm-usage` because the
  stock binary cannot start its sandbox in a container, headless automatically
  when `$DISPLAY` is unset. `close()` quits the browser with the replicator, so
  a caller that closes on its token never leaks a Chromium.
- **System opener** — the default is `webbrowser.open`, which on a box with no
  desktop session opens nothing at all: the URL is in the response and nobody
  is looking at it.

```bash
uv run hcap-rep serve --spawn-browser
uv run hcap-rep serve --spawn-browser --headless false   # force a window
```

| env | default | meaning |
|---|---|---|
| `HR_SPAWN_BROWSER` | `false` | spawn Chromium for `serve` |
| `HR_BROWSER_PATH` | *(auto)* | binary; else the newest Playwright build, else DrissionPage's lookup |
| `HR_HEADLESS` | `auto` | `true`/`false` to force; `auto` = headless when `$DISPLAY` is unset |

```python
with HcapReplicator(port=0, spawn_browser=True) as rep:
    _, handle = rep.replicate_captcha(website_key=KEY, website_url=ORIGIN)
    token = handle.wait(timeout=120)   # rep.close() quits the browser too
```

---

## Layout

```
main.py                          dev entrypoint — puts src/ on sys.path first
src/hcap_replicator/
  __init__.py                    lazy exports (PEP 562), __version__, NullHandler
  __main__.py                    `python -m hcap_replicator` + hcap-replicator script
  cli.py                         argparse (flags > env > .env), commands, scorecard
  constants.py                   package-wide defaults
  html_builder.py                build_page() / build_bookmarklet()
  replicator.py                  HcapReplicator facade (context manager)
  server_manager.py              PageServer — serves the page, receives /__token
  token_monitor.py               TokenHandle.wait(timeout)
  target.py                      Target dataclass + probe()/login()/classify()
  logging_utils.py               enable_console_logging()
  templates/                     local.html, bookmarklet.js (shipped in the wheel)
  py.typed                       PEP 561
tests/                           offline: config, classification, render, HTTP loop
conftest.py                      pins src/ + repo root so tests test THIS checkout
LICENSE                          MIT
```

Console scripts target the package, never a root `main` — a top-level `main`
module collides with other installed distributions and would not be importable
for anyone installing from PyPI:

```toml
[project.scripts]
hcap-rep = "hcap_replicator.cli:main"
hcap-replicator = "hcap_replicator.__main__:main"
```

Root `main.py` remains the dev entrypoint (`uv run python main.py`) and puts
`src/` on `sys.path` first, so it always runs the code on disk. The installed
`hcap-rep` imports whatever hatch last built — after editing the package:

```bash
uv sync --reinstall-package hcap-replicator
```

Every exit path routes through one scorecard line (`done: X ok, Y failed`) so a
wrapper script can tell "worked" from "ran and failed".

---

## Tests

```bash
uv run pytest -q
```

Offline only: argument/env precedence, the verdict classifications (including
the one that matters — a Cloudflare HTML page must not be read as a captcha
answer), asset substitution, the entrypoint's exit codes, and the whole
token-over-HTTP loop against a real bound socket (`PageServer` serves the page,
a request `POST /__token`s, `TokenHandle.wait()` resolves). `probe` and `login`
are deliberately *not* mocked: a mocked verdict would only prove the mock. They
are exercised by hand with a real token.

Release check:

```bash
uv build && uv run pytest -q
```

---

## Publishing

Version lives in `pyproject.toml`; PyPI is append-only per version, so bump it
before every upload. Both paths start the same way:

```bash
uv sync --extra dev
rm -rf dist && uv build
uvx twine check dist/*            # metadata + long-description rendering
```

### A. Trusted publishing — recommended, no token exists anywhere

`.github/workflows/publish.yml` builds, checks, installs the wheel into a clean
venv, then publishes via OIDC. PyPI mints a short-lived upload token for the
run; nothing is stored in GitHub or on this machine.

One-time setup on PyPI (**Account → Publishing → Add a new pending publisher**):

| field | value |
|---|---|
| PyPI Project Name | `hcap-replicator` |
| Owner | `fdjrr` |
| Repository name | `hcap-replicator` |
| Workflow name | `publish.yml` |
| Environment name | *(leave blank — this workflow uses none)* |

Then trigger it: push a GitHub release, or **Actions → Publish to PyPI → Run
workflow**. Works for this private repo; no `PYPI_TOKEN` secret is involved.

### B. Local upload with an API token

Create a token scoped to just this project (PyPI → Account settings → API
tokens), keep it out of the repo, and pass it through the environment:

```bash
uv publish --token "$PYPI_TOKEN" dist/*
```

`uv publish` reads `PYPI_TOKEN` from the environment too, so the flag is
optional. Verify afterwards:

```bash
pip index versions hcap-replicator
```

### Shipped

0.1.0 was published with path A — run
[`37095334121`](https://github.com/fdjrr/hcap-replicator/actions/runs/37095334121),
`build` + `publish` both green, attestations generated. Verified from a clean
venv pulling from PyPI rather than the local `dist/`:

```bash
uv venv /tmp/v && uv pip install --python /tmp/v/bin/python hcap-replicator
/tmp/v/bin/python -c "import hcap_replicator as m; print(m.__version__)"   # 0.1.0
/tmp/v/bin/hcap-rep info
```

https://pypi.org/project/hcap-replicator/

---

## Status

- [x] Target contract recovered and encoded as `Target` + defaults (endpoint, wire field, sitekey)
- [x] Both render paths + `probe` to decide between them
- [x] WAF behaviour of `/v1/login` mapped (path-scoped, cooldown, not fingerprint)
- [x] Published to PyPI as 0.1.0 via trusted publishing — no token stored anywhere
- [ ] Whether a Path A token survives `siteverify` — needs one manual solve
- [ ] Autonomous solve. One grid step was answered correctly by vision (tile
      selected, button flipped `Skip` -> `Next`); the second step exceeded the
      time limit. The audio path would be the next thing to try.
