audora-literotica-py (0.1.0)

Published 2026-08-12 06:25:38 +02:00 by aaron

Installation

pip install --index-url  audora-literotica-py

About this package

Async Literotica API client implementing the audora-provider-base contract

audora-literotica-py

An async Literotica client implementing the 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:

[[tool.uv.index]]
name = "forgejo"
url = "https://forge.towu.dev/api/packages/aaron/pypi/simple/"
uv add audora-literotica-py

With pip:

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:

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:

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

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:

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

Then once more the way CI does it:

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.
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.

Requirements

Requires Python: >=3.12
Details
PyPI
2026-08-12 06:25:38 +02:00
6
Proprietary
113 KiB
Assets (2)
Versions (1) View all
0.1.0 2026-08-12