Metadata-Version: 2.5
Name: ncert-cli
Version: 1.0.1
Summary: Browse NCERT textbooks and download one as a single merged PDF.
Project-URL: Homepage, https://github.com/amanverma-765/ncert-cli
Project-URL: Issues, https://github.com/amanverma-765/ncert-cli/issues
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: platformdirs>=3.0
Requires-Dist: pypdf>=4.0
Requires-Dist: requests>=2.28
Requires-Dist: rich>=13.0
Description-Content-Type: text/markdown

# ncert-cli

Browse NCERT textbooks from the terminal and download any book as a single
merged PDF.

[![PyPI](https://img.shields.io/pypi/v/ncert-cli)](https://pypi.org/project/ncert-cli/)
![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)

```sh
uv tool install ncert-cli

ncert                 # browse class → subject → book
ncert --list          # every book and its code
ncert jesc1           # download one, no menus
ncert --class 10      # or a whole year
```

NCERT publishes each textbook as one PDF per chapter, so a single book is
fifteen separate downloads. This fetches them all and merges them into one
file, either through menus or straight from a script.

- [Demo](#demo)
- [Install](#install)
- [Upgrade and uninstall](#upgrade-and-uninstall)
- [Browsing](#browsing)
- [Downloading by code](#downloading-by-code)
- [Flags and exit codes](#flags-and-exit-codes)
- [Where files go](#where-files-go)
- [How it works](#how-it-works)
- [Troubleshooting](#troubleshooting)
- [Layout](#layout)
- [Development](#development)
- [Tests](#tests)
- [Legal](#legal)

## Demo

![Browsing from class to subject to book](docs/demo.png)

After a download you stay on the book list, so grabbing Aroh and then Vitan
takes one keystroke each.

## Install

Needs Python 3.10 or newer. Pure Python - no compiler, no system packages.

**With [uv](https://docs.astral.sh/uv/) (recommended).** Installs into its own
isolated environment and puts `ncert` on your PATH. If your system Python is too
old, uv downloads a suitable one itself:

```sh
uv tool install ncert-cli
```

**With pipx:**

```sh
pipx install ncert-cli
```

**With pip**, if you would rather not add another tool:

```sh
pip install --user ncert-cli
```

On Debian/Ubuntu, `pip install --user` may refuse with
`externally-managed-environment`. That is the distro protecting its own Python -
use uv or pipx above, or install into a venv you made yourself.

**From a clone:**

```sh
git clone https://github.com/amanverma-765/ncert-cli
cd ncert-cli
uv tool install .
```

**For an offline machine**, build a wheel and carry it over. It is pure Python,
so one wheel works on Linux, macOS and Windows alike:

```sh
uv build                                              # → dist/
uv tool install dist/ncert_cli-1.0.0-py3-none-any.whl # on the target machine
```

Anywhere the package is importable, `python -m ncert_cli` runs it too - useful if
the install put `ncert` somewhere that is not on your PATH.

## Upgrade and uninstall

```sh
uv tool upgrade ncert-cli        # pipx upgrade ncert-cli
uv tool uninstall ncert-cli      # pipx uninstall ncert-cli
```

Uninstalling leaves the cached book list behind; see [Where files
go](#where-files-go) for the path to delete.

## Browsing

Run `ncert` with no arguments and pick your way through three menus - class,
then subject, then book:

| Key | Does |
| --- | --- |
| a number | Select that entry. |
| `↵` | Step back one menu. At the class menu there is nothing above, so it quits. |
| `q` | Quit from any depth. |
| `Ctrl-C` | During a menu, quits. During a download, cancels that book and returns to the menu. |

```sh
ncert                    # download into the current directory
ncert -o ~/books         # write somewhere else
ncert --no-prelims       # chapters only, drop the cover and contents
ncert --force            # re-download over an existing file
ncert --refresh          # rebuild the cached book list first
```

## Downloading by code

Naming a book on the command line skips the menus entirely, which is what you
want from a script, a Makefile, or a second terminal.

```sh
ncert --list                      # every book and its code
ncert --list --class 10           # narrow it down
ncert --list --subject science    # substring, case-insensitive
ncert --list | grep -i kshitij    # or just grep

ncert jesc1                       # download one book
ncert jesc1 jemh1 jeff1           # download several
ncert --class 10                  # download all of class X
ncert --class 10 --subject hindi  # filters are combined
ncert --class 10 --title kritika  # narrow a subject down to one book
ncert jesc1 --no-prelims          # chapters only
ncert jesc1 -o ~/books            # the other flags still apply
```

`--list` prints one line per book, which is where the codes come from:

```
  jesc1  Class X / Science / Science
  jhsc1  Class X / Science / Vigyan
```

**Why codes and not menu numbers.** A code such as `jesc1` is NCERT's own
identifier, the one every download URL is built from. It keeps meaning the same
book even if ncert.nic.in reorders its page, so a saved script does not quietly
start fetching something else. Positions in the menus carry no such promise.

Four codes in the catalog are shared by two language editions of one book, so a
code selects one *or more* books and all of the matches download.

Codes and filters build a single list, deduplicated, so `ncert --class 10 jesc1`
downloads Science once. Everything resolves before anything downloads: a typo
fails the run up front rather than 40 MB in. Books already present are skipped
unless `--force` is given, and `Ctrl-C` ends the whole run rather than skipping
to the next book.

## Flags and exit codes

| Flag | Effect |
| --- | --- |
| `CODE ...` | Book codes to download, e.g. `jesc1`. Omit to browse interactively. |
| `--class N` | Select every book in class N, e.g. `--class 10`. |
| `--subject TEXT` | Select books whose subject contains TEXT, case-insensitive. |
| `--title TEXT` | Select books whose title contains TEXT, case-insensitive. |
| `--list` | Print the selected books and their codes, download nothing. |
| `-o`, `--outdir PATH` | Where to write the PDFs. Default: the current directory. Created if missing. |
| `--no-prelims` | Leave out the cover and table of contents. They are merged ahead of chapter 1 by default. |
| `--force` | Rebuild a book whose output file already exists. |
| `--refresh` | Refetch the book list from ncert.nic.in instead of using the cache. |
| `-h`, `--help` | Usage, worked examples, and exit. |

| Code | Means |
| --- | --- |
| `0` | Every selected book is in place, or you left the menus cleanly. |
| `1` | The catalog could not be fetched or parsed, no book matched what you asked for, or a book failed to download. |
| `130` | Interrupted. |

## Where files go

Books are written to `--outdir` as `class_10_science_science.pdf` - lowercase,
no spaces, arabic class number so a directory of them sorts sensibly and never
collides with the "XI & XII Combined" pseudo-class.

The book list is fetched once and cached:

| OS | Path |
| --- | --- |
| Linux | `~/.cache/ncert-cli/catalog.html` |
| macOS | `~/Library/Caches/ncert-cli/catalog.html` |
| Windows | `%LOCALAPPDATA%\ncert-cli\ncert-cli\Cache\catalog.html` |

What is cached is the page itself, not a derived format. Re-parsing it costs
about 5 ms, so startup is instant, and a cache left over from a site redesign
simply fails to parse and refetches - no manual `--refresh` needed. Deleting the
file by hand is always safe.

## How it works

`ncert.nic.in/textbook.php` is entirely client-side. Requesting it with any
query string returns the same HTML; the class, subject and book dropdowns are
populated by two inline JavaScript functions, and the chapter list is rendered
from `location.search`. There is no API to call.

So the catalog is scraped out of that JavaScript (`catalog/parser.py`) and the
PDFs are fetched directly:

| Asset | URL |
| --- | --- |
| Chapter *n* | `/textbook/pdf/{code}{n:02d}.pdf` |
| Prelims | `/textbook/pdf/{code}ps.pdf` |
| Whole book | `/textbook/pdf/{code}dd.zip` |

The `dd.zip` bundle is not linked anywhere on the site. Most books have one, so
it is tried first as a single request; books without one are assembled chapter
by chapter.

Two quirks worth knowing, both covered by tests:

- Retired books are left in the page as commented-out JavaScript, so commented
  lines must be skipped or withdrawn titles reappear in the menu.
- A few books are stored with a doubled dot before the extension
  (`jehp106..pdf`), in the bundle and on the server alike. Matching only
  `NN.pdf` silently drops those chapters.

## Troubleshooting

**`Error: could not reach ncert.nic.in (...)`** - the site is down, slow, or
your connection dropped. Connection failures and 429/5xx responses are already
retried before you see this. Try again later.

**`Error: failed after 4 attempts: ...dd.zip`** - the connection broke partway
through the file and every retry broke too. The site sometimes serves at a few
kB/s, and the larger bundles are 70 MB. Try again later; `--refresh` will not
help - this is the transfer failing, not the catalog.

**`Error: no book with code '...'`** - the code is not in the catalog. Run
`ncert --list` and grep for the title.

**`curl` fails against ncert.nic.in but `ncert` works.** Not a bug on either
side: the host negotiates a cipher that strict OpenSSL 3 builds reject.
`net/session.py` relaxes the policy to `DEFAULT@SECLEVEL=1` for this host, which
is what `curl --ciphers DEFAULT@SECLEVEL=1` does manually.

**`Chapters not available on the server: 11, 12, 13`** - NCERT is genuinely
missing those files. The merged PDF is still produced from the chapters that do
exist, and the summary line tells you how many made it in.

**`Skipped N unreadable file(s)`** - a chapter downloaded but would not parse as
a PDF. One damaged chapter costs that chapter, not the whole book.

**`No bundle for this book - fetching chapters individually.`** - normal for the
minority of books with no `dd.zip`. Just slower: one request per chapter.

**`Already downloaded: ... (delete it or use --force to rebuild)`** - the output
file exists. Pass `--force` to overwrite it.

**A book you know exists is missing from the menu** - NCERT added it after your
cache was written. Run `ncert --refresh`.

**`requires a different Python`** on install - your interpreter is older than
3.10. `uv tool install` sidesteps this by fetching its own.

## Layout

```
src/ncert_cli/
  cli.py           argument parsing, the ncert entry point
  __main__.py      python -m ncert_cli
  config.py        constants and cache locations
  models.py        Book, Catalog, BookFiles
  naming.py        snake_case output filenames
  errors.py        exception hierarchy
  net/             session (TLS, retries) and verified streaming downloads
  catalog/         parser (pure), repository (fetch, cache), selector resolution
  books/           URL shapes, bundle/chapter sources, merging, orchestration
  ui/              console, menu, REPL navigation, batch mode
```

`catalog/parser.py` is a pure `html -> Catalog` function with no I/O, which is
what makes the test suite run entirely offline against a fixture.
`books/service.py` orchestrates one book through callbacks rather than printing,
which is why the REPL and the non-interactive path share it unchanged.

## Development

```sh
uv sync                      # environment from uv.lock, exact tested versions
uv run ncert                 # run from the checkout
uvx pre-commit install       # once per clone - wires up the ruff hooks
uv run ruff check            # lint
uv run ruff format           # format
```

The pre-commit hooks are `repo: local` and shell out to `uv run ruff`, so the
project's dev dependency is the only ruff version involved and there is no
second pin to keep in sync. Upgrading is `uv add --dev ruff@latest`, nothing
else.

## Tests

```sh
uv run pytest
```

100 tests, no network needed. The catalog parser is a pure `html -> Catalog`
function so it runs against a fixture; the download pipeline
(`test_service.py`, `test_sources.py`, `test_merger.py`) runs against fake
sessions, zips built in `tmp_path`, and real single-page PDFs; selector
resolution and CLI dispatch (`test_select.py`, `test_cli.py`) run against a
hand-built catalog with the per-book download stubbed out. `python3 -m pytest`
also works in a bare checkout with no environment synced.

## Legal

NCERT's own textbook page states that entire books or individual chapters may
be downloaded subject to its copyright notice. This tool only automates
retrieval of files the site already offers.

The tool itself is MIT licensed - see [LICENSE](LICENSE). That covers this code,
not the textbooks it downloads.
