Metadata-Version: 2.5
Name: audora-literotica-py
Version: 0.1.0
Summary: Async Literotica API client implementing the audora-provider-base contract
Author-email: Aaron <aaron@audora.art>
License: Proprietary
Keywords: api,async,fiction,literotica,provider
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: audora-provider-base<2.0.0,>=1.2.1
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# audora-literotica-py

An async [Literotica](https://www.literotica.com) client implementing the
[`audora-provider-base`](https://forge.towu.dev/aaron/-/packages/pypi/audora-provider-base)
contract.

> **Unofficial and reverse-engineered.** Literotica publishes no API
> documentation and makes no compatibility promise. Every endpoint here was
> found by inspecting live traffic, and any of them can change without notice.
> Internal use only.

Anonymous and read-only. Literotica requires no login to read stories, so this
package has no credentials, no session handling, and no write surface.

## Install

`audora-provider-base` lives on a Forgejo registry rather than PyPI, so the
index has to be configured or the dependency will not resolve — or worse, will
resolve to a name-squatted package on PyPI.

With `uv`, in `pyproject.toml`:

```toml
[[tool.uv.index]]
name = "forgejo"
url = "https://forge.towu.dev/api/packages/aaron/pypi/simple/"
```

```bash
uv add audora-literotica-py
```

With `pip`:

```bash
pip install --extra-index-url https://forge.towu.dev/api/packages/aaron/pypi/simple/ audora-literotica-py
```

## Usage

The entry point the package exists for — a title in, a whole story out:

```python
import asyncio
from audora_literotica import LiteroticaProvider


async def main() -> None:
    async with LiteroticaProvider() as lit:
        story = await lit.find_story("A Fresh Start Ch. 01")
        assert story is not None

        # 1 request so far. Bodies are one request per chapter, so they are
        # opt-in.
        await lit.load_chapters(story, with_text=True, allow_fanout=True)

        print(story.title, "by", story.author.username)
        print(story.chapter_count, "chapters,", story.tags)
        for chapter in story.chapters:
            print(f"  {chapter.number}. {chapter.title} ({chapter.word_count}w)")
        print(story.chapters[0].text[:400])


asyncio.run(main())
```

Searching, and reading a specific submission or series:

```python
async with LiteroticaProvider() as lit:
    async for story in lit.search_stories("truck stop"):
        print(story.title)  # lazy: one request per 50 results
        break

    await lit.get_story("a-fresh-start-ch-01")  # -> the series it belongs to
    await lit.get_story("se:19634")  # -> the same series, directly
    await lit.get_chapter("a-fresh-start-ch-02", with_text=True)
    await lit.get_user("Belisana")
```

## Notes specific to Literotica

**Two id-spaces.** Literotica has *submissions* (`/s/<slug>`) and *series*
(`/series/se/<id>`) that group them. A story reference is therefore either a
submission slug or a `se:`-prefixed series id — `"se:19634"`. A bare integer is
a legal slug, so the prefix is what keeps the two apart.

**A submission slug resolves to its series.** `get_story("a-fresh-start-ch-01")`
returns the whole three-chapter work, not the one instalment, because that is
almost always what the caller means. The chapter stubs ride along in the same
payload, so it costs one request. A submission in no series comes back as a
one-shot with a single chapter.

**Titles do not predict slugs.** The story titled "A Fresh Start" lives at
`a-new-start-10`; "House Calls Ch. 01" at `house-calls-one`. There is no
derivation rule, which is why `find_story` searches instead of guessing.

**Chapter text costs one request per chapter, sometimes more.** There is no bulk
text endpoint, so `load_chapters(with_text=True)` needs `allow_fanout=True`.
Worse, the JSON API serves only the *first page* of a chapter and silently
ignores its own `page` parameter — so a chapter over ~3,750 words costs one
extra request per additional page, read from the reader's HTML. A client that
trusted the API's `pageText` would truncate long chapters without any error;
this one assembles them and verifies against `words_count` in its live tests.

**Rate limiting.** Defaults to **1 request/second**, matching the only published
figure for this site (FanFicFare's `slow_down_sleep_time`). Literotica documents
no limit; pass `limiter=` if you know better.

**Everything is `EXPLICIT`.** `Story.maturity` is a platform fact here, not an
inference — there is no general-audience tier to distinguish.

**`UNSET` vs `None`.** `UNSET` means "this projection did not include the
field"; `None` means "Literotica says there is no value". Notably
`Story.language.code` is `UNSET` for every non-English story: the language is
numbered, only id 1 is confirmed, and a read never spends a request to look one
up.

**Caching is yours to decide.** No cache policy is imposed. Nothing is
account-scoped, so there is no credentialed traffic to keep out of a shared
backend.

## Development

```bash
python3.12 -m venv .venv
.venv/bin/pip install --extra-index-url https://forge.towu.dev/api/packages/aaron/pypi/simple/ -e ".[dev]"
```

The required loop after any edit — run all four:

```bash
.venv/bin/ruff format .
.venv/bin/ruff check --fix .
.venv/bin/mypy
.venv/bin/pytest
```

Then once more the way CI does it:

```bash
AUDORA_PROVIDER_BASE_STRICT=1 .venv/bin/pytest
```

### Testing

A default `pytest` run is offline and fast: it exercises the parsers, the text
assembler, the error mapper, the routes, and the provider's own plumbing against
recorded payloads in `tests/fixtures/`, captured verbatim from live responses.

| Variable | Effect when absent |
|---|---|
| `LITEROTICA_LIVE=1` | Every `live`-marked test skips. This is the whole network gate; there are no credentials to configure. |
| `LITEROTICA_STORY_SLUG` | Contract and live tests fall back to `a-fresh-start-ch-01`. |
| `LITEROTICA_SERIES_REF` | Falls back to `se:19634`. |
| `LITEROTICA_USER` | Falls back to `Belisana`. |
| `LITEROTICA_SEARCH_QUERY` | Falls back to `kenworth`: 124 results over 3 pages. Deliberately narrow — the contract suite walks the whole result set, so a generic term would issue thousands of requests. Any replacement needs >50 results and should stay in the low hundreds. |

```bash
LITEROTICA_LIVE=1 .venv/bin/pytest -m live -rs
```

Live tests are slow on purpose — the provider throttles itself to one request a
second — and they read a third-party site, so they are opt-in rather than
opt-out. Run `-rs` to see what skipped; a contract test that quietly does not
run is the main failure mode of a configuration-driven live suite.

## Caveats

- **Only `SEARCH_STORIES` is declared.** Comments, user search, and the language
  catalogue are all reachable in principle but were not verified, and declaring
  an unverified endpoint is worse than omitting it. See the "Deliberately
  absent" block at the foot of `provider.py` for the reasoning on each.
- **Search relevance is the platform's, and it is loose.** A query matches
  descriptions as well as titles, and audio submissions rank highly. Filter on
  `story.raw["type"] == "story"` for prose.
- **`Story.word_count` is `UNSET` for a series** fetched from a submission: the
  payload states one instalment's length and carries no per-chapter counts to
  sum. `load_chapters` fills in the per-chapter figures.
