audora-literotica-py (0.1.0)
Installation
pip install --index-url audora-literotica-pyAbout 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_STORIESis 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 ofprovider.pyfor 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_countisUNSETfor a series fetched from a submission: the payload states one instalment's length and carries no per-chapter counts to sum.load_chaptersfills in the per-chapter figures.