Metadata-Version: 2.5
Name: hcap-replicator
Version: 0.1.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
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: 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. One target only: `dashboard.iproyal.com`. 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      # once published; from source: uv add ./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="c7035b11-f1df-41ef-b947-0d754c76a210",
        website_url="https://dashboard.iproyal.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 | used by |
|---|---|---|
| `hcaptcha` *(default)* | `hcaptcha.com/1/api.js` | IPRoyal's login page |
| `recaptcha` | `google.com/recaptcha/api.js` | [`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 `https://dashboard.iproyal.com/login/`, paste the URL 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 `dashboard.iproyal.com`. 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`.

---

## Recovered target facts

All of this was read out of the shipped Vite bundle and confirmed against the
live API. It is what makes the tool work; recording it saves the next session an
hour of re-recon.

**Endpoint** — `_withdraw-status-label-C_XHOxxk.js`:

```js
mzt = async e => Z({ path: "v1/login", method: "POST", body: e })
TX  = { dashboard: "https://apid.iproyal.com", residential: "https://resi-api.iproyal.com" }
```

```
POST https://apid.iproyal.com/v1/login
Content-Type: application/json
Origin: https://dashboard.iproyal.com

{ "email": "...", "password": "...", "h_captcha_response": "<token>" }
```

**The wire field is `h_captcha_response` (snake_case)**, not the camelCase
`hCaptchaResponse` the frontend writes — a transform sits between them. Sending
the wrong name does not fail auth, it fails field validation, and the 422 names
the field. That is how it was recovered.

**Sitekey** — hardcoded in `HCaptcha.vue_vue_type_script_setup_true_lang-Ian8n7rE.js`:

```js
const s = "c7035b11-f1df-41ef-b947-0d754c76a210"
... sitekey: h(s), size: "invisible"
```

**The server really verifies the token.** A forged JWT returns:

```
422 {"error":{"code":"failed_validation",
     "message":"You need to prove you are a human.",
     "detailed_messages":{"h_captcha_response":["You need to prove you are a human."]}}}
```

The string `prove you are a human` appears in none of the 250 fetched bundle
chunks, so that answer comes from `apid.iproyal.com`, not from client-side
validation. Forgery is not an option; only a solved token is.

Empty token, for comparison:

```
422 ... "The h captcha response field is required."
```

**The frontend only POSTs after the widget reports a token** —
`index-VoerXWv1.js`:

```js
{ mutation: async r => {
    const g = await m.value?.executeAsync()
    return G({ ...r, hCaptchaResponse: g?.response ?? "" })
  },
  onSuccess: r => d.login(r.accessToken) }
```

An empty token never leaves the browser; it dies in the client schema.

**CORS is permissive.** A plain `fetch` from `https://dashboard.iproyal.com`
with only `Content-Type: application/json` is accepted. Adding a `User-Agent`
header breaks the preflight (`TypeError: Failed to fetch`), so the client sends
nothing extra — and the CLI mirrors that.

**Cloudflare fronts `/v1/login` and only that path.** This is worth knowing
before debugging a `probe` result:

| request | answer |
|---|---|
| `GET /` , `GET /v1/login` , `POST /` | `404 {"code":"route_not_found"}` — JSON, not blocked |
| `POST /v1/login` (first attempts) | `422 {"code":"failed_validation", ...}` — real answer |
| `POST /v1/login` (after repeated attempts) | `403` HTML challenge page |

So the WAF rule is **path-scoped**, not egress-scoped: the same browser fetch
that returned `422` a minute earlier later returned
`TypeError: Failed to fetch`, while `GET` kept passing the whole time. The
egress IP is the same for both (`103.154.148.197` — confirmed by comparing
`api.ipify.org` from the harness browser and from Python). Impersonating
`chrome131`, `chrome124`, `chrome110`, `chrome120`, `safari170`, `firefox133`
and `edge101` all returned the identical 403, so a TLS fingerprint is not the
variable — no `cf_clearance` cookie was ever issued either (the harness browser
holds zero `*.iproyal.com` cookies).

Practical consequence: `probe` can come back `cf_blocked` through no fault of
the token. Wait for the cooldown; retrying harder makes it worse.

**The challenge iframe is readable from CDP.** The hCaptcha frames are not
OOPIFs, so they share the page's CDP session. Brute-force
`Runtime.evaluate` `contextId` and read `location.hash`: `#frame=challenge` is
the challenge (520x570), `#frame=checkbox-invisible` the checkbox. Instruction
text and every tile/button rect are then readable directly from the frame's DOM
with no screenshot round trip — which is what would make an automated solver
viable later.

---

## 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
```

Neither path has been run yet — the project does not exist on PyPI until the
first successful upload.

---

## Status

- [x] Endpoint, wire field name, sitekey, client/server split of the check
- [x] Both render paths + `probe` to decide between them
- [x] WAF behaviour of `/v1/login` mapped (path-scoped, cooldown, not fingerprint)
- [ ] 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.
