Self-hosted manager for the web novels I follow: add a novel by its table-of-contents URL, a worker checks it daily and fetches only new chapters, and the EPUB is regenerated into the Audiobookshelf library folder (its folder watcher picks it up). Scraping reuses WebToEpub's 640+ site parsers, run headless in Node (jsdom), instead of writing our own.
Project page: marvello.xyz/chapterly
shared/ db.js (the only module with SQL) + migrations/ — used by both worker and web
worker/ scraper + scheduler + CLI (cli.js, src/, test/, vendor-WebToEpub/)
web/ Next.js UI (reads the DB, records intents; never scrapes)
npm run setup # worker/setup.sh (clones WebToEpub into worker/vendor-WebToEpub/, npm install) + web install
npm test # worker tests (synthetic freewebnovel-shaped site, incl. full pipeline) + web tests
Re-run worker/setup.sh to pull upstream parser fixes. CLI commands below run from worker/
(cd worker && node cli.js …) or in Docker (docker compose exec worker node cli.js …).
cp .env.example .env && sed -i '' "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 32)|" .env
docker compose up -d --build # worker + web UI on http://localhost:3000
docker compose exec worker node cli.js user:create you@example.com "Your Name" # prompts for the password
docker compose logs -f worker web
docker compose build --no-cache && docker compose up -d # pull WebToEpub parser fixes
The web UI never scrapes: add / check now / retry / interval / pause / delete are written to the DB and
the worker picks them up within CHAPTERLY_TICK_MIN (1 min). The SQLite DB lives in the chapterly-data
Docker volume (shared by both containers) and the EPUBs in ./library. Override with CHAPTERLY_DATA_DIR,
CHAPTERLY_LIBRARY_DIR (point this at the Audiobookshelf library) and PUID/PGID (the user that owns
that library, default 1000) in .env. Don't open the DB from the host while the containers run on
Docker Desktop (SQLite WAL across the Mac/VM boundary can corrupt reads); use
docker compose exec worker node cli.js list instead. On a Linux host a host path for CHAPTERLY_DATA_DIR
is fine. Change the password with cli.js user:password <email> (logs out every session). The web port
listens on all host interfaces (see Deploy). Every CLI command below also works via docker compose exec worker node cli.js ….
The library page is also the reader: tap a novel to read it. Use Chrome's "Install app" to put it on the
home screen; it opens offline from what's saved on the phone (adding novels, check status and the novel's
management page need the server and are hidden while it can't be reached). Chapters come straight from the DB. Progress is saved per novel and only moves forward on its own:
opening an earlier chapter shows "Your progress is at …" with Set progress here / Go to it. The
current chapter plus the next 50 unread of every novel you're reading are kept on the phone for offline
reading; "Download unread" on a novel keeps all of them. EPUB building for Audiobookshelf can be switched
off per novel (novel page, or node cli.js epub <id> on|off).
Controlled by env (see .env.example). With no AUTH_OIDC_* set, only the password form is shown.
Set all three AUTH_OIDC_* (issuer, client id, secret) to add a "Sign in with <AUTH_OIDC_NAME>" button
(password stays as a fallback unless AUTH_PASSWORD_LOGIN=false). Partial OIDC config, or
AUTH_PASSWORD_LOGIN=false without OIDC, stops the web container at startup. Provider setup:
- Create a confidential OAuth2/OpenID client with redirect URI
https://<your host>/api/auth/callback/oidc, RS256 signing, scopesopenid email profile. The provider must returnemail_verified: truefor your user (the app refuses unverified emails). - Use the provider's issuer URL for
AUTH_OIDC_ISSUER, and restrict the client to your own user/group. - First sign-in links the OIDC identity to the account with the same email; afterwards only that
identity is accepted.
cli.js user:unlink-oidc <email>resets the link.
Password sign-in is rate-limited (10 attempts/min) per client IP, plus a per-account lockout. The app
only learns the client IP from Cloudflare's cf-connecting-ip header, and only trusts it with
TRUST_CF_HEADER=1 — set that only behind a Cloudflare tunnel/proxy that is the sole way in. Without it
(e.g. plain compose, no proxy) all clients share one rate-limit bucket; the account lockout still applies.
X-Forwarded-For is never trusted. Sessions expire after 7 days without use.
Keep a git checkout of this repo on the server, next to its .env (never committed). To update, push
from your machine, then pull and rebuild on the server:
git push origin main
ssh <server> 'cd <chapterly dir> && git pull --ff-only && docker compose up -d --build'
In the server's .env, point CHAPTERLY_LIBRARY_DIR at the Audiobookshelf library, PUID/PGID at
its owner, and optionally set CHAPTERLY_DATA_DIR (a host path), CHAPTERLY_WEB_PORT and AUTH_URL
(the URL you open the UI at). CLI commands: docker exec -it chapterly-worker node cli.js ….
The web port listens on all of the host's interfaces, so it's reachable over the LAN or a private VPN.
Keep it behind a firewall and never port-forward it to the internet; for public access put an
authenticating tunnel / reverse proxy in front, with AUTH_URL set to that https URL. For localhost
only, bind it as 127.0.0.1:${CHAPTERLY_WEB_PORT:-3000}:3000 in compose.yaml.
A change to how EPUBs are packed only reaches existing books when they are next rebuilt (a new chapter
arrives); to apply it now: for i in $(seq 1 <last id>); do docker exec chapterly-worker node cli.js build $i; done.
node cli.js add https://freewebnovel.com/novel/<slug> # fetch TOC, store novel + chapter list
node cli.js list # fetched/total chapters, last check, errors, EPUB path
node cli.js update [id] # check now: fetch new chapters, rebuild EPUB
node cli.js build <id> # rebuild EPUB from stored chapters
node cli.js pause|resume|remove <id>
node cli.js retry <id> # retry chapters that gave up
node cli.js epub <id> on|off # EPUB / Audiobookshelf rescan for this novel
npm run worker # run forever (from worker/)
- Storage: SQLite at
CHAPTERLY_DB(defaultdata/chapterly.dbat the repo root), via built-innode:sqlite(Node 24). All SQL lives inshared/db.js; migrations are numbered files inshared/migrations/. Postgres later = rewritedb.js, callers unchanged. - Worker: wakes every
CHAPTERLY_TICK_MIN(default 1) and checks each active novel that was asked to "check now" or whose last check started ≥check_interval_min(default 1440 = once a day) ago, one novel at a time. New chapters are fetched one by one with the parser's throttle and saved as they arrive, so a crash loses nothing. A failed chapter stays pending and is retried with exponential backoff: after the nth failure it waitsCHAPTERLY_RETRY_BASE_MIN × 2^(n-1)(default 1h, 2h, 4h, 8h; any wait capped at 7 days); afterCHAPTERLY_MAX_ATTEMPTS(5) it stops andlistshows the error.node cli.js retry <id>resets it. A novel is checked as soon as one of its chapters' retry is due, so the backoff applies even with daily checks — unless the whole check is backing off (site down), which holds chapter retries back. (Within a single check, WebToEpub's HttpClient already retries 429/5xx after 15/30/60/120 s.) Each HTTP request times out afterCHAPTERLY_FETCH_TIMEOUT_SEC(default 120); a timeout counts as a failed attempt. The worker refuses to fetch private/loopback/link-local/CGNAT addresses (also after redirects) and responses over 20 MB;CHAPTERLY_ALLOW_PRIVATE=1allows private addresses (e.g. a site on your own network). A check that fails as a whole (e.g. the novel's page times out) is retried on the same backoff instead of a full interval later, so a site that's down at the same hour every day doesn't block the novel forever. - Restarts: on startup the worker resumes any check it was stopped in the middle of (restart, redeploy, crash) right away instead of after the novel's interval, fetching only the chapters it hadn't saved. An interrupted check counts as a failed check: resumed at once the first time, backed off if it's interrupted again (so a novel that crashes the worker can't crash-loop it). "Check now" requests (these, or the UI button) run before routine scheduled checks. The novel page warns when the worker hasn't been seen for 3 ticks (min 10 min).
- Story status: each novel is ongoing / completed / dropped — read from the site's
og:novel:statustag on every check, unless you set it yourself (novel page orcli.js series <id> <status>), after which the site never overrides it. Completed + every chapter fetched → no more scheduled checks (only "Check now"); dropped (author or site abandoned it) → checked at most weekly. - Audiobookshelf rescan (optional): with
CHAPTERLY_ABS_URL,CHAPTERLY_ABS_TOKEN(an ABS API key of an admin user; ABS → Settings → API Keys) andCHAPTERLY_ABS_LIBRARY(library name or id, e.g.Ebooks), the worker calls ABS'sPOST /api/libraries/:id/scanright after an EPUB changes, instead of waiting for ABS's nightly scan. Best effort: failures are logged, the check still succeeds. A partial config stops the worker at startup. For ABS published on the same host, useCHAPTERLY_ABS_URL=http://host.docker.internal:<ABS port>. - EPUB: packed by WebToEpub's own
EpubPacker(EPUB 3 + toc.ncx, cover embedded), written toCHAPTERLY_LIBRARY/<Author>/<Title>/<Title>.epub(defaultlibrary/at the repo root) via temp file + rename. The path is fixed on first build, so Audiobookshelf keeps it as one item. Chapters are rendered one at a time with an event-loop yield every 50, so memory stays flat for long novels (4800 chapters ≈ 750 MB peak; packing them in one synchronous pass needed > 4 GB, because jsdom WeakRefs keep each sanitized temp document alive until the task ends). A site heading that repeats the chapter title (<h2>with the same "Chapter N" right under WebToEpub's<h1>, as on freewebnovel) is dropped at build time. - Library (web UI): Overview / Table / Posters (remembered per device, in localStorage), plus search (title or
author), a filter (new chapters, ongoing / completed / dropped, errors, paused) and a sort (new first,
title, recently updated, recently added, most chapters). Search, filter and sort live in the URL
(
?q=&filter=&sort=), so back/forward and bookmarks keep them. - "+N new" badge (web UI): chapters fetched in the last 24 h. It expires on its own; rebuilding an EPUB doesn't reset it.
node:sqliteprints an ExperimentalWarning on Node 24;npm --prefix worker run workerhides it, or setNODE_OPTIONS=--disable-warning=ExperimentalWarning.
node cli.js parser https://freewebnovel.com/novel/<slug>
node cli.js info https://freewebnovel.com/novel/<slug> > known.json
node cli.js chapter https://freewebnovel.com/novel/<slug> 1
node cli.js check https://freewebnovel.com/novel/<slug> known.json # new chapters only
Requests go through worker/src/browserFetch.js (got-scraping: Chrome-like TLS + headers), which passes
Cloudflare's bot check on freewebnovel without cookies. If a site still blocks, set
CHAPTERLY_COOKIE="cf_clearance=..." plus CHAPTERLY_UA (that browser's exact User-Agent).
const { createScraper } = require("./worker/src/scraper");
const { diffChapters } = require("./worker/src/diff");
const s = createScraper(); // ~0.5s, load once per worker
const novel = await s.getNovel(tocUrl); // {title, author, cover, chapters:[{index,url,title}], ...}
const ch = await s.getChapter(novel, url); // {title, html, text, warnings}; honours parser throttle
const { added } = diffChapters(storedChapters, novel.chapters);
worker/src/loader.jsreadspopup.htmlto get WebToEpub's script order, loads every core + parser file into one jsdom VM context, and stubs the extension-only parts (chrome.*, progress bar, chapter table UI, i18n from_locales/en). Node supplies fetch, TextDecoder, etc.- zip.js and DOMPurify (WebToEpub's npm deps, normally copied into
plugin/by its postinstall) are loaded from this project'snode_modules/. worker/src/scraper.jsmirrors the extension flow: pick parser by hostname ->getChapterUrls(incl. paginated TOCs) ->fetchChapter->convertRawDomToContent(the same cleanup the EPUB gets). Images are left as absolute URLs for the EPUB builder to download.
- Only sites with a dedicated WebToEpub parser are supported. Other sites would fall back to
WebToEpub's DefaultParser, which needs per-site CSS configured in the extension's UI; headless it
would turn any page's links into junk chapters, so they are rejected ("No WebToEpub parser for this site").
The worker publishes the ~636 supported hostnames to the DB at startup, so the web Add form rejects
other sites instantly. The ~25 sites WebToEpub matches by URL/page rules instead of hostname can
only be added with
cli.js add. - Password login locks for up to 60 min after repeated failures, and anyone who can reach the login page can trigger that. OIDC login, if configured, stays usable while it's locked.
- Sites that render chapters with client-side JS, or sit behind interactive Cloudflare challenges (Turnstile), return empty/blocked HTML here too. Those need a Playwright fallback (next milestone).
innerTextis approximated bytextContent(jsdom has no layout); a few parsers that depend on rendered line breaks may produce slightly different whitespace.- License: GPL-3.0-or-later (see
LICENSE), because the worker runs WebToEpub (GPL-3.0-only) in-process. WebToEpub isn't in this repo (fetched at setup / build); a published Docker image does include it, with its ownLICENSE.md.
- Keep
worker/vendor-WebToEpub/unmodified;worker/setup.shpulls upstream parser fixes. Patches go inworker/src/loader.js(stubs/polyfills), never in vendor files. - Be polite to sites: parser throttle, one request per site at a time, daily checks. Prefer a novel's original translator site over aggregators when WebToEpub supports it.
- Library pagination (a couple of hundred novels+). The library query counts chapters per novel on every load (covering index since 008); at that size also store the counts on the novel row.
- Playwright fallback per novel (
fetch_mode: http|browser) for JS-rendered sites or interactive Cloudflare challenges.