Source crawlers
Sources live in sources/ grouped by language dir (en/ is sub-bucketed by first letter:
sources/en/<letter>/<file>.py; other languages are flat). There is no scaffold command —
copy a similar existing source. The loader (services/sources/helper.py) skips files starting
with _, abstract classes, classes with is_template = True, and classes without a valid
base_url — a "missing" crawler is usually one of these. User-provided sources are discovered
the same way from ctx.config.crawler.user_sources (a dir under the app data dir).
Class hierarchy — pick your base
SoupTemplate (lncrawl/core/template.py) — the default for new sources. Declarative
CSS selectors over self.scraper.get_soup. Challenges are the scraper's problem, not the
source's: it escalates to a browser on its own evidence.
- A shared template in
lncrawl/templates/ — if the site runs a known engine
(WordPress/Madara, NovelFull, NovelMTL, MangaStream, FreeWebNovel, NovelPub, …), subclass
the matching template and override only what differs. This is the most common shape for new
sources and usually ~10 lines. Read the directory before writing a source: some templates
key off a publishing shape rather than a site engine — a novel that is a WordPress
category, or a Blogger label, with chapters as its posts — and those match small sites that
look bespoke.
LegacyCrawler (lncrawl/core/legacy.py) — the classic imperative API
(read_novel_info, download_chapter_body, optional search_novel, instance attrs
novel_title/chapters/volumes, helper self.get_soup). Most existing sources use it.
Never use it for new work, and convert away from it when you touch one — it exists to
keep ~200 committed sources loading, not as a base to build on.
Crawler (lncrawl/core/crawler.py) — the raw abstract base. Its modern abstract
methods are read_novel(novel) / download_chapter(chapter) — not the legacy names;
the two APIs must not be mixed in one class.
The Scraper (HTTP/BS4/Cloudflare) comes from the external lncrawl-scraper package
(from scraper import Scraper) — there is no scraper module inside this repo. PageSoup
selectors are null-safe: select_one() always returns a (possibly falsy) PageSoup, never
None.
SoupTemplate essentials
Class attributes: base_url (str or list — always required), language, and flags
can_search, can_login, has_manga, has_mtl. Selector groups:
- Novel:
novel_title_selector, novel_cover_selector, novel_author_selector,
novel_tags_selector, novel_synopsis_selector (defaults fall back to OpenGraph/meta).
- Chapters:
chapter_list_selector, chapter_title_selector, chapter_url_selector,
chapter_body_selector, chapter_list_reverse.
- Volumes:
volume_list_selector, volume_title_selector — leave empty and format_novel
auto-buckets chapters into volumes of chapters_per_volume.
- Search:
search_item_* selectors + build_search_url(query) (must override when
can_search).
When selectors can't express it, override the hooks: parse_title/cover/authors/tags/summary,
select_volume_tags, select_chapter_tags, parse_chapter_title/url, build_chapter_url,
parse_chapter_body, get_novel_soup. Chapter-list pagination has no framework helper —
loop pages inside select_chapter_tags (see existing sources that do this).
Idioms that matter
- URLs: route every href/src through
self.absolute_url(x). It resolves against
scraper.last_url, which only get_soup/post_soup set — after raw get()/
get_json(), pass page_url= explicitly.
- Cleaner: chapter HTML goes through
self.cleaner (TextCleaner,
lncrawl/core/cleaner.py). Tune it in initialize(): self.cleaner.bad_css.update({...})
for ad/nav selectors, bad_tag_text_pairs to drop tags whose text matches a pattern,
whitelist_attributes/whitelist_css_property to keep extras.
- Rate limit: declare the static class field
request_rate_limit = R (max requests/sec
to this source; default 3). It is enforced globally per source domain across all
concurrent server jobs: init_crawler (services/sources/service.py) builds one
scraper.SharedState per domain and hands it to every crawler for that domain, so the
pacing clock, the held exit address and the identity are one visitor rather than
several contradicting each other. The rate is a mean — each gap is drawn from a
distribution around it, because a constant interval is itself a signal. The
parallel-request cap and CLI worker pool derive from it (Crawler.max_concurrency()).
Do not call init_executor in initialize() — that's the legacy pattern and is dead in
server mode. Many sites ban parallel scrapers — when in doubt, keep the default.
- Headers/cookies/login:
self.scraper.headers is a plain dict you can write to, and
self.scraper.set_cookie(name, value) sets a cookie; implement login() and set
can_login = True. Do not try to set a User-Agent or reorder headers — the
impersonation profile owns the header set, and header order is read as a
fingerprint. Referer and the Sec-Fetch-* set are supplied per request by the
scraper's navigation chain; leave them alone.
format_novel renumbers everything (sorts, re-ids, buckets orphan chapters) — don't
rely on your assigned ids; set correct chapter.volume grouping instead.
- Data models (
lncrawl/core/models.py): Novel/Volume/Chapter/SearchResult are
Box-based (attribute access, extra kwargs preserved); use novel.add_volume(...) /
novel.add_chapter(...) which auto-assign ids.
Workflow
- Create
sources/<lang>/[<letter>/]<site>.py by copying the closest existing source or
template subclass.
- Implement (see above). Type-annotate signatures; f-strings only.
- Test against a real novel:
uv run python -m lncrawl crawl "https://site/novel/x" --first 3 -f epub --noin
The web Source Editor's streaming tester (services/sources/tester.py) is the reference
for what "passing" means — it reads the novel then downloads the first and last
chapters. Both ends, always: a list built from the wrong panel downloads perfectly and
arrives backwards, and a paginated list that silently stops at page one still passes a
first-chapter-only check.
- Read the chapter titles before believing the count. This is the step that catches the
failures a count cannot — see Reading a chapter list below.
make lint, then uv run python -m lncrawl dev check-sources for a batch.
- Do not run
make index-gen. A GitHub workflow regenerates sources/_index.json/.zip
and the README/SOURCES tables after the change lands; running it locally rewrites hundreds
of unrelated README lines into the diff. Add or rename a source and stop — CI indexes it.
(make check-sources is a different thing: an HTTP reachability probe of base URLs feeding
sources/_rejected.json. It does not validate crawler code either.)
Fixing a broken source
Reproduce before reading the report. Most "fix this source" issues were filed years ago
against an app version that predates the current scraper, so the first question is whether the
site still fails at all — several reproduce as works, and closing those is the whole fix.
Distinguish the failures, because they need different work and only one of them is yours:
| what comes back |
whose problem |
LNException: Failed to parse chapter list, wrong titles, empty bodies |
the source — stale selectors |
Exhausted: L<n> <name> naming a real layer |
the site is challenging; a source change will not help |
Exhausted: no detection layer |
ours — the scraper could not attribute the failure to anything the site did |
ServerError(502) … [Site is down] |
already in _rejected.json; the crawler never ran |
Never reject a domain that still serves the content. sources/_rejected.json maps a base
URL to a reason, and it means this host stopped serving relevant content — parked, dead DNS,
turned into a shop. A site that was rebuilt and now needs different selectors is a parser fix,
and a rendered page that turns out to hold real links was a picker failure, not a dead site.
Rejecting a live host loses it silently, because nothing re-probes a rejected entry.
Sibling domains are separate sites until proven otherwise. foo.com, foo.org and
www.foo.net routinely have separate crawlers, separate engines and separate fates — one
being terminated says nothing about the others. Check each host named in the report, and pick
the novel URL off the host's own landing page rather than reusing the one in a stale issue.
Reading a chapter list
A chapter count proves nothing on its own. Every failure below produced a plausible number and
a green run, and each was caught only by looking at the titles and at both ends of the list.
- Nav and share links arrive looking like chapters.
absolute_url resolves a bare
#fragment or a ?share= link back onto the novel's own path, so when you harvest
a[href] from a page rather than from a dedicated list container, Skip to content,
Search and Privacy become chapters one, two and three. Skip hrefs that start with # or
carry a query string, and compare against the novel path with the query and fragment
stripped.
- A "latest chapters" panel sits beside the real list, newest first, and often shares a
class with it. Sampling a few links to decide
chapter_list_reverse reads whichever panel
the selector hit first. Check the actual first and last title instead.
- A theme that prints its whole page tree puts every novel on the site into every novel's
sidebar. Scope to the children of this novel's own path.
- Titles that omit the chapter word are still chapters. Filtering a list down to rows
matching
chapter|capítulo|бөлүм silently drops the ones titled MARTIAL PEAK 2445:. Where
a site orders by publication date, take the date order and do not re-derive numbers.
When a site does expose numbers, prefer the order the site itself publishes in over one you
parse out of a title — a template that sorts on a scraped integer inverts the moment a title
says Volume 2 Chapter 1.
When plain HTTP is not enough
A source never drives a browser. A challenge is a detection layer, and the scraper
escalates to its own solver when its diagnosis says one is binding — reusing the clearance
for the requests that follow, which a browser a source opened itself cannot do.
The one case a source decides is different: a page that answers 200 with a shell that
JavaScript fills in. Nothing is blocking, so no diagnosis leads there and the scraper cannot
infer it — the source must say so with self.scraper.render_soup(url, wait_for="…"). Give it
a wait_for that cannot exist before the data does; a selector matching an empty
skeleton returns a page that parses to nothing.
Order of preference, and the second half is not optional. Look for an API the site's own
front-end calls before you render — open the network panel, or guess the obvious ones
(wp-json/wp/v2, a Blogger feeds/posts path, a /api/ route beside the page). An API is
faster, pages deterministically, and does not depend on a selector surviving a redesign. But
when there is no API, render — even though it is slow. A JavaScript shell is a reason to
render, never a reason to reject the site.
Most JS-shell hosts turn out to fetch their content over an API the page calls, so the
render pass is worth running mainly to find that request.
1---2name: add-source3description: Create or fix a source crawler — base-class choice (SoupTemplate/shared templates), selectors, cleaner tuning, reading a chapter list correctly, testing via CLI. Use when adding a new source site, fixing a broken crawler, or working in sources/ or lncrawl/templates/.4---56# Source crawlers78Sources live in `sources/` grouped by language dir (`en/` is sub-bucketed by first letter:9`sources/en/<letter>/<file>.py`; other languages are flat). There is **no scaffold command** —10copy a similar existing source. The loader (`services/sources/helper.py`) skips files starting11with `_`, abstract classes, classes with `is_template = True`, and classes without a valid12`base_url` — a "missing" crawler is usually one of these. User-provided sources are discovered13the same way from `ctx.config.crawler.user_sources` (a dir under the app data dir).1415## Class hierarchy — pick your base1617- **`SoupTemplate`** (`lncrawl/core/template.py`) — the default for new sources. Declarative18 CSS selectors over `self.scraper.get_soup`. Challenges are the scraper's problem, not the19 source's: it escalates to a browser on its own evidence.20- **A shared template in `lncrawl/templates/`** — if the site runs a known engine21 (WordPress/Madara, NovelFull, NovelMTL, MangaStream, FreeWebNovel, NovelPub, …), subclass22 the matching template and override only what differs. This is the most common shape for new23 sources and usually ~10 lines. Read the directory before writing a source: some templates24 key off a *publishing shape* rather than a site engine — a novel that is a WordPress25 category, or a Blogger label, with chapters as its posts — and those match small sites that26 look bespoke.27- **`LegacyCrawler`** (`lncrawl/core/legacy.py`) — the classic imperative API28 (`read_novel_info`, `download_chapter_body`, optional `search_novel`, instance attrs29 `novel_title`/`chapters`/`volumes`, helper `self.get_soup`). Most existing sources use it.30 **Never use it for new work, and convert away from it when you touch one** — it exists to31 keep ~200 committed sources loading, not as a base to build on.32- **`Crawler`** (`lncrawl/core/crawler.py`) — the raw abstract base. Its modern abstract33 methods are `read_novel(novel)` / `download_chapter(chapter)` — **not** the legacy names;34 the two APIs must not be mixed in one class.3536The `Scraper` (HTTP/BS4/Cloudflare) comes from the external **`lncrawl-scraper`** package37(`from scraper import Scraper`) — there is no scraper module inside this repo. `PageSoup`38selectors are null-safe: `select_one()` always returns a (possibly falsy) `PageSoup`, never39`None`.4041## SoupTemplate essentials4243Class attributes: `base_url` (str or list — always required), `language`, and flags44`can_search`, `can_login`, `has_manga`, `has_mtl`. Selector groups:4546- Novel: `novel_title_selector`, `novel_cover_selector`, `novel_author_selector`,47 `novel_tags_selector`, `novel_synopsis_selector` (defaults fall back to OpenGraph/meta).48- Chapters: `chapter_list_selector`, `chapter_title_selector`, `chapter_url_selector`,49 `chapter_body_selector`, `chapter_list_reverse`.50- Volumes: `volume_list_selector`, `volume_title_selector` — leave empty and `format_novel`51 auto-buckets chapters into volumes of `chapters_per_volume`.52- Search: `search_item_*` selectors + `build_search_url(query)` (must override when53 `can_search`).5455When selectors can't express it, override the hooks: `parse_title/cover/authors/tags/summary`,56`select_volume_tags`, `select_chapter_tags`, `parse_chapter_title/url`, `build_chapter_url`,57`parse_chapter_body`, `get_novel_soup`. Chapter-list pagination has no framework helper —58loop pages inside `select_chapter_tags` (see existing sources that do this).5960## Idioms that matter6162- **URLs**: route every href/src through `self.absolute_url(x)`. It resolves against63 `scraper.last_url`, which only `get_soup`/`post_soup` set — after raw `get()`/64 `get_json()`, pass `page_url=` explicitly.65- **Cleaner**: chapter HTML goes through `self.cleaner` (`TextCleaner`,66 `lncrawl/core/cleaner.py`). Tune it in `initialize()`: `self.cleaner.bad_css.update({...})`67 for ad/nav selectors, `bad_tag_text_pairs` to drop tags whose text matches a pattern,68 `whitelist_attributes`/`whitelist_css_property` to keep extras.69- **Rate limit**: declare the static class field `request_rate_limit = R` (max requests/sec70 to this source; default 3). It is enforced **globally per source domain** across all71 concurrent server jobs: `init_crawler` (`services/sources/service.py`) builds one72 `scraper.SharedState` per domain and hands it to every crawler for that domain, so the73 pacing clock, the held exit address and the identity are one visitor rather than74 several contradicting each other. The rate is a *mean* — each gap is drawn from a75 distribution around it, because a constant interval is itself a signal. The76 parallel-request cap and CLI worker pool derive from it (`Crawler.max_concurrency()`).77 Do **not** call `init_executor` in `initialize()` — that's the legacy pattern and is dead in78 server mode. Many sites ban parallel scrapers — when in doubt, keep the default.79- **Headers/cookies/login**: `self.scraper.headers` is a plain dict you can write to, and80 `self.scraper.set_cookie(name, value)` sets a cookie; implement `login()` and set81 `can_login = True`. Do **not** try to set a `User-Agent` or reorder headers — the82 impersonation profile owns the header set, and header *order* is read as a83 fingerprint. `Referer` and the `Sec-Fetch-*` set are supplied per request by the84 scraper's navigation chain; leave them alone.85- **`format_novel` renumbers everything** (sorts, re-ids, buckets orphan chapters) — don't86 rely on your assigned ids; set correct `chapter.volume` grouping instead.87- Data models (`lncrawl/core/models.py`): `Novel`/`Volume`/`Chapter`/`SearchResult` are88 Box-based (attribute access, extra kwargs preserved); use `novel.add_volume(...)` /89 `novel.add_chapter(...)` which auto-assign ids.9091## Workflow92931. Create `sources/<lang>/[<letter>/]<site>.py` by copying the closest existing source or94 template subclass.952. Implement (see above). Type-annotate signatures; f-strings only.963. Test against a real novel:97 ```bash98 uv run python -m lncrawl crawl "https://site/novel/x" --first 3 -f epub --noin99 ```100 The web Source Editor's streaming tester (`services/sources/tester.py`) is the reference101 for what "passing" means — it reads the novel then downloads the **first and last**102 chapters. Both ends, always: a list built from the wrong panel downloads perfectly and103 arrives backwards, and a paginated list that silently stops at page one still passes a104 first-chapter-only check.1054. **Read the chapter titles before believing the count.** This is the step that catches the106 failures a count cannot — see *Reading a chapter list* below.1075. `make lint`, then `uv run python -m lncrawl dev check-sources` for a batch.1086. **Do not run `make index-gen`.** A GitHub workflow regenerates `sources/_index.json`/`.zip`109 and the README/SOURCES tables after the change lands; running it locally rewrites hundreds110 of unrelated README lines into the diff. Add or rename a source and stop — CI indexes it.111 (`make check-sources` is a different thing: an HTTP reachability probe of base URLs feeding112 `sources/_rejected.json`. It does not validate crawler code either.)113114## Fixing a broken source115116Reproduce before reading the report. Most "fix this source" issues were filed years ago117against an app version that predates the current scraper, so the first question is whether the118site still fails at all — several reproduce as *works*, and closing those is the whole fix.119120**Distinguish the failures, because they need different work and only one of them is yours:**121122| what comes back | whose problem |123| --- | --- |124| `LNException: Failed to parse chapter list`, wrong titles, empty bodies | the source — stale selectors |125| `Exhausted: L<n> <name>` naming a real layer | the site is challenging; a source change will not help |126| `Exhausted: no detection layer` | ours — the scraper could not attribute the failure to anything the site did |127| `ServerError(502) … [Site is down]` | already in `_rejected.json`; the crawler never ran |128129**Never reject a domain that still serves the content.** `sources/_rejected.json` maps a base130URL to a reason, and it means *this host stopped serving relevant content* — parked, dead DNS,131turned into a shop. A site that was rebuilt and now needs different selectors is a parser fix,132and a rendered page that turns out to hold real links was a picker failure, not a dead site.133Rejecting a live host loses it silently, because nothing re-probes a rejected entry.134135**Sibling domains are separate sites until proven otherwise.** `foo.com`, `foo.org` and136`www.foo.net` routinely have separate crawlers, separate engines and separate fates — one137being terminated says nothing about the others. Check each host named in the report, and pick138the novel URL off the host's own landing page rather than reusing the one in a stale issue.139140## Reading a chapter list141142A chapter count proves nothing on its own. Every failure below produced a plausible number and143a green run, and each was caught only by looking at the titles and at both ends of the list.144145- **Nav and share links arrive looking like chapters.** `absolute_url` resolves a bare146 `#fragment` or a `?share=` link back onto the novel's own path, so when you harvest147 `a[href]` from a page rather than from a dedicated list container, *Skip to content*,148 *Search* and *Privacy* become chapters one, two and three. Skip hrefs that start with `#` or149 carry a query string, and compare against the novel path with the query and fragment150 stripped.151- **A "latest chapters" panel sits beside the real list**, newest first, and often shares a152 class with it. Sampling a few links to decide `chapter_list_reverse` reads whichever panel153 the selector hit first. Check the actual first and last title instead.154- **A theme that prints its whole page tree** puts every novel on the site into every novel's155 sidebar. Scope to the children of this novel's own path.156- **Titles that omit the chapter word are still chapters.** Filtering a list down to rows157 matching `chapter|capítulo|бөлүм` silently drops the ones titled `MARTIAL PEAK 2445:`. Where158 a site orders by publication date, take the date order and do not re-derive numbers.159160When a site does expose numbers, prefer the order the site itself publishes in over one you161parse out of a title — a template that sorts on a scraped integer inverts the moment a title162says `Volume 2 Chapter 1`.163164## When plain HTTP is not enough165166**A source never drives a browser.** A challenge is a detection layer, and the scraper167escalates to its own solver when its diagnosis says one is binding — reusing the clearance168for the requests that follow, which a browser a source opened itself cannot do.169170The one case a source decides is different: a page that answers `200` with a shell that171JavaScript fills in. Nothing is blocking, so no diagnosis leads there and the scraper cannot172infer it — the source must say so with `self.scraper.render_soup(url, wait_for="…")`. Give it173a `wait_for` that **cannot exist before the data does**; a selector matching an empty174skeleton returns a page that parses to nothing.175176**Order of preference, and the second half is not optional.** Look for an API the site's own177front-end calls before you render — open the network panel, or guess the obvious ones178(`wp-json/wp/v2`, a Blogger `feeds/posts` path, a `/api/` route beside the page). An API is179faster, pages deterministically, and does not depend on a selector surviving a redesign. But180when there is no API, **render — even though it is slow**. A JavaScript shell is a reason to181render, never a reason to reject the site.182183Most JS-shell hosts turn out to fetch their content over an API the page calls, so the184render pass is worth running mainly to *find* that request.