Notes on the undocumented ranobelib API
Living document of findings about the api.cdnlibs.org API, gathered by manual inspection
(browser devtools / network tab) before implementing the corresponding SDK feature. Update
this file as new endpoints or edge cases are investigated — see CLAUDE.md for the current
list of open questions.
Confirmed
- Base URL:
https://api.cdnlibs.org/api, no authorization required for public content — but a non-emptyRefereris (see "WAF 403" below). GET /api/manga/{slug}/chaptersreturns the full chapter list (including fractional chapters) in a single response, keyed under"data". No pagination observed so far on a 308-chapter title.GET /api/manga/{slug}?fields[]=...returns title metadata; the full set of usefulfields[]values has not been catalogued yet.
Site scoping: Site-Id header
api.cdnlibs.org is shared across the whole lib.social network (mangalib, ranobelib,
hentailib, ...). Title-detail and chapter-list endpoints (GET /api/manga/{slug_url},
GET /api/manga/{slug_url}/chapters) need a Site-Id: 3 request header to scope the lookup
to ranobelib.me — 3 is the value found in <html data-id="3"> on ranobelib.me pages.
Without this header (or with the wrong site id), a request for a real, existing title
returns 404 with the same generic body as an actually-missing title:
{"data": {"toast": {"type": "silent", "message": "Not Found"}}}
i.e. a bare 404 from this API does not distinguish "wrong site" from "title doesn't exist" —
the SDK always sends the correct header, so in practice this collapses to TitleNotFoundError.
The GET /api/manga search/list endpoint uses a different mechanism: a site_id[]=3 query
parameter instead of the header (e.g. GET /api/manga?q=foo&site_id[]=3).
Accept: application/json is required for JSON error bodies
On error status codes (404 confirmed), the API serves an HTML SPA shell (the site's own
404 page) unless the request sends Accept: application/json — with that header it serves
the JSON error body shown above instead. Successful (2xx) responses are JSON either way. The
SDK client sends Accept: application/json on every request to avoid parsing HTML by
accident.
WAF 403: a non-empty Referer is required (checked 2026-09-30)
api.cdnlibs.org and cover.cdnlibs.org sit behind DDoS-Guard (Server: ddos-guard on
every response). By 2026-09-30 its edge started rejecting the SDK's requests — which until
then sent only Site-Id/Accept and httpx's default User-Agent: python-httpx/0.28.1 —
with a 403 HTML page, on every endpoint, before the request reaches the API:
403 - Forbidden . That's an error.
Client does not have access rights to the content so server is rejecting to give proper
response. That's all we know.
This is not the API's own 403: that one is JSON ({"message": "User is not logged in."},
see "Catalog listing/search" below) with Content-Type: application/json. The edge's 403 is
Content-Type: text/html; charset=utf-8, regardless of Accept.
Checked against the live API on 2026-09-30, same IP, on GET /api/manga/{slug} (Site-Id:
3), GET /api/manga?site_id[]=3&q=dxd and GET /api/constants?fields[]=genres:
Headers on top of Site-Id + Accept |
Result (all three endpoints) |
|---|---|
| none (the SDK's old behavior) | 403 HTML |
Origin: https://ranobelib.me + Referer: https://ranobelib.me/ |
200 JSON |
iPhone Safari User-Agent only |
403 HTML |
| all three | 200 JSON |
Origin: https://ranobelib.me only |
403 HTML |
Referer: https://ranobelib.me/ only |
200 JSON |
Referer: https://ranobelib.me (no trailing /) only |
200 JSON |
Origin: https://example.com only |
403 HTML |
Follow-up the same day (while reproducing AccessBlockedError for
examples/11_error_handling.py), GET /api/manga/{slug} only:
Headers on top of Site-Id + Accept |
Result |
|---|---|
Referer: https://example.com/ only |
200 JSON |
Referer: https://mangalib.me/ only |
200 JSON |
Referer: x only |
200 JSON |
Origin: https://ranobelib.me + Referer: https://example.com/ + UA |
200 JSON |
Referer: (empty value) only |
403 HTML |
So the filter only checks that a non-empty Referer is present — its value isn't
checked against ranobelib.me, or even for being a URL (the first round of checks above only
ever tried ranobelib.me values, so it wrongly read as "a ranobelib.me Referer";
corrected by the follow-up). Origin doesn't count, and User-Agent alone neither causes
nor fixes the block (no TLS/HTTP2 fingerprinting needed, at least as of this date). With all three headers,
/chapters and /chapter also return 200, and a missing chapter still returns the API's own
JSON 404, so error mapping is unaffected.
Images: a title cover on cover.cdnlibs.org returned the same 403 without headers and 200
with them (same rule: Referer: x and Referer: https://example.com/ got 200 too) — which silently dropped covers from epub/pdf exports, since illustration
downloads are best-effort. An in-chapter image on ranobelib.me/uploads/... returned 200
either way (also behind ddos-guard, just not filtered by Referer at the time).
What the SDK sends as a result (ranobelib/_http.py's BROWSER_HEADERS): Origin,
Referer (https://ranobelib.me/, trailing slash, as the browser sends it) and an iPhone
Safari User-Agent, taken from a real browser session on the site. Only some Referer
is needed today, but the site's own value is sent, alongside the other two, so the request
as a whole matches the site's own traffic rather than half-imitating it — a real browser
never sends a site Referer with a python-httpx UA, and a stricter future rule is likelier
to check for ranobelib.me than to reject it. They go on every ApiClient request and on
the epub/pdf exporters' image clients (the latter without Site-Id/Accept, which are
API-specific).
headers= on RanobeLib, Catalog (and the underlying ApiClient) merges extra headers
over these (a given key replaces the default, case-insensitively), so a caller can send what
the edge expects if its rules change again, without waiting for an SDK release.
RanobeLib.export() passes the same headers on to the exporter
(Exporter.export(..., headers=...)), so epub/pdf illustration downloads — cover.cdnlibs.org
sits behind the same edge — use them too. This is the one ApiClient setting the facades
forward: the others (timeout, base_url, retry/pacing, see "Rate limiting и retry") tune
how the SDK talks to the API and have working defaults, while headers decide whether
the edge lets the SDK talk to it at all — without a way to change them from the facades,
a rule change on the site's side would leave every SDK user stuck until the next release.
AccessBlockedError vs AuthRequiredError
ApiClient used to turn any 403 into AuthRequiredError ("Authorization required") — a
misleading hint when it's the edge, not the API, refusing the request. The two are now told
apart by the response's Content-Type: application/json (the API's own 403, e.g. {"message":
"User is not logged in."}) → AuthRequiredError as before; anything else, including a
missing Content-Type (the edge's HTML page) → AccessBlockedError. Content-Type rather
than the page's text ("That's an error."), since the wording of an edge's error page is the
likeliest thing about it to change, while "the API answers in JSON" is the API's contract
(see "Accept: application/json is required for JSON error bodies" above: with that header
sent, even the API's own error pages are JSON).
AccessBlockedError isn't retried (403 isn't in ApiClient's retryable statuses, and
download_title()'s extra retry layer only rides out RateLimitError) — resending the same
request with the same headers gets the same answer. Inside download_title() it's still a
RanobeLibError, so it's wrapped in DownloadTitleInterruptedError with the chapters
already fetched, like any other mid-download failure.
Reproducible on demand — unlike AuthRequiredError, which needs a paywalled title — by
sending an empty Referer: RanobeLib(..., headers={"Referer": ""}) (that's what
examples/11_error_handling.py does, against the live API).
get_info() — verified fields[] list
Confirmed against a real title (91443--new-hero-in-dxd) with
Site-Id: 3 + Accept: application/json:
GET /api/manga/{slug_url}?fields[]=background&fields[]=eng_name&fields[]=otherNames
&fields[]=summary&fields[]=releaseDate&fields[]=genres&fields[]=tags&fields[]=teams
&fields[]=authors&fields[]=artists&fields[]=chap_count&fields[]=last_item_at
Notes on individual fields, since the mapping from requested fields[] name to response key
is not always 1:1:
chap_countdoes not add achap_countkey — it surfacesitems_count: {"uploaded": int, "total": int}instead.uploadedis the actual number of published chapters; this is what the SDK'sTitle.chapter_countis populated from.last_item_atadds alast_item_atISO timestamp — when the most recent chapter was published; the SDK'sTitle.last_chapter_at. See "Last chapter date" below for how it was verified and why the catalog listing can't provide it.status_id(not currently requested by the SDK) surfacesscanlateStatus: {"id", "label"}— the translation status (e.g. "Заброшен" / abandoned by the team), distinct from the title's ownstatusfield (e.g. "Онгоинг" / ongoing), which is present by default without requesting any extra fields.summaryis prosemirror-doc JSON ({"type": "doc", "content": [...]}). The SDK flattens it to plain text by concatenatingtextleaves paragraph by paragraph — deliberately simpler than the chapter-content renderer below, since a title summary doesn't need bold/italic/images preserved the way chapter content does.- Fields present without being requested:
id,name,rus_name,eng_name,model,slug,slug_url,cover,ageRestriction,site,type,is_licensed,content_marking,status,releaseDateString.
Last chapter date: last_item_at (issue #67, checked 2026-10-04)
Issue #67 asked for the date of a title's most recent chapter on Title, mainly for
Catalog.list_titles() items (catalog cards showing "обновлён <дата>").
- Title endpoint — available.
GET /api/manga/{slug_url}?fields[]=last_item_ataddslast_item_at(ISO timestamp, UTC). Not present by default. Checked that it's really the latest chapter's date: on6712--high-school-dxd-novel(308 chapters) and91443--new-hero-in-dxd(47 chapters) it equals the newestbranches[].created_atacross the title's whole/chapterslist, to the second. It's also the value the catalog'ssort_by=last_chapter_atorders by: the first six results of that sort, looked up one by one, came back in strictly descendinglast_item_atorder. Requested byget_info(), surfaced asTitle.last_chapter_at. - Catalog listing (
GET /api/manga) — not available. Unlike the title endpoint, the listing validatesfields[]: an unknown value is a 422 ("Выбранное значение для fields.0 ошибочно.", no list of allowed values). Rejected:last_item_at,last_chapter_at,lastChapterAt,last_chapter,lastItemAt,last_item,updated_at,chap_count,items_count,chapters,latest_chapter,new_chapters,views, and ~30 other guesses. Accepted, and none of them carry the date:metadata(only shiki/anilist sync timestamps, even withsort_by=last_chapter_at),created_at(the title's creation date),releaseDate,moderated,rate/rate_avg,userBookmark. The site's own catalog cards don't show a date either — its JS bundle only readslast_item_atin the home page's "latest updates" block. GET /api/latest-updates— available, but not a catalog. Items carrylast_item_at,items_count, andmetadata.latest_items: {"count": N, "items": [...]}— the site renderscount - 1as "+ ещё N глав" (the "+N" badge the issue's nice-to-have asked about). It paginates (page=2gives different items) but ignoresgenres[],q,limit,per_page, so it can't stand in forlist_titles(). Not wrapped by the SDK.
So Title.last_chapter_at is filled in by RanobeLib.get_info() only, and stays None on
Catalog.list_titles() items. Fetching it per item (one title request per catalog row, ~30
per page) was considered and left out: a hidden N+1 on a listing call, with the rate limit
that comes with it, is a caller-visible cost the SDK shouldn't take on implicitly.
Chapter count on listing items: also not available (issue #74, checked 2026-10-04)
Issue #74 asked for chapter_count alongside last_chapter_at on Catalog.list_titles()
items, and named two places the probe above hadn't looked: whatever ranobelib.me's own
catalog page sends, and a batch endpoint. Both checked, and the answer is no: the listing
can't return either value.
- The site's catalog request. Read from the site's JS bundle (the store that loads the
catalog grid): it calls
GET /mangawith the filter params plus exactlyfields: ["rate", "rate_avg", "userBookmark"], and nothing else. The site's catalog cards don't show a chapter count or a date. Where the site does show a chapter count for a catalog entry, in the hover popup, it fetches the title on its own (GET /manga/{slug}withfields: ["genres", "tags", "releaseDate", "summary", "status_id", "chap_count"]), one request per hovered card. So the site itself does per-title requests, on demand. fields[]on the listing, re-probed.chap_count(the name that givesitems_counton the title endpoint),items_count,itemsCount,chapters_count,chaptersCount,count_chapters,chap_count_uploaded,items_count_uploaded,uploaded,items,latest_items,last_items,stats,counters,info,status_id,type,formatall return 422. The accepted ones haven't changed (metadata,moderated,created_at,releaseDate,rate,rate_avg,userBookmark). None of them has a chapter count.- Batch lookup.
GET /api/mangaignoresids[],id[], andslugs[]: it returns the normal unfiltered page, so it can't be used to fetch "these 30 titles" in one request.GET /api/latest-updatesdoes haveitems_countandlast_item_aton every item, but it can't be filtered (see above). It's the home page's update feed, not something that covers an arbitrary catalog page.
chapter_count and last_chapter_at stay None on Catalog.list_titles() items. The only
way to get them for a catalog page is one get_info() per title, which is the N+1 that issue
74 itself rules out.
Title names without the format suffix (issue #79, checked 2026-10-18)
ranobelib.me appends a publication-format marker to titles' names: Старшая школа D×D
(Новелла), Haisukuru Di Di (Novel). Title.base_names removes it. To build the list of
markers from real data, I walked the whole catalog (GET /api/manga, sort_by=created_at,
411 pages, 24,658 titles) and counted every bracketed tail of rus_name, eng_name and
name:
(Новелла)ends 11,648rus_names and(Novel)ends 10,870names. Those two are what the site appends itself.- Next come
(LN)(358 over the three fields),(WN)(149), then a long tail of spellings people typed by hand:(Light Novel),(Web Novel),(webnovel),(web-novel),(Lite-Novel),(Novella),(Novell),(Webnovela),(новела),(Лайт-новелла),(Лайт Новелла),(Веб-Новелла),( веб - новелла),(Вэб Новелла),(ЛН),(Ranobe),(Ранобэ),(web serial),[quest],[квест],[ln],[wn]. Case and inner spaces vary. - Markers can stack:
(LN) (Novel),(WN) (Новелла),(новелла) (Novel),(Вэб Новелла) (WN). A separator is sometimes left in front of one (Цитрусовый маг, (Новелла),...Shuren: (Novel),... / (Novel)). A few names are only the marker ((Новелла)). - Every other tail is part of telling one title from another, so it's kept:
(Oneshot),(перезапуск)/(Remake),(Фанфик)/(фф),(18+), years ((2013)),(Прототип),(Печатная версия),[LitRPG],[MTL], franchise names ((Star Wars),(Гарри Поттер)),(#1)/(#2). Tails that mention a format word but aren't a bare marker are kept too:(Gothic novel),(Novel test),(не Новелла),[A Tower Climb Web Serial].
The rule is a closed list of format words inside (...) or [...] at the very end of the
name, repeated until none is left, together with any ,/:// and whitespace before it.
A -/– before the marker stays, because it usually closes a subtitle (Devil May Cry 5
-Before the Nightmare- (Novel)). Over the full catalog this changes 24,321 names, and no
bare format marker is left at the end of any of them.
other_names isn't in the listing. A random sample of 150 titles through get_info() (91
of them have other_names) had no bracketed tail in any of them: those are translations
and original-language names. The same rule is applied to them anyway, as the issue asks.
Normalizing names for comparison (case, punctuation, × → x) isn't done here. That
belongs to whatever matches two catalogs against each other.
Title URL / slug format
A title URL like https://ranobelib.me/ru/book/91443--new-hero-in-dxd (locale prefix is
optional — /book/91443--new-hero-in-dxd also resolves) has the API's slug_url
({numeric_id}--{slug}) as its last path segment. The SDK extracts this segment and uses it
directly as the path parameter for /api/manga/{slug_url} — no separate slug/id lookup step
needed.
number_secondary is not a chapter-number fraction — it mirrors volume
CLAUDE.md's original notes guessed number_secondary was the fractional part of a chapter
number (e.g. number_secondary: "5" on a number: "51" chapter, read as chapter "51.5").
Verified against three different titles totaling 667 chapters (6712--high-school-dxd-novel,
308 chapters, 25 volumes; 147836--o-moem-pererozdenii-v-bessmertnogo, 84 chapters, 8
volumes, including fractional chapter numbers "6.5"/"81.5"; 11407--solo-leveling, 275
chapters including 20 multi-team-branch chapters): number_secondary equals volume in
every single case, zero exceptions. The exact example the original guess was based on
(volume: "5", number: "51", number_secondary: "5") fits this pattern too — it wasn't a
coincidental fraction, number_secondary was just echoing the volume.
The actual fractional part of a chapter number, when present, is already embedded directly
in the number field as a string ("6.5", "81.5", "0.1") — there is no separate field
for it. This means the SDK's get_chapter/get_chapters/get_translations take number:
str as-is from the API instead of a separate number_secondary parameter; see CLAUDE.md's
"Публичный API" section for the corrected signatures (this was originally documented
differently, based on the wrong guess above, before get_table_of_contents was
implemented).
Chapter content: endpoint, and two different content formats
GET /api/manga/{slug_url}/chapter?number={number}&volume={volume}&branch_id={branch_id}
number/volume match CLAUDE.md's guessed pattern exactly (first try). branch_id is
optional; confirmed working against a title with two translation teams (11407--solo-leveling,
chapter volume=1, number=0, branch_id=2251 vs branch_id=635 returned that branch's own
teams). Without branch_id, the API did not pick branches[0] from the chapter-list
response — it returned the older of the two branches (created 2019 vs 2020). Default
branch selection is not simply "first in the list"; needs its own investigation before
implementing team selection / MultipleTranslationsError (roadmap step 9). 404 on an
unknown number/volume combination returns the same generic body as title-not-found.
The single-chapter response shape differs from a chapter's entry in the /chapters list:
no index/item_number/branches_count/branches — instead a singular branch_id and a
flat teams array (the teams for that branch specifically, can be empty even when the
chapter has a team in the list endpoint's branches[].teams). Extra fields not modeled:
moderated, likes_count, is_liked, is_viewed, expired_type, expired_at,
publish_at, translation_quality_rating, bundle, manga_id, created_at.
Content is not always prosemirror-doc JSON, despite CLAUDE.md's original suspicion
(reasonable at the time, based on ranobelib-loader's upload format and Title.summary
being prosemirror). Sampled all 308 chapters of 6712--high-school-dxd-novel's content
field (some requests hit 429 partway through — sample is 273 successfully fetched chapters,
still large enough to be conclusive):
- 235 chapters (~86%):
contentis an HTML string, e.g.<p data-paragraph-index="1">text</p><p data-paragraph-index="2">text</p>. Tags observed across samples from two titles:p,img,em,strong,b,i(inconsistent between<em>/<strong>and<b>/<i>for the same semantic meaning — presumably different editor versions over the site's history; both pairs occur, never mixed within one chapter in the samples checked).data-paragraph-indexlooks like a pure editor artifact, safe to ignore. Image<img>tags carry an already-absolutesrc, e.g.<img loading="lazy" src="https://ranobelib.me/uploads/ranobe/{slug}/chapters/{chapter_id}/{filename}" />. - 38 chapters (~14%):
contentis prosemirror-doc JSON ({"type": "doc", "content": [...]}), matchingTitle.summary's format. Node types observed:paragraph(can have nocontentkey at all for a blank line; can carryattrs: {"textAlign": "center"}— not currently reproduced in the SDK's HTML output, a known gap),image(block-level, sibling to paragraphs, not nested inside one),hardBreak,horizontalRule. Mark types ontextnodes:bold,italic. No headings/lists/blockquotes observed in the sample. - The
contentformat is a per-chapter property, not per-title or per-team — seen both formats within the same title.
Image resolution for the prosemirror format: an image node doesn't carry a URL
directly, only an opaque id: {"type": "image", "attrs": {"images": [{"image":
"9bb930b8-...-f717bf"}]}}. The response's top-level attachments array resolves it — find
the entry whose name equals that id, then prepend https://ranobelib.me to its url
(relative, e.g. /uploads/ranobe/{slug}/chapters/{chapter_id}/{filename}.{extension}) to
get the same absolute URL shape the HTML-string format embeds directly. attachments is
empty ([]) for chapters with no images, present alongside either content format.
The SDK normalizes both formats into a single HTML fragment on Chapter.content — prosemirror
format: rendered with the restricted tag vocabulary (<p>, <img loading="lazy" src="...">,
<strong>, <em>, <br />, <hr />); string format: sanitized down to that same
vocabulary, not passed through as-is — the site's own markup isn't SDK-generated and can
carry tags/attributes outside it (data-paragraph-index, b/i instead of strong/em, or
worse, see the tag survey above). b/i are folded into strong/em; every other
unrecognized tag is dropped (its text kept, except inside script/style, which drop their
text too); every attribute is dropped except img's src, and only when it resolves to an
http(s) URL.
Dropped block-level tags keep their paragraph boundary (issue #59). Dropping a tag but
keeping its text is fine for inline wrappers (a, span), but for a block wrapper it used
to remove the only boundary its text had: the text landed as bare text outside any <p>,
running into its neighbors. Block tags observed in the cached HTML-string chapters of
6712--high-school-dxd-novel (263 chapters): h3 (13 section headings, e.g.
<h3>Император</h3>, in 5 chapters) and ol/li (footnote lists, 5 chapters) — no div,
blockquote, tables or other lists. The sanitizer handles the standard HTML block tags
anyway (li, div, h1–h6, blockquote, section, table cells, ... — see
_DROPPED_BLOCK_TAGS), since it exists to cope with markup it can't predict: a dropped
block's inline content is wrapped in <p>, one <p> per run of inline content between
nested blocks (<div>a<p>b</p>c</div> → <p>a</p><p>b</p><p>c</p>), whitespace-only runs
(the \r\n\t between <li>s) are dropped, and opening any block inside an open <p> closes
that <p> first, as HTML does. So Chapter.content is a flat sequence of <p>/<hr />/
<img> for block-structured input, same as the prosemirror path produces. A heading comes
out as a plain <p> (no heading tag in the vocabulary — adding one would be a separate
change to every exporter). Re-checked on the same 263 chapters: 10 change (exactly the
h3/ol ones), their visible text and footnotes are identical before/after, and none has
text left outside a <p>. So exporters only ever handle one shape and Chapter.content is safe to
render as raw HTML regardless of which format the API used for a given chapter — see
_sanitize_content_html/_ContentSanitizer in models.py.
Translator footnotes (issue #58)
The API has no structured footnotes. Checked both content formats: 313 cached chapter
responses (308 of 6712--high-school-dxd-novel, 5 of another title; 263 HTML-string, 50
prosemirror) plus ~45 live chapters from 12 recently updated titles (all prosemirror). The
prosemirror node types seen were only doc/paragraph/text/hardBreak/heading/image/
horizontalRule, marks only bold/italic — no footnote node, mark or attribute. The
HTML-string chapters carry no <sup>, id/href anchors or classes either. A footnote is
just translator-typed text, in (at least) these shapes, all seen within the one DxD title:
↑-prefixed blocks —<p>↑ text</p>one per footnote, or an<ol><li>↑ text</li>…</ol>list (older chapters). All 10 chapters of the cached sample that contain a↑use it this way: 42↑in total, every one at the start of a block, none anywhere else in the text. In all 10 the footnotes are a contiguous run at the very end of the chapter.- Numbered asterisks —
word*1in the text, a trailing<p>1*text</p>per note. - A "Заметки переводчика" heading paragraph followed by
<p>[1] text</p>items, withword [1]in the text.
What the SDK detects: only shape 1. A paragraph — p, or one made from a dropped
block's text such as li (HTML-string, see above), or paragraph (prosemirror) — whose
first non-blank text starts with ↑ is moved out of Chapter.content
into Chapter.footnotes (Footnote.content: sanitized inline HTML, arrow and following
whitespace stripped, no wrapping <p>). ↑ is distinctive enough to be unambiguous in the
sample; shapes 2 and 3 aren't — [1], 1* and a "notes" heading are ordinary text too, and
guessing wrong would silently delete story text from content. They stay in content as
before; recognizing them is a possible future extension if it turns out to matter.
No marker/back-reference. The issue's proposed Footnote.marker isn't there because the
data has nothing to fill it with: in shape 1 the reference point in the text is a bare,
unnumbered * after a word (count matches the footnote count in the chapters checked), but
the same text also uses * for *** scene breaks, *action* emphasis and censored digits
(32****8517) — 416 paragraphs with a * right after a non-space character across the
cached chapters, vs 42 footnotes. The order of Chapter.footnotes (source order) is the only link available.
Exporters list a chapter's footnotes after its text under a "Notes" heading
(exporters/_shared.py's chapter_body_html), so moving them out of content doesn't drop
them from exported files; sizing.chapter_size() counts them too.
No bulk "volume content" endpoint
Checked for a shortcut before implementing get_volume() (fetch a whole volume's chapters
in fewer requests than one-per-chapter): GET /api/manga/{slug}/volume?volume=1,
GET /api/manga/{slug}/volumes both 404. GET /api/manga/{slug}/chapter?volume=1 (omitting
number) returns 422 with {"data": {"number": ["Поле number обязательно для
заполнения."]}} — number is a required parameter, confirming there's no way to fetch a
volume's chapters in one call. get_volume() fetches the chapter list once (to find which
numbers belong to the requested volume), then fetches each of those chapters individually,
same as calling get_chapter() in a loop. Sequential, not concurrent — no rate-limit/retry
handling exists yet (roadmap step 11), so no concurrency control to bound it either.
Translation selection: branch_id, not team_id, and no reliable default
Researched before implementing get_translations()/MultipleTranslationsError/branch_id
(roadmap step 9), against 11407--solo-leveling, which has 20 chapters with two competing
translations (out of 275 total). Example: volume=1, number=0 has two branches entries
in the /chapters list response:
{"id": 933688, "branch_id": 2251, "created_at": "2020-10-02...", "teams": [{"id": 13375, "name": "BerkuD13", ...}], "user": {"username": "Birdzz", "id": 1171713}}
{"id": 269251, "branch_id": 635, "created_at": "2019-04-11...", "teams": [], "user": {"username": "ItsEND", "id": 24545}}
Two things this confirms, both contradicting CLAUDE.md's original guess (the public API
sketch used a team_id parameter, assumed equal to a Team.id):
- The selector the
/chapterendpoint actually accepts isbranch_id, not a team id.branch_id(2251/635above) is a stable id for a translation line, distinct from both the branch entry's ownid(a per-chapter-revision id, different on every chapter) andTeam.id(13375for BerkuD13). It doesn't always correspond to a team at all — the second branch above has"teams": [](an uploader with no team credit), so ateam_idparameter couldn't even address it. The SDK's public API is corrected to usebranch_idthroughout (get_chapter(..., branch_id=...)), same kind of correction asnumber_secondaryearlier in this file. - The API's own default (
branch_idomitted) is not "the first branch listed." Confirmed by requestingvolume=1, number=0three ways:
branch_id param |
response branch_id |
response teams |
|---|---|---|
| (omitted) | 635 |
[] |
2251 |
2251 |
[{"name": "BerkuD13", ...}] |
635 |
635 |
[] |
Omitting branch_id returned 635 (branches[1], the older upload, 2019 vs 2020) — not
branches[0] (2251). The single-chapter response also carries no branches/
branches_count field to detect ambiguity after the fact (see the chapter-content section
above for its full shape) — the only way to know a chapter has more than one translation
is to check the /chapters list first.
Design decision (CLAUDE.md's ТЗ explicitly leaves this choice to be made and documented
here): since the API's own default isn't documented and the one example checked doesn't fit
any obvious rule ("first in list", "newest", "has a credited team" all fail here), the SDK
does not attempt to replicate or guess it. get_chapter() (and, since they build on it,
get_chapters()/get_volume()/get_volumes()) raises MultipleTranslationsError when a
chapter has more than one branch and branch_id wasn't given, instead of silently returning
whichever one the API happens to default to. Callers use get_translations() to list the
options and pick one explicitly.
Cost of this: get_chapter() without an explicit branch_id now fetches the full
/chapters list first (to check for ambiguity) before fetching the chapter itself — one
extra request it didn't need before this feature, only when branch_id isn't already known.
get_chapters()/get_volume()/get_volumes() already fetched that list for other reasons,
so they get the ambiguity check for free. This cost is expected to shrink once the disk
cache (roadmap step 10) makes repeated /chapters fetches for the same title free.
get_volume()/get_volumes()/get_chapters() do not expose a way to pick a specific
branch_id per chapter when fetching in bulk (their signatures don't have room for a
per-chapter override) — they only raise if any chapter they touch turns out to be ambiguous.
get_chapter(..., branch_id=...) already covers the single-chapter case the public API's
quickstart shows it for.
Title-wide bulk download (download_title(), roadmap step 20) needed a real answer to this
rather than "future enhancement" — it can hit many ambiguous chapters across a whole title,
and stopping at the first one (like get_volume() does) would mean fixing one, rerunning,
hitting the next, rerunning again. It resolves this two ways instead, both optional and
mutually exclusive: branch_id (same branch_id applied wherever a chapter has it —
meaningful because branch_id is a stable translation-line id, not a per-chapter revision
id, see above) or translation_index (position in each ambiguous chapter's branches list —
a fallback for when the same team doesn't share one branch_id across every chapter, or
doesn't appear on every chapter at all). Without either, or when the given one doesn't
resolve every ambiguous chapter, it raises MultipleTitleTranslationsError listing all
unresolved chapters at once (checked up front, not one failure at a time) — same "don't
guess" principle as MultipleTranslationsError, just batched for the bulk case.
Rate limiting и retry: ручная реализация вместо tenacity
Роадмап (шаг 11) явно оставлял выбор между tenacity и ручной реализацией на усмотрение
PR, с требованием задокументировать решение — вот оно.
Выбор: ручная реализация, без новой зависимости. Причины:
- Вся нужная логика — семафор на конкурентность, пейсинг между стартами запросов и
экспоненциальный backoff с уважением
Retry-After— укладывается в три небольших методаApiClient._get/_pace/_backoff_delay(~30 строк).tenacityизбавила бы от написания цикла retry, но для остального (семафор, пейсинг, разборRetry-After) всё равно нужен свой код — выигрыш от зависимости небольшой. - Тестируемость без реального ожидания:
sleep/clock— параметры конструктораApiClient(по умолчаниюasyncio.sleep/time.monotonic), тесты подменяют их на fake/recording-реализации и проверяют backoff/пейсинг мгновенно и детерминированно (tests/unit/test_client.py), без монки патчингаasyncio.sleepглобально (что сломало бы не связанные с этим тесты, использующие реальныйasyncio.sleepдля координации). - Ни одна из существующих кассет VCR не воспроизводит 429/5xx подряд, так что интеграционным
тестам retry не нужен — юнит-тесты полностью покрывают эту логику через
httpx.MockTransport.
Реализация (ApiClient, все параметры — только на уровне клиента, не пробрасываются
через RanobeLib, аналогично timeout/base_url; единственное исключение среди настроек
ApiClient — headers, см. раздел "WAF 403" выше):
max_concurrency=5—asyncio.Semaphore, оборачивает сам HTTP-запрос (не время ожидания backoff между попытками — семафор освобождается на время сна между ретраями, чтобы долгий backoff одного запроса не блокировал слот для других).request_delay=0.2s(DEFAULT_REQUEST_DELAYвclient.py, дефолт можно переопределить черезNone-сентинел в конструкторе) — минимальный интервал между стартами запросов, даже приmax_concurrency > 1; применяется на каждую попытку, включая ретраи.max_retries=3,retry_base_delay=1.0s, экспонента ×2 за попытку (1s → 2s → 4s). На 429 сначала проверяетсяRetry-After(секунды); если заголовок есть и парсится — используется он вместо экспоненты; если отсутствует или не парсится — обычный экспоненциальный backoff.- После исчерпания ретраев
_raise_for_statusработает как раньше (немедленно на первом вызове до этой фичи):RateLimitError/RanobeLibErrorна итоговый ответ.
Тестовый набор целиком глушит request_delay (через ranobelib.client.DEFAULT_REQUEST_DELAY,
monkeypatch в tests/conftest.py) — RanobeLib не даёт способа передать request_delay
per-instance, а без этого тесты вроде get_volume() на реальном тайтле с полусотней глав
реально ждали бы ~10 секунд ради поведения, которое test_client.py и так проверяет точно
и без ожидания через инъекцию fake sleep/clock.
Пейсинг общий на хост, а не на клиент (libsocial-core, issue #87)
api.cdnlibs.org один на всю сеть lib.social. Считает ли он rate limit отдельно по сайтам, не
проверялось, и оснований так считать нет. Поэтому в ядре (packages/core, libsocial.pacing) пейсинг вынесен из
ApiClient в Pacer, и по умолчанию все ApiClient процесса, которые ходят на один хост,
делят один Pacer (shared_pacer(host)). Два клиента для разных сайтов в одном процессе
(ranobelib и mangalib) вместе шлют запросы не чаще одного клиента, а не вдвое чаще.
Семафор max_concurrency и ретраи остаются у каждого клиента свои: общим сделан только
интервал между стартами запросов, о котором и шла речь в issue.
- Слоты резервируются, а не ждутся под замком: вызывающий под
threading.Lock(безawaitвнутри) берёт следующий свободный момент старта и сдвигает его на свойrequest_delay, потом спит уже вне замка. Поэтому одинPacerможно делить между event loop'ами и потоками (asyncio.Lockпривязывается к loop'у, который первым его использовал, и в другомasyncio.run()падает). Каждый вызывающий резервирует свою задержку, так что клиенты с разнымrequest_delayмогут делить один пейсер. ApiClient(pacer=...): свойPacer(например, с fake-часами в тестах) или отдельный, чтобы не делить общий. Параметрclockпереехал изApiClientвPacer, потому что часы нужны только пейсингу.- Проверено вживую (2026-10-18):
Catalogядра для site 3 и site 1 в одном процессе получают один и тот же объектPacer, и оба листинга отвечают нормально.
С шага 3 монорепозитория (#88) ranobelib работает на ядре: ranobelib.client.ApiClient —
наследник libsocial.ApiClient с зашитым сайтом, так что общий пейсер по умолчанию теперь
и у RanobeLib/Catalog. Параметр clock= у ranobelib.client.ApiClient остался:
переданный, он даёт клиенту собственный Pacer на этих часах вместо общего.
download_title() outliving ApiClient's per-request retry budget (issue #41)
Reported against a real 3932-chapter title (17435--fan-ren-xiu-xian-chuan): sustained
sequential fetching by download_title() reliably trips the API's own 429 around chapter
~217, well before ApiClient's retry budget was ever meant to cover — max_retries=3/
retry_base_delay=1.0s (~1s/2s/4s, ~7s total) is tuned for one request tolerating a
transient blip, not a download that's already been running for tens of seconds to minutes.
Once that budget is exhausted, the RateLimitError used to propagate straight out of
download_title(), discarding every chapter fetched so far — they only ever lived in a
local list, never returned to the caller on failure.
Fix, two parts, both in sdk.py (not ApiClient — this is a bulk-operation-specific
concern layered on top, not a change to the per-request retry policy every other method
also relies on):
RanobeLib._fetch_chapter_riding_out_rate_limits()wraps_fetch_chapter()fordownload_title()'s loop: on aRateLimitErrorthat already survivedApiClient's own retries, wait and try again — honoringRetry-Afterif the API sent one, otherwise capped exponential backoff (_RATE_LIMIT_RETRY_BASE_DELAY=5.0s, doubling, capped at_RATE_LIMIT_RETRY_MAX_DELAY=60.0s) — up to a newmax_rate_limit_retriesparameter ondownload_title()(defaultDEFAULT_RATE_LIMIT_RETRIES=6;0disables this extra layer entirely). These numbers are a judgment call, not measured against the live API's actual rate-limit window (undocumented, see the rest of this file's running theme) — generous enough to ride out a temporary throttling window without an unbounded/indefinite retry loop that could hang forever on a title the API has decided to block outright.- If a chapter fetch still fails after that (rate limiting past the ceiling above, or any
other
RanobeLibError— e.g. a chapter unexpectedly requiring auth mid-download), the chapters already fetched are no longer thrown away:download_title()catches it and raisesDownloadTitleInterruptedErrorinstead, carrying.volumes(what was already downloaded, grouped exactly like a successful return),.completed,.total, and the original error as__cause__. A caller can keep the partial result, or just calldownload_title()again — the disk cache (see "Кэширование" in CLAUDE.md) means already-fetched chapters aren't re-requested, so a retry resumes rather than restarts. This is the same patternranobelib-companionwas already doing outside the SDK as a workaround (catchRateLimitError, back off, calldownload_title()again, rely on the cache) — issue #41 asked for it to move inside the SDK so every caller gets it for free, not just that one.
Not extended to get_chapters()/get_volume()/get_volumes()/estimate_title_size() in
this fix — the reported failure and this issue's reproduction are specifically about
download_title() (the one bulk method whose whole point is walking every chapter of a
long title sequentially in one call, so it's the one that actually reaches the chapter
counts where this bites); the other bulk methods take an explicit, caller-provided list of
chapters/volumes to fetch, which for the same underlying reason (this is a "how many
requests can this one call make in a row" problem) rarely gets anywhere near that many in
practice. Same retry-layer/partial-progress mechanism if one of them turns out to need it —
left as a follow-up, not guessed at speculatively here.
Unit-tested in tests/unit/test_sdk.py (test_download_title_*rate_limit*,
test_download_title_raises_interrupted_error_with_partial_progress) via the same
monkeypatch(lib, "_fetch_chapter", ...) pattern estimate_title_size()'s tests already
use, not VCR — same reasoning as ApiClient's own retry tests above: no existing cassette
reproduces sustained 429s, and a fake _fetch_chapter exercises the retry/give-up logic
exactly without needing one.
PDF export: WeasyPrint needs native libraries at import time, not just install time
Discovered while implementing the pdf exporter (roadmap step 15), on this repo's Windows
dev environment: uv add weasyprint installs cleanly (pure wheel, no build step) on any
platform, but import weasyprint itself raises OSError: cannot load library
'libgobject-2.0-0' unless the GTK3 runtime (Pango/cairo/gobject, which WeasyPrint uses for
text shaping) is present on the system. This is different from every other dependency this
project has added — pip install succeeding doesn't mean the package actually works.
Consequence for import ranobelib: exporters/__init__.py eagerly auto-imports every
module in the package (see "Экспорт" in CLAUDE.md) to run each exporter's @register. If
pdf.py did a plain top-level import weasyprint, that OSError would propagate straight
through the auto-import loop and crash import ranobelib itself on any system without
GTK3 — not just "pdf export doesn't work", the whole SDK would be unusable.
Fix: pdf.py wraps its own import weasyprint in try/except (ImportError, OSError);
on failure it sets weasyprint = None and skips defining PdfExporter entirely (the
@register line lives inside if weasyprint is not None:). import ranobelib then always
succeeds, EXPORTERS just doesn't have a "pdf" key, and RanobeLib.export(fmt="pdf") on
such a system raises the same ValueError: Unknown export format 'pdf'. Available: ... it
would for any other unregistered format — no new exception type needed, and the message
already tells the caller what is available.
Testing implication: tests/unit/test_pdf_exporter.py's WeasyPrint-dependent tests
(pytest.mark.skipif(weasyprint is None, ...)) skip rather than fail on machines without
the native deps — this repo's Windows dev environment among them. ci.yml's test job
installs libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libcairo2 via apt-get
before running pytest so they actually execute there instead of silently always skipping.
No VCR-backed integration test for fmt="pdf" exists (unlike txt/fb2/epub) for the same
root cause: recording its cassette needs a real WeasyPrint run to make the real HTTP calls
worth capturing, which isn't possible in this dev environment, and CI runs with
--record-mode=none so it can't record one either — see tests/integration/test_export.py's
module docstring. The unit tests (mock transport, no cassette needed) cover the same
download-then-render pipeline instead.
Catalog listing/search: GET /api/manga
Researched for issue #38 (companion web UI needs catalog browsing without scraping HTML) —
the existing "Site scoping" section above already noted this endpoint uses a site_id[]=3
query parameter rather than the Site-Id header, and that's confirmed still true; everything
else below is new. Checked with real curl requests against the live API (no test title
needed — this endpoint lists/searches across the whole site).
Pagination — page/limit, not page/per_page:
GET /api/manga?site_id[]=3&page=1&limit=30
limitcontrols page size, but only within10..60inclusive —9or61both return 422 ("Поле limit должно быть между 10 и 60."). Omittinglimitdefaults to60, not some smaller number.pagemust be>= 1—0/negative both 422 ("Поле page должно быть не менее 1.").- Response shape:
{"data": [...], "links": {...}, "meta": {...}}— unlike every other endpoint in this SDK, the top-level object isn't just{"data": ...}, callers needmetatoo. meta.has_next_page(bool) is exactly the "indicate whether more results exist" signal the issue asked for — no need to compute it from a total count, and in fact there is no total count anywhere in the response (nometa.total/meta.last_page). A page past the end returns 200 with"data": []andhas_next_page: false, not 404.meta.seedis present on every response but turned out to be informational/unrelated to requesting a specific page reliably — see "sort_by=random" below for the parameter that actually matters for that.
Search — q:
q=<text> does a free-text search (matches on name/rus_name/eng_name, empirically).
No separate "search" endpoint — same GET /api/manga as browsing, q is just another filter.
Genre filter — genres[], AND semantics:
genres[]=<id> (repeatable) filters to titles having all given genre ids, not any of them
— confirmed by combining a real genre id with a nonexistent one (genres[]=34&genres[]=999999
→ 0 results, not the same non-zero count as genres[]=34 alone; an OR filter would still have
matched on the real id). Unknown genre ids don't error (no whitelist validation) — they just
never match anything, so the SDK doesn't need to fetch/validate against a genre catalog at
all, which is convenient because there isn't a public way to do that (see below).
There's no way to list genre ids/names via GET /api/genres or GET /api/manga/genres —
both respond 403 {"message": "User is not logged in."}, a different error shape than
every other 403 this SDK has seen (AuthRequiredError's shape assumes the /manga/...
family's JSON body). A working, unauthenticated endpoint was found (issue #44):
GET /api/constants?fields[]=genres
Accept: application/json still required (same as everywhere else — no Site-Id
requirement here though, see below), no auth needed, 200 with
{"data": {"genres": [...]}}. Each element:
{
"id": 34,
"name": "Боевик",
"alt_name": [],
"dsc": "",
"adult": false,
"alert": false,
"site_ids": [1, 2, 3, 4, 5],
"blocked_for_country": [],
"allowed_for_country": [],
"allowed_for_domain": [],
"anilist_id": null,
"shiki_id": null,
"mal_id": null,
"is_media_spoiler": null,
"is_general_spoiler": null
}
55 genres total as of this writing. id/name/adult map directly onto the existing
Genre model (Title.genres's element type, reused per the issue's ask — no separate
"catalog genre" model). site_ids isn't site-scoped by any request parameter — a
Site-Id: 3 header (or omitting it) makes no difference to which genres come back, unlike
every other endpoint that header matters for; the response always contains genres for the
whole lib.social network at once, and callers must filter by site_ids themselves.
54 of the 55 genres include 3 (ranobelib.me) in site_ids; the one exception is id 88
("Детское"), tagged site_ids: [5] only. Catalog.list_genres() filters to site_ids
containing 3 before returning, so a ranobelib-scoped caller never sees a genre that
couldn't possibly match any ranobelib title — see catalog.py's _build_genres().
GET /api/constants also exposes other fields[] values unrelated to this issue (found
while probing, not otherwise used by this SDK): tags, status, types,
imageServers — each 403-free the same way genres is. Not investigated further; noted
here in case a future feature needs one of them (same "don't guess, check first" principle
as this whole section). (types was picked up later for issue #48, see "Country/origin
filter" below, and status for issue #68, see "Status filter" below; tags still isn't used for anything beyond the filter parameter itself, see
next.)
Tag filter — tags[], AND semantics, same as genres (issue #50):
Issue #50 (companion app wants each tag badge on a title page to link to the catalog
pre-filtered to that tag, same as genre badges already do with genres=[...]) asked
whether the API supports filtering by tag id at all, and if so whether the match is AND or
OR — tags being more numerous/specific than genres made OR seem plausible as the more
useful default even at the cost of inconsistency with genres. Checked directly:
tags[]=<id>is a real, working filter — confirmed by checking that a title present intags[]=218results (id129971, "Immortal Drunkard") actually has tag id218("Боги") in its ownfields[]=tagsresponse.- It's AND, not OR — same semantics as
genres[], contradicting the issue's OR guess. Same test methodology as the genre AND confirmation above: combining a real tag id with a nonexistent one (tags[]=218&tags[]=999999) returns 0 results, not the same non-zero count astags[]=218alone — an OR filter would still have matched on the real id. - Unknown tag ids don't error (
tags[]=999999alone → 200,"data": [], not a 422) — no server-side whitelist, same asgenres[]and unlikestatus[]/types[]. This SDK doesn't validatetagsagainst a tag catalog before sending, same reasoning asgenres. - Not investigated as part of this issue (out of scope — the issue only asked for the
filter, not a listing endpoint): whether
GET /api/constants?fields[]=tags(noted as present but unexplored above) would support a futureCatalog.list_tags()the wayfields[]=genres/fields[]=typesdo for genres/countries. Unlike those two, this issue's actual use case (companion linking a tag badge already rendered fromTitle.tags) never needs a tag id → name lookup independent of a specific title, so nolist_tags()was added here.
Author/artist/team filter — not supported by the API at all (issue #51, not implemented):
Issue #51 proposed author_id/artist_id/team_id single-value filters on list_titles()
(Person.id/Person.id/Team.id from Title.authors/Title.artists/Title.teams), the
same shape as status, and explicitly flagged as an open question whether the API supports
this at all — same caveat issue #50 raised for tags, which turned out to be unfounded there
(tags are filterable). For author/artist/team, extensive checking against the live API
found no supported mechanism whatsoever:
- Brute-forced ~25 plausible
GET /api/mangaquery parameter names against a real title's known author id (57847, "kingCH") and team id (23286, "Stixs TEAM") — snake_case and camelCase singular/plural variants (author_id,authors[],author_ids[],people[],people_ids[],artist_id,artists_id[],team_id,teams[],translator_id,translators[],user_id,creator_id,publisher_id,franchise_id,authorId,teamId,author_slug,team_slug, ...), plus a Laravel-style bracket-filter guess (filter[author_id]=..., checked withcurl -gto rule out shell/URL-globbing false positives). Every single one returned the exact same result set as no filter at all — silently ignored, not a 422 (this API generally doesn't reject unknown query parameters, so silence here isn't as strong a signal asgenres[]/tags[]'s AND-semantics tests, but combined with the points below it's conclusive enough not to guess further). - No dedicated "titles by this person/team" sub-endpoint exists either:
GET /api/people/{slug_url}/manga,.../titles,GET /api/teams/{slug_url}/manga,.../titlesare all 404. GET /api/people/{slug_url}(a real, working, unauthenticated endpoint — returns id, name, avatar,titles_count_detailsper site id, subscriber stats) doesn't expose an actual title list through anyfields[]value tried (manga,titles,works,franchise) — response shape is identical regardless, and even a clearly-bogusfields[]value doesn't error the way it does on/api/manga/{slug}({"fields.0": [...]}), suggestingfields[]isn't wired up for this endpoint at all, not just missing these particular values.- Checked whether the free-text
qsearch (documented above as matching name/rus_name/eng_name) incidentally matches author name as a fallback:q=kingCH(the exact name of a real credited author) returns 0 results, even though that author has exactly one real title on ranobelib.me. So there's no id-based or name-based way to search/filter the catalog by author/artist/team through this API.
Conclusion: not implemented. Adding author_id/artist_id/team_id parameters that
silently do nothing (since nothing on the wire would ever narrow results) would be worse
than not having them — a filter that looks like it works but doesn't is actively misleading,
unlike e.g. genres/tags/country, which are all confirmed-working real filters. If the
API adds this capability later, or a working parameter name surfaces from a source other
than guessing (e.g. observing the real ranobelib.me frontend's own network requests), this
can be revisited then — not before.
Status filter — status[], not status:
status=1 (scalar) is 422 ("Поле status должно быть массивом." — must be an array),
confirming the real param is status[]=1. Values are Title.status.ids — confirmed by
requesting status[]=1 and checking every returned item's status.id == 1. Observed ids in
the wild: 1 ("В процессе"/ongoing), 2 ("Завершён"/completed), 4, 5 (labels not fully
sampled). Same as genres, an unrecognized id (status[]=99) is 422 here though ("The
selected status.0 is invalid.") — unlike genres[], this one is validated against a
whitelist server-side.
Status list and multiple statuses — fields[]=status, OR semantics (issue #68, checked
2026-10-04):
Issue #68 (companion app's mobile catalog filters: a «Статус» group of multi-select chips)
asked for an id → label list, like list_genres()/list_countries(), and for filtering by
several statuses at once.
- The whitelist above is
GET /api/constants?fields[]=status: same network-wide,site_ids-tagged shape asfields[]=genres/fields[]=types(aSite-Idheader changes nothing about the response). As of this writing, 5 entries, all tagged for ranobelib.me:1"Онгоинг",2"Завершён",3"Анонс",4"Приостановлен",5"Выпуск прекращён" (4/5are taggedsite_ids: [0, 1, 2, 3, 4], i.e. not for site 5,1-3for all sites). The labels matchTitle.status.labelfor the same ids (eachstatus[]=<id>alone returned only titles with thatstatus.id/label), and6/99are the 422 above, so the list is the full set.Catalog.list_statuses()filters bysite_idscontaining3anyway, same as the other two lists — today that drops nothing, but the endpoint isn't site-scoped, so it could. - Repeated
status[]is OR, liketypes[]:status[]=1&status[]=2(limit=60,sort_by=name) returns both ongoing and completed titles (46 + 14),status[]=2&status[]=4both completed and paused. Expected, since a title has exactly one status. Catalog.list_titles()'sstatus: int | Nonewas replaced outright withstatuses: list[int] | None— not kept as a deprecated alias, same call ascountry→countriesin issue #55 below.Noneor an empty list omitsstatus[]entirely.
Chapter-count filter — chap_count_min/chap_count_max (issue #69, checked
2026-10-04):
Issue #69 (companion app's mobile catalog filters: «Количество глав» — «от 100» / «от 500» /
«от 1000») asked for a minimum chapter count, plus a maximum if the API has one. The parameter
names weren't guessed: the site's own JS bundle declares its catalog filter schema with
chap_count_min/chap_count_max (next to year_min/year_max, rating_min/rating_max,
...). Checked against the live API — the listing items don't carry a chapter count, so each
result's count was looked up through GET /api/manga/{slug_url}?fields[]=chap_count:
- Both bounds are inclusive and count
items_count.uploaded— the same number asTitle.chapter_count.chap_count_min=1000&sort_by=chap_count&sort_type=ascstarts at titles with exactly 1000 chapters;chap_count_max=100&sort_by=chap_count(desc) starts at exactly 100;chap_count_min=308&chap_count_max=308includes6712--high-school-dxd-novel(308 chapters).chap_count_min=100&chap_count_max=500ascending starts at 100. - Validated server-side: a negative value is 422 (
"... должно быть не менее 0."), a non-integer (abc,1.5) is 422 ("... должно быть целым числом.").0is accepted, andchap_count_max=0returns titles with no chapters yet. min > maxis silently swapped, not rejected:chap_count_min=500&chap_count_max=100gives the same 100..500 range (ascending starts at 100, descending at 500). The SDK doesn't rely on that undocumented behavior:Catalog.list_titles(min_chapters=..., max_chapters=...)raisesValueErrorformin > maxand for negative values before any request, same as it already does forpage/per_page.- A bound above every title (
chap_count_min=99999999) is 200 with an empty page.
Country/origin filter — types[], not country/countries[] (issue #48):
Issue #48 (companion app's catalog filter sidebar needs a country-of-origin filter, same
motivation as the genre filter in #44) proposed guessing at country/countries[]
parameters and a fields[]=countries constants endpoint, mirroring genres. Checked
directly, both guesses turned out wrong:
GET /api/constants?fields[]=countriesis a real, working, unauthenticated endpoint — but it returns something unrelated to title origin: as of this writing, 3 entries (Беларусь/Казахстан/Россия), each with aphone_codeandemoji_unicodeand nosite_idsfield at all. This looks like a phone-country-code list for some account/ registration flow elsewhere on the site, not a title metadata concept — the SDK does not use this endpoint.- The actual "country a title comes from" concept already exists in every title response,
just under a different name: the
typefield (present by default on bothGET /api/manga/{slug}andGET /api/mangacatalog listing items, no extrafields[]needed — confirmed via a real title,91443--new-hero-in-dxd:"type": {"id": 15, "label": "Фанфик"}, matchingtests/unit/test_models.py'sRAW_TITLEfixture, which already carried this field unused before this issue). Before this issue,typewas listed among "extra fields this endpoint sends thatTitledoesn't model" (see below) — that was accurate at the time (nothing consumed it) but incomplete once its actual meaning was understood. - The catalog listing filter for it is
types[]=<id>(repeatable; confirmed OR semantics by combiningtypes[]=10&types[]=11→ results with either type, unlikegenres[]'s AND — expected, since a title only ever has onetype, so OR is the only semantics that make sense).type_id[]/type[]were tried as alternate names and silently do nothing (same "accepted but ignored" pattern as baresort); unrecognized ids 422 ("The selected types.0 is invalid.") — validated server-side, likestatus[]. - The listing endpoint for id → label is
GET /api/constants?fields[]=types(same network-wide,site_ids-tagged shape asfields[]=genres, see above — not site-scoped by request parameter, filter bysite_idscontaining3client-side). As of this writing, 6 entries are tagged for ranobelib.me (site_ids: [3]):10"Япония" (Japan),11"Корея" (Korea),12"Китай" (China),13"Английский" (originally English-language),14"Авторский" (original/non-translated web novel),15"Фанфик" (fanfiction). The other ~14 entries in the full response are tagged for other lib.social sites (manga/manhwa/ manhua-type entries for site 1/2/4, anime episode-format entries for site 5) and excluded the same wayCatalog.list_genres()excludes non-ranobelib genres. - Given three of the six ranobelib values aren't literal countries, the SDK's
Countrymodel (id/name,namemapped from the API'slabelkey) surfaces all six as-is rather than trying to filter down to "real" countries only — seeCountry's docstring. This is the same kind of naming mismatch assort/sort_byandnumber_secondary/team selection elsewhere in this file: the issue's proposed public shape (Country,Title.country,list_titles(country=...),Catalog.list_countries()) is kept because it matches the real use case, but everything under the hood — wire parameter names, constants endpoint, and the fact that this isn't a pure country concept — was corrected against what the API actually does, not what the issue guessed.
Widened to accept multiple countries — countries: list[int], OR semantics (issue #55):
list_titles(country=...) originally only accepted a single id (previous paragraph),
matching "a title only has one country" — but that's a fact about what a title has, not
about what a filter UI needs to select, and issue #55 (companion app's catalog filters
moving country from radio buttons to checkboxes) asked for the same list-of-ids shape
genres/tags already have. The OR semantics of repeated types[] were already confirmed
during #48's investigation (bullet above: types[]=10&types[]=11 → results with either
type) but the public API only ever sent one value. Re-confirmed directly for this issue with
a larger sample (types[]=10&types[]=11&limit=60&sort_by=name): the 60 results include both
type 10 and type 11 titles, not just one — real OR, not "last one wins" or silently
ignored like a bare sort. Catalog.list_titles()'s country: int | None parameter was
replaced outright with countries: list[int] | None (not kept alongside it — the issue left
this open, but the project doesn't keep unused parallel parameters, see CLAUDE.md); None or
an empty list omits types[] entirely, same as genres/tags already do for their own
list parameters.
Sort — the real parameter is sort_by, not sort (sort is silently accepted and does
nothing):
CLAUDE.md's original proposed shape (from the issue) guessed a sort: str = "updated_at"
parameter. Checked directly: sort=<anything> (including garbage values) returns 200 with
no change in ordering whatsoever compared to omitting it entirely — it isn't validated,
isn't applied, just ignored. The parameter that actually controls ordering is sort_by,
which is validated (unknown values → 422, no enumeration of allowed ones in the error body,
so the set below was found by brute-forcing likely candidates, not read from a schema):
sort_by value |
Result |
|---|---|
name |
200, changes order |
created_at |
200, changes order |
views |
200, changes order |
chap_count |
200, changes order |
last_chapter_at |
200, changes order |
rate_avg |
200, changes order (confirmed by rating) |
random |
200, genuinely randomizes (different order every call, no shared seed by default) |
updated_at, rating, alphabet, id, popularity, ... |
422 invalid |
This list isn't necessarily exhaustive — only what was tried. Also confirmed a companion
sort_type parameter ("desc"/"asc", default desc) that reverses ordering for
whichever sort_by is active; order/dir were tried as alternate names for this and don't
do anything (silently ignored, same as bare sort).
Given updated_at doesn't exist, the SDK's default is last_chapter_at instead — the
closest real equivalent to "recently updated" (a title's last-chapter timestamp, which is
what actually changes when a title gets a new chapter). Same kind of correction as
number_secondary/team_id earlier in this file: the issue's proposed shape was a starting
guess, not verified API behavior.
sort_by=random pagination note (not implemented, documented as a known gap): passing back
the random_order float from a previous response's meta.random_order as a random_order
query param on the next request keeps the random order stable across pages (confirmed:
page=1 and page=2 with the same random_order value returned no overlapping ids).
Without it, consecutive sort_by=random pages are independently randomized and can repeat or
skip items. Catalog.list_titles() doesn't expose sort_by="random" pagination stability
(no random_order parameter) — out of scope for this issue's ask (plain listing/search, not
a "browse randomly without duplicates" feature); a caller can still pass sort="random" and
get a valid, just not stably-paginated, response.
Catalog list items validate directly against the existing Title model: a raw item from
GET /api/manga's data array has every field Title requires (id, name, slug,
slug_url, cover, ageRestriction, status) and nothing that conflicts with it — confirmed
by running Title.model_validate() on a real captured item. Fields Title defines with
defaults but that this endpoint doesn't send (genres, tags, authors, artists, teams,
summary, release_date, chapter_count, last_chapter_at — see "Last chapter date"
above, ...) just come back empty/None, same as any other optional field — no separate "catalog list item" model needed, matching the issue's explicit
ask to reuse Title. Extra fields this endpoint sends that Title doesn't model (rating,
content_marking, site, releaseDateString) are ignored by pydantic, same as everywhere
else in the SDK. type used to be in this list too, until issue #48 (see "Country/origin
filter" above) — it's now Title.country.
MangaLib (mangalib-python-sdk, issue #90, checked 2026-10-18)
mangalib.me sits on the same backend as ranobelib.me. Everything below was checked against
the live API with Site-Id: 1 and Origin/Referer: https://mangalib.me before the models
were written. The starting point was the SDK's own code and the open-source
keiyoushi lib-multisrc/libgroup
client, and the live API corrected it in a few places (image servers, closed chapters).
Same as ranobelib
GET /manga/{slug}with the samefields[](get_info()uses ranobelib's list unchanged),GET /manga/{slug}/chapters,GET /manga/{slug}/chapter,GET /mangawithsite_id[]=1,GET /constants. A title of the other site answers 404 with the wrongSite-Id, as described under "Site scoping".- The chapter list has the same shape,
branches[]included.numberholds the fraction (344 of 11,523 chapters of 41 titles, e.g."6.5"), andnumber_secondaryequalsvolumeon all 20,596 chapters of 114 other titles (latest-updated and most-viewed), as on ranobelib. Title.countryis the site's title type. For site 1/constants?fields[]=typesgivesМанга,OEL-манга,Манхва,Маньхуа,Руманга,Комикс.fields[]=statushas the same five statuses as ranobelib.fields[]=genreslists 49 genres for site 1.- No reliable default branch. On 12 chapters of four titles with competing translations
(
7965--chainsaw-man,3321--dorohedoro,105--fullmetal_alchemist,18009--jujutsu-kaisen-0-...), the branch the API picks withoutbranch_idwas the first in the chapter list'sbranchesin only 5 of them. Per title the pick was the samebranch_ideach time, but thatbranch_idwas the lowest of the title's ids in three titles and the highest in the fourth. SoMangaLibraisesMultipleTranslationsErrorlike ranobelib does ("Translation selection"). - Many of the most-viewed titles (
247--shingeki-no-kyojin,3601--unordinary_, Solo Leveling, ...) return an empty chapter list: licensed titles keep their page but not their chapters. That's an empty table of contents, not an error.
Chapter pages
A fetched chapter has pages instead of content:
{"id": 4577438, "image": "00.png_res.jpg", "slug": 1, "external": 0, "chunks": 1,
"chapter_id": 223269, "created_at": "...", "updated_at": "...", "height": 2048,
"width": 1425, "url": "//manga/chainsaw-man/chapters/1-1/00.png_res.jpg", "ratio": "0.6958"}
slugis the page's 1-based position, andpagescomes in that order:1..Nwithout gaps on all 108 chapters (one per title, 1,497 pages) checked for it.Page.numberis thisslug.urlstarted with//on all of those pages and on 510 more (41 chapters of 41 other titles). It's relative to an image server. Neither the chapter nor the page says which server, because every server serves every page under the same path.externalwas0on every one of the 510 pages.chunksranged from 0 to 5. On tall webtoon strips (690×38200) the server still sends the whole image for the page URL, sochunksdoesn't change how a page is downloaded and isn't modelled. Non-ASCII file names occur (1화_Dfpp.png) and download fine.width/heightmatched the downloaded image's dimensions.
Image servers: GET /constants?fields[]=imageServers
Not site-scoped by any parameter. Entries carry site_ids like the genre list:
id |
site 1 (and 3) url |
serves |
|---|---|---|
main |
https://img2.imglib.info |
HTML 403 from DDoS-Guard on every attempt |
secondary |
https://img2.imglib.info |
same host as main |
compress |
https://img3.cdnlibs.org |
the full-size page (1425×2048 for the example above) |
download |
https://img3.cdnlibs.org |
same host as compress |
crop |
https://crops.mangalib.me |
a 310×450 preview; 400 Invalid path on some pages |
Sites 2 and 4 have their own hosts (img2.hentaicdn.org, ...), and site 2's crop has an
empty url. MangaLib.list_image_servers() keeps the entries for site 1 that have a URL.
img2.imglib.info was checked with no headers, with only Referer, with only a browser
User-Agent, with Accept: image/*, with Sec-Fetch-*, with Accept-Language, and with
all of them together, using fresh clients and a cookie-keeping client. Every request got
DDoS-Guard's HTML 403, except a single 200 on the first request of one client, after which
that client got 403s too. That host wants a browser that runs DDoS-Guard's JavaScript check.
img3.cdnlibs.org and crops.mangalib.me served every combination, including no headers at all.
Two more things about compress:
- It sends JPEG (
Content-Type: image/jpeg, JPEG bytes) even for.pngfile names, so a file name's extension doesn't tell the format. server.url + page.urlgives a double slash (https://img3.cdnlibs.org//manga/...). The single-slash form returns the same bytes on every server, andmangalib.page_url()uses it.
So MangaLib.download_page() tries the servers in the order compress, download, main,
secondary (images.DEFAULT_SERVER_ORDER), skips any server whose URL was already tried,
and moves on after a non-2xx status or a non-image Content-Type (a 429, a 5xx or a network
error only after retries, see "Export, page sizes and the image host's rate limit" below).
server= puts a server first; crop is only used when asked for. Image requests send
browser_headers_for("https://mangalib.me") plus Accept: image/*, with the same
headers= overrides as the API client. They don't send Site-Id.
Paid and early-access chapters: restricted_view
Scanned on the chapter lists of 114 titles (the 60 latest-updated and 60 most-viewed, less duplicates and five with no chapters; 20,880 branches):
- A restricted branch in the chapter list carries
restricted_view: {"is_open": false, "expired_at": ..., "price": null}: 1,868 branches.expired_atis when early access ends (89 of them), andnullfor a chapter that stays paid (1,779). The list never hadis_open: true. A chapter that had been in early access and has since opened (226780--...'s 1/73) has norestricted_viewin the list, though fetching it returns one withis_open: true. - Branches also carry
expired_type:0on 17,972,2on exactly the 1,779 permanently paid ones,1on 1,129, which are the 89 in early access plus chapters whose early access ended long ago. So it doesn't say whether a chapter is readable now, and isn't modelled. - Fetching a closed chapter without an account is 200, not 403. The response has
restricted_view: {"is_open": false, "price": 18, "expired_at": ..., "can_open_with_content_key": false}and nopageskey at all. With or withoutbranch_idthe answer is the same. A chapter whose early access has ended hasrestricted_view.is_open: trueand itspages.
The SDK has no login. ChapterBranch.restricted_view/is_open come from the chapter list.
On the chapter fetch, mangalib.client.ApiClient.get_chapter() raises AuthRequiredError when
restricted_view.is_open is false. The check runs there and not on the parsed model, so that
the refusal never reaches the disk cache. A cached refusal would keep failing after the
chapter opens at expired_at. The chapter list isn't used to refuse early: a cached list
can be older than the chapter's opening.
get_chapters()/get_volume()/get_volumes() hit the same error on a closed chapter:
they're given explicit chapters, and silently leaving one out would hand back less than
was asked for. The whole-title operations, download_title() and estimate_title_size(),
work over open translations only instead (issue #91). They'd otherwise end at the first
closed chapter, usually one of the newest, with DownloadTitleInterruptedError. A chapter
whose translations are all closed is skipped (logged with verbosity="full"). A closed
translation next to open ones is dropped from the choice, so translation_index counts
open translations and MultipleTitleTranslationsError lists only those. When one
translation is left open out of several, it's requested by its branch_id, because without
one the API picks its own default, which can be the closed one. On
226780--online-game-... this downloads 132 of the 134 chapters, leaving out the two in
early access.
Export, page sizes and the image host's rate limit (issue #91, checked 2026-10-18)
HEADgives a page's exact size.img3.cdnlibs.organswersHEADwithContent-Lengthequal to the downloaded body (checked page by page), withCache-Control: max-age=4320000.MangaLib.chapter_size()/volume_size()sum these without downloading any image.estimate_title_size()samples chapters the same way: the chapter list has no page counts, so both the page count and the page weight come from the sample. For chapter 1/1 of7965--chainsaw-man(55 pages) the sum was 17,943,292 bytes, and the PDF export of it 17,974,132.- The image host rate-limits, through DDoS-Guard. 300 full-size GETs (63 MB) went
through with five at a time, 0.2 s between request starts, without a 429. So did 300 GETs
with twenty at a time and no delay, which ran at ~15 requests/s because of bandwidth.
Dense
HEADbursts got 429s: 5 of 256 requests at ~57 requests/s, and 751 of 927 with thirty at a time (the first after 2.6 s). The 429 is DDoS-Guard's HTML page, with noRetry-After. A request a minute or two later went through again.GETs at 0.1 s pacing right after those bursts got a few 429s too, so the limit looks like requests per time window, not request rate alone. - A 429 on
HEADbreaks the connection. DDoS-Guard sends its HTML body even in answer toHEAD, so the next response on that keep-alive connection fails to parse (httpx.RemoteProtocolError: illegal status line, with the leftover HTML in it). - So page requests go through the process-wide
libsocial.shared_pacer()of the image's own host (not api.cdnlibs.org's) withimages.DEFAULT_IMAGE_REQUEST_DELAY = 0.2, five at a time by default (concurrency=). A 429, a 5xx or anyhttpx.TransportError(the broken connection above included) is retried on the same server up to 3 times, after 1, 2 and 4 s. A refusal (another 4xx, or a 2xx that isn't an image) isn't retried. After either, the next server is tried. - PDF via img2pdf (the
pdfextra). JPEGs are embedded as-is: the page image in the PDF is byte-identical to the download. It also took PNG with alpha (adding a soft mask) and WebP. A 690×38200 webtoon strip, beyond the PDF page-size limit (14,400 pt), came out as one page of 69×3820 with/UserUnit 10, not split. That keeps one PDF page per image, as the issue asks. The PDF is built in memory, so for a whole long title CBZ is the lighter format. - A fetched chapter's
namebelongs to its translation. It can be missing where the chapter list has one (1/1 of7965--chainsaw-man,branch_id4667:nullvs."Пёс и бензопила"), and two translations can name a chapter differently (3/20:"За выпивкой"in the list,"Выпивка"in the fetched one). Export uses the fetched chapter's name, so a translation's file isn't labelled with another translation's title. Many chapters have an empty""name. A chapter without a name is labelledТом N Глава M.
Title names without a format marker (checked 2026-10-18)
Title.base_names has the same job as ranobelib's (issue #79): names to look a title up
by elsewhere, without a publication-format marker. The marker list was built the same way,
by walking the whole catalog (GET /api/manga, site_id[]=1, sort_by=created_at, 691
pages, 41,419 titles) and counting every bracketed tail of rus_name, eng_name and
name.
- mangalib.me appends no marker of its own. Only 2,061 names over the three fields of
41,419 titles end with a bracketed tail at all, and most of those tell titles apart:
(Doujinshi)(88),(Colored),(перезапуск),(додзинси),(Цветная версия),(Oneshot),(Serialization),(Genshin Impact), years,(Vol 1),(Remake),(Артбук), authors. - Bare format markers were typed by hand on 49 names:
(webtoon)/[Webtoon]/(WEBTOON)(the most common, mostly on romanized Koreannames),(вебтун)/[Вебтун],(manhwa),(manga)/(Манга),(Manhua),(comic)/(comics)/(Комикс)/(комикс),(comic book),(Webcomic). Two have no space before it (...gibun-eun?(webtoon),Donghwa(webtoon)). - Tails that mention a format but say more are kept, because they tell versions of a work
apart:
Вакфу: Великая Волна (манга-версия)and(вебтун-версия)are two titles, likewise(Color Webtoon)/(цветная вебтун версия)vs. the original, and(Fan Comics),(Digital Colored Comics),(Graphic Novel),(Sonic The Comic),(мини-комиксы),(Пролог к новелле). A bare(novel)/(новелла)was left too: on a manga site it marks the one title that isn't manga (1 case).
The rule is ranobelib's (bracketed marker at the very end, repeated, with ,/:// before
it) with the list above. Over the full catalog it changes exactly those 49 names.
other_names of a random 150 titles through get_info() (113 have some) had one bracketed
tail, an author's name (Love Planet (KISARAGI Sono)), and no marker.
Open questions
See the "Что НЕ проверено" section of CLAUDE.md for the current list: how a fractional
number affects the reading URL, illustration CDN structure for the p/img-only cases
(confirmed to be ranobelib.me itself, not a separate CDN — see above, so this is largely
resolved, but worth re-confirming CDN domain doesn't vary by title/region), paywall/403
behavior, /chapters pagination for very large titles, and reproducing textAlign/
heading/list formatting from the prosemirror format if it turns out to matter for exports.
Default branch/team selection is now resolved — see "Translation selection" above.