Metadata-Version: 2.5
Name: nepse-tms-captcha
Version: 0.1.0
Summary: Offline solver for NEPSE TMS login captchas — no network, no third-party service.
Project-URL: Homepage, https://github.com/DevAbhinav2073/nepse-tms-captcha
Project-URL: Repository, https://github.com/DevAbhinav2073/nepse-tms-captcha
Project-URL: Issues, https://github.com/DevAbhinav2073/nepse-tms-captcha/issues
Project-URL: Changelog, https://github.com/DevAbhinav2073/nepse-tms-captcha/blob/main/CHANGELOG.md
Author: Abhinav Dev
License: MIT License
        
        Copyright (c) 2023 Arpan Koirala (original TypeScript implementation,
                                         https://github.com/arpandaze/tms-captcha)
        Copyright (c) 2026 Abhinav Dev (Python port)
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: captcha,nepal,nepse,ocr,stock,tms,trading
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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 :: Scientific/Engineering :: Image Recognition
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Requires-Dist: numpy>=1.21
Requires-Dist: pillow>=9.0
Provides-Extra: dev
Requires-Dist: build>=1.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: twine>=5.0; extra == 'dev'
Description-Content-Type: text/markdown

# nepse-tms-captcha

[![PyPI](https://img.shields.io/pypi/v/nepse-tms-captcha.svg)](https://pypi.org/project/nepse-tms-captcha/)
[![Python](https://img.shields.io/pypi/pyversions/nepse-tms-captcha.svg)](https://pypi.org/project/nepse-tms-captcha/)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

Offline solver for the login captchas on Nepal's [NEPSE TMS](https://tms35.nepsetms.com.np/) broker portals.

Solving takes about a millisecond of arithmetic. There is **no machine learning model, no network call, and no third-party captcha service** — your captcha images and credentials never leave your machine.

```python
from tms_captcha import solve

result = solve("captcha.png")
if result:
    print(result.value)      # 'qij450'
```

---

## Contents

- [How it works](#how-it-works)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Usage guide](#usage-guide)
  - [Input formats](#input-formats)
  - [Interpreting the result](#interpreting-the-result)
  - [Reusing a solver](#reusing-a-solver)
  - [End-to-end: logging in to TMS](#end-to-end-logging-in-to-tms)
- [Command line](#command-line)
- [Accuracy](#accuracy)
- [Recalibrating](#recalibrating)
- [API reference](#api-reference)
- [Troubleshooting](#troubleshooting)
- [Credits](#credits)
- [Responsible use](#responsible-use)

---

## How it works

TMS captchas are 290×80 images: dark text over a noisy background. The trick is that **the background never changes** — it is the same noise on every captcha. So it can simply be subtracted away.

```
   ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
   │ ▒▒▒qij450▒▒▒ │  −  │ ▒▒▒▒▒▒▒▒▒▒▒▒ │  =  │    qij450    │
   │   captcha    │     │  reference   │     │  clean text  │
   └──────────────┘     └──────────────┘     └──────────────┘
```

1. **Subtract** the captcha from a stored reference of an empty captcha, clamping negatives to zero. Only the glyph strokes survive.
2. **Amplify and threshold** to a clean black-and-white mask.
3. **Split** on fully blank columns — each gap is a character boundary.
4. **Match** each glyph against a table of reference glyphs using five cheap statistics and a weighted L1 distance:

   | # | Feature | Weight |
   |---|---------|--------|
   | 0 | Total ink | 1 |
   | 1 | Ink in the left half | 3 |
   | 2 | Ink in the top half | 2 |
   | 3 | Ink in the bottom half | 8 |
   | 4 | Glyph width | 3 |

A match is rejected when the best distance exceeds 60, or when the runner-up is within 5 — meaning the two candidates are indistinguishable and picking either would be a coin flip. TMS renders captchas in two weights, **bold** and **slim**; the solver tries bold first and falls back to slim.

## Installation

```bash
pip install nepse-tms-captcha
```

Requires Python 3.9+. The only dependencies are `numpy` and `pillow`.

<details>
<summary>Installing from source</summary>

```bash
git clone https://github.com/DevAbhinav2073/nepse-tms-captcha.git
cd nepse-tms-captcha
pip install -e ".[dev]"
pytest
```
</details>

## Quick start

```python
from tms_captcha import solve

result = solve("captcha.png")

print(result.value)     # 'qij450'  — the solved text
print(result.ok)        # True
print(result.status)    # Status.SUCCESS
print(result.kind)      # Kind.BOLD  — which glyph table matched
```

`SolveResult` is falsy when solving fails, so the common case reads naturally:

```python
if result := solve(png_bytes):
    log_in(captcha=result.value)
else:
    # Don't retry the same image — captchas are single use.
    # Fetch a fresh one and try again.
    ...
```

## Usage guide

### Input formats

`solve()` accepts anything you are likely to have on hand:

```python
from pathlib import Path
from PIL import Image
from tms_captcha import solve

solve("captcha.png")                       # path as str
solve(Path("captcha.png"))                 # pathlib.Path
solve(response.content)                    # raw bytes, e.g. straight from requests
solve(open("captcha.png", "rb"))           # any file-like object
solve(Image.open("captcha.png"))           # a PIL image
solve("data:image/png;base64,iVBORw0...")  # a data: URI
```

Raw bytes is usually what you want: the TMS API hands back a PNG body, so it can be piped straight in without touching disk.

### Interpreting the result

| `status` | Meaning | What to do |
|----------|---------|------------|
| `Status.SUCCESS` | Six glyphs, all matched confidently | Use `result.value` |
| `Status.LOW_CONFIDENCE` | A glyph was ambiguous or matched nothing well | Fetch a fresh captcha and retry |
| `Status.INVALID_LENGTH` | The image did not split into six glyphs | Fetch a fresh captcha and retry |

On a failure, `result.value` holds the partial text decoded so far, which is useful for debugging but should not be submitted.

`result.scores` gives per-character `(distance, margin)` pairs — the matched distance and how far ahead of the runner-up it was. Larger margins mean more certainty:

```python
for char, (distance, margin) in zip(result.value, result.scores):
    print(f"{char}: distance={distance:.1f} margin={margin:.1f}")
```

Because a captcha is single use, **retry with a fresh image rather than re-solving the same one** — the answer will not change.

### Reusing a solver

`solve()` uses a lazily created process-wide solver, which is fine for most uses. If you are solving in a loop or a long-lived service, construct a `Solver` once — loading the reference tables costs more than solving does:

```python
from tms_captcha import Solver

solver = Solver()
for image in images:
    print(solver.solve(image).value)
```

### End-to-end: logging in to TMS

A complete unattended login against a broker portal, with retries:

```python
import base64
import httpx
from tms_captcha import Solver

BASE = "https://tms35.nepsetms.com.np"
solver = Solver(min_margin=12)   # prefer a retry over a rejected attempt


def login(client: httpx.Client, username: str, password: str, attempts: int = 5):
    for _ in range(attempts):
        captcha_id = client.get(f"{BASE}/tmsapi/authApi/captcha/id").json()["id"]
        image = client.get(f"{BASE}/tmsapi/authApi/captcha/image/{captcha_id}").content

        result = solver.solve(image)
        if not result:
            continue          # unreadable render; fetch another

        response = client.post(f"{BASE}/tmsapi/authApi/authenticate", json={
            "userName": username,
            "password": base64.b64encode(password.encode()).decode(),
            "jwt": "", "otp": "",
            "captchaIdentifier": captcha_id,
            "userCaptcha": result.value,
        })
        if response.status_code == 200:
            return response.json()

        # status 108 is "wrong captcha" — anything else is a real failure
        if response.json().get("status") != "108":
            raise RuntimeError(f"login failed: {response.text}")

    raise RuntimeError(f"captcha not solved after {attempts} attempts")
```

A handful of attempts makes an unreadable render a non-event: each retry costs one HTTP round trip.

## Command line

```bash
# Solve one or more images
tms-captcha solve captcha.png
# captcha.png: qij450

# Just the text, for scripting
tms-captcha solve --quiet captcha.png
# qij450

# From stdin
curl -s "$TMS/tmsapi/authApi/captcha/image/$ID" | tms-captcha solve -q -

# Several at once, as JSON
tms-captcha solve --json *.png

# Force a glyph table instead of auto-detecting
tms-captcha solve --kind slim captcha.png
```

Exit codes: `0` solved, `1` could not be solved confidently, `2` bad input.

## Accuracy

Measured on the 52 labelled captchas from the upstream extension (the filename is the correct answer):

| Metric | Result |
|--------|--------|
| Solved correctly | **43 / 52 (82.7%)** |
| Precision — of answers reported as `SUCCESS`, how many were right | **43 / 45 (95.6%)** |
| Reported honestly as unsure rather than guessed | 7 / 9 failures |
| Solve time | **~1 ms** |

Broken down by glyph set: **bold 25/27 (92.6%)**, **slim 18/25 (72%)**.

The nine failures are inherited from the original implementation, not introduced by the port — recalibrating from these images reproduces upstream's tables to within 1e-9, so the original misreads them too. They fall into three groups, each pinned individually in the test suite:

- **4 touching glyphs** — characters overlap, so the blank-column split finds only five. Unfixable without a different segmentation approach.
- **3 low confidence** — an ambiguous glyph; the solver correctly declines to answer.
- **2 confidently wrong** — `3m0y55` → `3m0y5e`, `tyx099` → `ty3099`. These are the ones that matter, because they get submitted.

**This is why retrying matters more than raw accuracy.** A failure costs one extra captcha fetch, which is free. At 82.7% per attempt, five attempts succeed 99.98% of the time.

### Trading recall for precision

If a wrong submission is more expensive than another round trip, raise `min_margin`:

```python
solver = Solver(min_margin=12)   # no wrong answers on the fixture set
```

| `min_margin` | Solved | Precision | Confidently wrong |
|--------------|--------|-----------|-------------------|
| 5 (default, matches upstream) | 82.7% | 95.6% | 2 |
| 8 | 78.8% | 97.6% | 1 |
| **12** | 63.5% | **100%** | **0** |
| 15 | 48.1% | 100% | 0 |

Consider this for automated login: fetching a captcha costs nothing, but a rejected login attempt may count against the account (TMS tracks a `loginAttempts` counter on the user record — this package's author has not established what threshold, if any, triggers a lockout, so the cautious setting is the safer default for unattended use).

## Recalibrating

If TMS changes its captcha rendering, results will degrade and you can rebuild the tables from a fresh batch of solved captchas. Name each image after its solution:

```
images/
├── qij450.png
├── 5yxd70.png
└── cxxivw.png
```

```bash
tms-captcha calibrate images/ -o bold_data.json --pretty
```

```python
from tms_captcha.calibrate import calibrate, write_table

table = calibrate("images/")            # {'0': [149.58, 77.03, ...], ...}
write_table("images/", "bold_data.json")
```

Images that do not split into exactly six glyphs are skipped, since their glyph-to-label alignment cannot be trusted. Around 25–30 images gives good coverage of the alphabet.

Note the alphabet excludes **`l`** and **`z`** — TMS does not render them, presumably because they are too easily confused with `1` and `2`.

## API reference

### `solve(source, kind=None) -> SolveResult`

Solve a captcha using the shared process-wide solver.

- **`source`** — path, bytes, data URI, file object, or PIL image
- **`kind`** — `Kind.BOLD` or `Kind.SLIM` to pin a table; `None` (default) tries bold then slim

### `Solver(data_dir=DATA_DIR, *, max_distance=60, min_margin=5)`

Reusable solver holding the reference tables in memory. `max_distance` and
`min_margin` set how sure it must be before reporting success — see
[Trading recall for precision](#trading-recall-for-precision).

| Member | Description |
|--------|-------------|
| `solve(source, kind=None)` | As above |
| `features(source)` | Feature vectors per glyph — for calibration and debugging |
| `alphabet` | Characters the tables can produce |

### `SolveResult`

| Attribute | Type | Description |
|-----------|------|-------------|
| `value` | `str` | Solved text (partial on failure) |
| `status` | `Status` | `SUCCESS`, `LOW_CONFIDENCE`, or `INVALID_LENGTH` |
| `kind` | `Kind \| None` | Which table matched |
| `scores` | `tuple[tuple[float, float], ...]` | Per-character `(distance, margin)` |
| `ok` | `bool` | True on success; also `bool(result)` |

### Exceptions

`CaptchaError` (a `ValueError`) is raised for a wrong-sized image or an unsupported source type. A failure to *recognise* is reported through `status`, not by raising.

## Troubleshooting

**`CaptchaError: expected a 290x80 TMS captcha`** — the image is not a raw TMS captcha. Fetch it from `/tmsapi/authApi/captcha/image/{id}` rather than screenshotting the page, since a screenshot is scaled and cropped differently.

**Everything returns `LOW_CONFIDENCE`** — either the images are not TMS captchas, or TMS has changed its rendering. Check that a captcha still looks like dark text on the familiar noise, then [recalibrate](#recalibrating).

**`INVALID_LENGTH` on some captchas** — glyphs are touching, so the blank-column split cannot separate them. Retry with a fresh captcha.

**Login fails even though the captcha was solved** — captchas are single use. A captcha id that has already been submitted will be rejected no matter how correct the text is; request a new id for every attempt.

## Credits

This is a Python port of [**arpandaze/tms-captcha**](https://github.com/arpandaze/tms-captcha) by **Arpan Koirala** — the browser extension that devised this approach. The algorithm and the reference glyph tables are theirs; this package reimplements the pipeline in Python, reproducing the original's feature extraction exactly.

Both the original and this port are MIT licensed. See [LICENSE](LICENSE).

## Responsible use

This tool exists so that **you can automate logging into your own broker account**. It does not bypass authentication — you still need valid credentials, and the captcha is only a bot-speed-bump in front of a login you are entitled to perform.

Please use it accordingly: don't point it at accounts that aren't yours, and don't use it to hammer broker infrastructure. Rate-limit your retries and be a good citizen of a system that a lot of people depend on.
