Guidance for Claude Code working in this repo —
Lightnovel Crawler: a CLI scraper that
produces e-books and a FastAPI server + web UI that turns the same engine into a
multi-user job/library service, in one package. Both share a single in-process AppContext
(ctx) singleton — concurrency is threads + asyncio inside one Python process. The web UI
source lives in the sibling lncrawl-web repo; the HTTP Scraper lives in the external
lncrawl-scraper package (imported as scraper) — 1.0+, which is organised around a
model of bot detection rather than a request pipeline. Two consequences show up here:
the profile owns the header set and the User-Agent (never set one), and per-origin state
is shared via one process-wide SharedState owned by ctx.scraper
(services/scraper.py) — the only place a Scraper is
constructed.
Deep, task-scoped knowledge lives in .claude/skills/. Read the matching skill before
starting work in its area — they hold the recipes and invariants this file only points to:
| Skill | Use when |
|---|---|
add-source |
Creating/fixing a source crawler; anything in sources/ or lncrawl/templates/ |
triage-source-issues |
Working the tracker's source/source-issue backlog; bulk host probing |
add-api-endpoint |
Server routes, security, DTOs, pagination, errors (server/) |
add-job-type |
Job kinds, handlers, scheduler behavior (services/scheduler/, services/jobs/) |
db-migration |
DAO model changes and Alembic migrations (dao/, migrations/) |
output-and-translation |
Output formats (binder) and the translation service |
releasing |
Version bumps, the tag-triggered release pipeline, CI workflows |
Toolchain: uv, Python per pyproject.toml. The
Makefile wraps all common tasks — read it for the full target list.
make install # setup + uv sync --all-extras --all-groups
make start # run the server (make dev = with auto-reload)
make lint # pyright lncrawl + ruff format --check + ruff check — run before finishing
make lint-fix # ruff check --fix + ruff format
make index-gen # regenerate source index + SOURCES.md tables — CI's job, not yours
make check-sources # HTTP reachability probe of source base URLs (NOT a code validator)Do not run make index-gen. A GitHub workflow regenerates the index and the generated
README/SOURCES tables after a change lands; running it locally buries the actual change under
hundreds of unrelated regenerated lines.
Run without make: uv run python -m lncrawl [args].
uv run python -m lncrawl dev check-sources is the code validator: it imports and instantiates
every crawler offline, asserts the delegation surface each base class promises, and fails if a
crawler in sources/_index.json no longer loads. Run it after anything that touches core/,
lncrawl/templates/ or a batch of sources.
No automated test suite — test.py is a scratchpad. Validate with lint, then exercise the
real thing, e.g.:
uv run python -m lncrawl crawl "https://site.com/novel/url" --first 3 -f epublncrawl/context.py exports ctx, the service registry — read it for
the current service list. Every service is a @cached_property with a function-local lazy
import returning a no-arg constructor: constructed on first access, never before. The lazy
import is load-bearing — it keeps lncrawl crawl from importing the FastAPI/DB stack.
Always use ctx.<service>; never instantiate service classes directly (services reach
each other the same way).
ctx.setup()boots logger → config → DB (Alembic migrations) → seed data (admin user, secrets) → sources.ctx.destroy()closes resource-holding services, guarded byif "<name>" in self.__dict__so never-touched services aren't constructed at shutdown — the guard string must match the property name exactly.- Adding a service: new
@cached_propertywith lazy import; no constructor args; add adestroy()guard if it holds threads/connections/files; add asetup()call only for boot-time initialization; server-start background work goes in the FastAPI lifespan instead. - Notable services:
ctx.tier(AccessManager, services/access.py) — every quota/permission check goes throughctx.tier.<check>(user); limits are per-tier class dicts whereNonemeans unlimited.ctx.scraper(ScraperService, services/scraper.py) — the only place aScraperis built;open()for crawl traffic (paced, remembered, routed through the configured exits) andplain()for everything else.ctx.http(services/fetch.py) wrapsplain()for non-crawl HTTP (translators, Calibre API, favicons); passsignal=per request rather than assigning it to the session.ctx.job_notifier— job-state emails, triggered automatically by handler helpers.
- lncrawl/__main__.py →
main()inlncrawl/__init__.py: frozen (PyInstaller) double-click launches the desktop webview; an env flag re-execs the exe as the Python LSP server; otherwise the Typer CLI in lncrawl/app.py (version,config,sources,crawl,search,server,app, plus a hiddendevgroup with the migration commands). - lncrawl/server/app.py: FastAPI app.
lifespanrunsctx.setup()then starts the background services (mail, scheduler, recommendation warmup) andctx.destroy()s on exit. API at/api, SPA at/, Swagger at/docs,/healthfor liveness. - lncrawl/server/webview.py: desktop launcher — Chrome/Edge
app-mode, fallback to system browser; logs in via a LOCAL-scope
?authToken=.
lncrawl/services/scheduler/ — JobScheduler spawns daemon
worker threads: general job runners, a dedicated artifact-only worker, a Scrubber cleanup
loop, and a stale-job reset loop. JobRunner._claim_next() does find-and-claim under a
module-level EventLock (fairness: one running job per user and per source domain);
run_job() executes outside the lock, dispatching to the first matching handler in
_HANDLER_REGISTRY. Cancellation is cooperative — a per-claim Event that handlers
poll; nothing kills threads. Job status is polled via REST; the only WebSocket is /api/lsp.
Full contracts and the add-a-job-type recipe: add-job-type skill.
Sources live in sources/ grouped by language; user sources load from
ctx.config.crawler.user_sources. Base classes in lncrawl/core/:
Crawler (abstract) → CrawlerTemplate → SoupTemplate (declarative selectors — the base
for all new sources) and LegacyCrawler (the classic read_novel_info/download_chapter_body
API most existing sources use — kept so they keep loading, never a base for new work).
A source never drives a browser: the scraper escalates to one on its own evidence, and a
page whose HTML is not its content is fetched with self.scraper.render_soup(url, wait_for=…)
— preferring an API the site's own front-end calls when one exists, but rendering rather than
giving up when one does not. Shared site-engine templates (WordPress/Madara, NovelFull, …)
live in lncrawl/templates/ — subclassing one is usually a ~10-line
source. There is no scaffold command; copy a similar source. sources/_rejected.json records
hosts that stopped serving relevant content — a rebuilt site belongs in a parser fix, not in
there. Everything else: add-source skill.
SQLModel/SQLAlchemy; models in lncrawl/dao/ (all extend BaseTable: UUID id,
UNIX-ms timestamps, JSON extra). Enums in lncrawl/enums.py,
re-exported from dao/__init__.py, stored by name (native ENUM on Postgres → enum
changes need a sync migration). SQLite by default, Postgres/MySQL via DATABASE_URL;
Alembic migrations run automatically on startup via the dev migrate CLI machinery.
Recipe: db-migration skill.
Routers in lncrawl/server/api/, aggregated with router-level
Security(ensure_user)/ensure_admin dependencies in its __init__.py. Pydantic DTOs in
server/models/ are distinct from the DAO models. Errors are
pre-instantiated ServerErrors singletons (lncrawl/exceptions.py) —
never hand-roll HTTPException. server/web/ is committed build
output synced from lncrawl-web — never hand-edit. Recipes: add-api-endpoint skill.
Binder (services/binder/): EPUB/JSON/text native; every other
format converts from EPUB via Calibre (local exe or remote API). Translation
(services/translators.py): in-process via the external
lncrawl-translator package — lncrawl owns the glossary loop and persistence, the package
owns engines/routing/failover; its dashboard is mounted admin-gated at /api/translator.
Recipes: output-and-translation skill.
lncrawl/config.py: typed config — _Section subclasses exposing
@property getter/setter pairs over a JSON-persisted store; property docstrings surface in
the admin settings UI. Secrets are marked Annotated[..., Sensitive] and redacted in the
admin API. Data dir: LNCRAWL_DATA_PATH env var, else the platform app dir; DATABASE_URL
and LNCRAWL_CONFIG override DB and config file; .env is auto-loaded.
- ruff (pyproject.toml) + pyright — the pyproject is the source of truth for line length, quote style, target version, and excluded dirs.
- Support the lowest Python the package can. Never raise
requires-pythonor a CI interpreter to satisfy a dependency or quiet a type-checker — mark the dependency with an environment marker and ignore at the import site instead. Users run old interpreters. - f-strings for all string interpolation and type annotations on function signatures and variable declarations — house conventions (not machine-enforced; follow them anyway).
- SOURCES.md source tables and the README.md CLI help and source-count blocks between
<!-- auto generated -->markers are rewritten bymake index-gen— don't hand-edit those regions, and keep each marker paired.README.mdis also the PyPI long description, so every link and image in it must be an absolute URL.CHANGELOG.mdsections are curated by hand and become release notes. CHANGELOG.md: one line per paragraph, and keep entries short. The release workflow lifts a version's section out verbatim, and the renderer on the other side turns a single newline into a line break — so never hard-wrap, or the paragraph arrives as a ragged column with its indentation showing. (Blank lines still separate paragraphs, and a continuation paragraph inside a bullet still needs its indent.) An entry is a bold lead sentence plus the shortest why that would stop someone undoing it — not the investigation that produced it. Reasoning at length belongs in the code comment or the docstring, where the reader is already looking at the thing it explains.- Prefer patching over refactoring in vendored/generated areas;
lncrawl/server/web/andsources/_index.*are generated.
- Never commit or push automatically. When work is done, pause and ask; at most draft a
commit message for the user to commit themselves. Only run
git commitwhen the user explicitly requests it in that moment — prior approval does not carry over. - No AI attribution trailers — never append
Co-Authored-By: Claude(or similar). - Message style: imperative subject, no type prefix; body bullets for non-trivial changes.