@duyquangnvx/webnovel-downloader 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -448,7 +448,8 @@ __export(index_exports, {
448
448
  builtinAdapters: () => builtinAdapters,
449
449
  createDownloader: () => createDownloader,
450
450
  createLogger: () => createLogger,
451
- downloader: () => downloader
451
+ downloader: () => downloader,
452
+ parseNovelHeader: () => parseNovelHeader
452
453
  });
453
454
  module.exports = __toCommonJS(index_exports);
454
455
 
@@ -881,6 +882,31 @@ function clamp(n, lo, hi) {
881
882
  // src/http/index.ts
882
883
  init_cookie_jar();
883
884
 
885
+ // src/http/stack-builder.ts
886
+ var HttpStack = class _HttpStack {
887
+ #client;
888
+ constructor(client) {
889
+ this.#client = client;
890
+ }
891
+ static from(leaf) {
892
+ return new _HttpStack(leaf);
893
+ }
894
+ rotateUserAgent(uas, defaultHeaders, cookieJar) {
895
+ return new _HttpStack(
896
+ new UserAgentRotatingHttpClient(this.#client, uas, defaultHeaders, cookieJar)
897
+ );
898
+ }
899
+ rateLimit(limiter, onEvent) {
900
+ return new _HttpStack(new RateLimitedHttpClient(this.#client, limiter, onEvent));
901
+ }
902
+ retry(opts, logger, limiter, onEvent) {
903
+ return new _HttpStack(new RetryingHttpClient(this.#client, opts, logger, limiter, onEvent));
904
+ }
905
+ build() {
906
+ return this.#client;
907
+ }
908
+ };
909
+
884
910
  // src/http/tiered.ts
885
911
  init_cf_challenge();
886
912
  var FALLBACK_UA = DEFAULT_UA_POOL[0] ?? "Mozilla/5.0";
@@ -942,21 +968,8 @@ function buildHttpClient(opts = {}) {
942
968
  const logger = opts.logger ?? silentLogger;
943
969
  const cookieJar = opts.cookieJar ?? new CookieJar();
944
970
  const leaf = opts.undiciOverride ?? new UndiciHttpClient({ logger });
945
- const ua = new UserAgentRotatingHttpClient(
946
- leaf,
947
- opts.userAgents ?? new UserAgents(),
948
- opts.defaultHeaders,
949
- cookieJar
950
- );
951
971
  const limiter = opts.rateLimiter ?? new RateLimiter(opts.rateLimit ?? DEFAULT_RATE_LIMIT);
952
- const limited = new RateLimitedHttpClient(ua, limiter, opts.onEvent);
953
- const httpBranch = new RetryingHttpClient(
954
- limited,
955
- normalizeRetry(opts.retry),
956
- logger,
957
- limiter,
958
- opts.onEvent
959
- );
972
+ const httpBranch = HttpStack.from(leaf).rotateUserAgent(opts.userAgents ?? new UserAgents(), opts.defaultHeaders, cookieJar).rateLimit(limiter, opts.onEvent).retry(normalizeRetry(opts.retry), logger, limiter, opts.onEvent).build();
960
973
  const { mode } = normalizeTransport(opts.transport);
961
974
  if (mode === "http-only") {
962
975
  return httpBranch;
@@ -1411,7 +1424,6 @@ function toResponse(entry) {
1411
1424
 
1412
1425
  // src/core/download-run.ts
1413
1426
  var import_p_queue = __toESM(require("p-queue"), 1);
1414
- var import_node_path6 = require("path");
1415
1427
 
1416
1428
  // src/core/debouncer.ts
1417
1429
  var Debouncer = class {
@@ -1474,30 +1486,47 @@ init_primitives();
1474
1486
 
1475
1487
  // src/pipeline/fetch-toc.ts
1476
1488
  init_errors();
1489
+
1490
+ // src/pipeline/walk-toc.ts
1491
+ init_errors();
1477
1492
  var TOC_PROGRESS_EMIT_EVERY = 50;
1478
- async function fetchTocStage(adapter, url, ctx, bus, onRef, range) {
1493
+ async function walkToc(source, opts) {
1494
+ const from = opts.range?.from;
1495
+ const to = opts.range?.to;
1479
1496
  let count = 0;
1480
- const from = range?.from;
1481
- const to = range?.to;
1482
- if (ctx.signal.aborted) throw new CancelledError({ cause: ctx.signal.reason });
1483
- for await (const raw of adapter.fetchChapterList(url, ctx)) {
1484
- if (ctx.signal.aborted) throw new CancelledError({ cause: ctx.signal.reason });
1485
- const parsed = ChapterRefSchema.safeParse(raw);
1486
- if (!parsed.success) {
1487
- throw new ParseError(`Invalid ChapterRef from adapter`, { cause: parsed.error, url });
1488
- }
1489
- const ref = parsed.data;
1497
+ if (opts.signal.aborted) throw new CancelledError({ cause: opts.signal.reason });
1498
+ for await (const ref of source) {
1499
+ if (opts.signal.aborted) throw new CancelledError({ cause: opts.signal.reason });
1490
1500
  if (to !== void 0 && ref.index > to) break;
1491
1501
  if (from !== void 0 && ref.index < from) continue;
1492
- await onRef(ref);
1502
+ await opts.onRef(ref);
1493
1503
  count++;
1494
- if (count % TOC_PROGRESS_EMIT_EVERY === 0) bus.emit({ type: "toc:progress", discovered: count });
1504
+ if (count % TOC_PROGRESS_EMIT_EVERY === 0) opts.bus.emit({ type: "toc:progress", discovered: count });
1495
1505
  }
1496
- bus.emit({ type: "toc:progress", discovered: count });
1497
- bus.emit({ type: "toc:complete", total: count });
1506
+ opts.bus.emit({ type: "toc:progress", discovered: count });
1507
+ opts.bus.emit({ type: "toc:complete", total: count });
1498
1508
  return count;
1499
1509
  }
1500
1510
 
1511
+ // src/pipeline/fetch-toc.ts
1512
+ async function fetchTocStage(adapter, url, ctx, bus, onRef, range) {
1513
+ async function* validated() {
1514
+ for await (const raw of adapter.fetchChapterList(url, ctx)) {
1515
+ const parsed = ChapterRefSchema.safeParse(raw);
1516
+ if (!parsed.success) {
1517
+ throw new ParseError(`Invalid ChapterRef from adapter`, { cause: parsed.error, url });
1518
+ }
1519
+ yield parsed.data;
1520
+ }
1521
+ }
1522
+ return walkToc(validated(), {
1523
+ ...range !== void 0 ? { range } : {},
1524
+ bus,
1525
+ signal: ctx.signal,
1526
+ onRef
1527
+ });
1528
+ }
1529
+
1501
1530
  // src/pipeline/fetch-chapters.ts
1502
1531
  init_errors();
1503
1532
  async function fetchChapterTask(adapter, ref, ctx, bus) {
@@ -1525,130 +1554,15 @@ function assembleNovelData(metadata, results) {
1525
1554
  // src/core/download-run.ts
1526
1555
  init_errors();
1527
1556
 
1528
- // src/storage/resume-store.ts
1529
- var import_promises3 = require("fs/promises");
1557
+ // src/storage/resume-token.ts
1530
1558
  var import_node_path3 = require("path");
1531
- init_errors();
1532
-
1533
- // src/storage/resume-state.ts
1534
1559
  var import_zod4 = require("zod");
1535
- init_primitives();
1536
- var ResumeFailureRecordSchema = import_zod4.z.object({
1537
- index: ChapterIndexSchema,
1538
- lastError: import_zod4.z.object({ code: import_zod4.z.string(), message: import_zod4.z.string() }).readonly()
1539
- }).readonly();
1540
- var ResumeStateSchema = import_zod4.z.object({
1541
- schemaVersion: import_zod4.z.literal(1),
1542
- url: UrlSchema,
1543
- adapterId: AdapterIdSchema,
1544
- metadata: NovelMetadataSchema.nullable(),
1545
- toc: import_zod4.z.array(ChapterRefSchema).readonly().nullable(),
1546
- completed: import_zod4.z.array(ChapterIndexSchema).readonly(),
1547
- failed: import_zod4.z.array(ResumeFailureRecordSchema).readonly(),
1548
- startedAt: IsoDateSchema,
1549
- updatedAt: IsoDateSchema
1550
- }).readonly();
1551
-
1552
- // src/storage/resume-store.ts
1553
- var FilesystemResumeStore = class {
1554
- async load(path) {
1555
- let text;
1556
- try {
1557
- text = await (0, import_promises3.readFile)(path, "utf8");
1558
- } catch (err) {
1559
- if (isEnoent(err)) return null;
1560
- throw err;
1561
- }
1562
- let raw;
1563
- try {
1564
- raw = JSON.parse(text);
1565
- } catch (cause) {
1566
- throw new ParseError("Resume state file is corrupt; delete it to start over", { cause, path });
1567
- }
1568
- const parsed = ResumeStateSchema.safeParse(raw);
1569
- if (!parsed.success) {
1570
- throw new ParseError("Resume state file is corrupt; delete it to start over", {
1571
- cause: parsed.error,
1572
- path
1573
- });
1574
- }
1575
- return parsed.data;
1576
- }
1577
- async save(path, state) {
1578
- await atomicWriteJson(path, state);
1579
- }
1580
- async delete(path) {
1581
- try {
1582
- await (0, import_promises3.unlink)(path);
1583
- } catch (err) {
1584
- if (!isEnoent(err)) throw err;
1585
- }
1586
- }
1587
- };
1588
-
1589
- // src/storage/chapter-store.ts
1590
- var import_promises4 = require("fs/promises");
1591
- var import_node_path4 = require("path");
1592
- var FilesystemChapterStore = class {
1593
- #logger;
1594
- #dir;
1595
- constructor(dir, opts = {}) {
1596
- this.#dir = (0, import_node_path4.resolve)(dir);
1597
- this.#logger = opts.logger ?? silentLogger;
1598
- }
1599
- async save(chapter) {
1600
- await atomicWriteJson((0, import_node_path4.join)(this.#dir, `${Number(chapter.index)}.json`), chapter);
1601
- }
1602
- async loadAll() {
1603
- let entries;
1604
- try {
1605
- entries = await (0, import_promises4.readdir)(this.#dir);
1606
- } catch (err) {
1607
- if (isEnoent(err)) return [];
1608
- throw err;
1609
- }
1610
- const chapters = [];
1611
- for (const name of entries) {
1612
- if (!/^\d+\.json$/.test(name)) continue;
1613
- const path = (0, import_node_path4.join)(this.#dir, name);
1614
- let text;
1615
- try {
1616
- text = await (0, import_promises4.readFile)(path, "utf8");
1617
- } catch (err) {
1618
- this.#logger.warn({ path, err }, "chapter-store: failed to read file, skipping");
1619
- continue;
1620
- }
1621
- let raw;
1622
- try {
1623
- raw = JSON.parse(text);
1624
- } catch (err) {
1625
- this.#logger.warn({ path, err }, "chapter-store: invalid JSON, skipping");
1626
- continue;
1627
- }
1628
- const parsed = ChapterSchema.safeParse(raw);
1629
- if (!parsed.success) {
1630
- this.#logger.warn(
1631
- { path, issues: parsed.error.issues },
1632
- "chapter-store: schema mismatch, skipping"
1633
- );
1634
- continue;
1635
- }
1636
- chapters.push(parsed.data);
1637
- }
1638
- chapters.sort((a, b) => Number(a.index) - Number(b.index));
1639
- return chapters;
1640
- }
1641
- };
1642
-
1643
- // src/storage/resume-token.ts
1644
- var import_node_path5 = require("path");
1645
- var import_zod5 = require("zod");
1646
1560
  init_errors();
1647
1561
  init_primitives();
1648
- var TokenPayloadSchema = import_zod5.z.object({
1649
- v: import_zod5.z.literal(1),
1650
- stateFile: import_zod5.z.string().min(1),
1651
- urlHash: import_zod5.z.string().min(1)
1562
+ var TokenPayloadSchema = import_zod4.z.object({
1563
+ v: import_zod4.z.literal(1),
1564
+ stateFile: import_zod4.z.string().min(1),
1565
+ urlHash: import_zod4.z.string().min(1)
1652
1566
  });
1653
1567
  function encodeResumeToken(stateFile, url) {
1654
1568
  const payload = { v: 1, stateFile, urlHash: sha1(url) };
@@ -1669,9 +1583,9 @@ function decodeResumeToken(token) {
1669
1583
  return { stateFile: parsed.data.stateFile, urlHash: parsed.data.urlHash };
1670
1584
  }
1671
1585
  function assertWithinCacheDir(stateFile) {
1672
- const root = (0, import_node_path5.resolve)((0, import_node_path5.join)(userCacheDir(), "webnovel-downloader"));
1673
- const rel = (0, import_node_path5.relative)(root, (0, import_node_path5.resolve)(stateFile));
1674
- if (rel === "" || rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
1586
+ const root = (0, import_node_path3.resolve)((0, import_node_path3.join)(userCacheDir(), "webnovel-downloader"));
1587
+ const rel = (0, import_node_path3.relative)(root, (0, import_node_path3.resolve)(stateFile));
1588
+ if (rel === "" || rel.startsWith("..") || (0, import_node_path3.isAbsolute)(rel)) {
1675
1589
  throw new ParseError("Resume token state path escapes the cache directory");
1676
1590
  }
1677
1591
  }
@@ -1687,25 +1601,6 @@ function verifyResumeToken(token, url) {
1687
1601
  // src/core/download-run.ts
1688
1602
  var QUEUE_BACKPRESSURE_RATIO = 4;
1689
1603
  var STATE_PERSIST_DEBOUNCE_MS = 500;
1690
- var TOC_PROGRESS_EMIT_EVERY2 = 50;
1691
- function resolveResumeConfig(opt, url, logger) {
1692
- if (opt === void 0) return null;
1693
- const make = (stateFile) => ({
1694
- stateFile,
1695
- store: new FilesystemResumeStore(),
1696
- chapterStore: new FilesystemChapterStore(`${stateFile}.chapters`, { logger })
1697
- });
1698
- if (opt === true) {
1699
- const stateFile = (0, import_node_path6.join)(userCacheDir(), "webnovel-downloader", "state", `${sha1(url)}.json`);
1700
- return make(stateFile);
1701
- }
1702
- if ("stateFile" in opt) return make(opt.stateFile);
1703
- if ("token" in opt) {
1704
- const { stateFile } = verifyResumeToken(opt.token, url);
1705
- return make(stateFile);
1706
- }
1707
- return null;
1708
- }
1709
1604
  function freshState(url, adapterId) {
1710
1605
  const now = nowIso();
1711
1606
  return {
@@ -1727,14 +1622,13 @@ var DownloadRun = class {
1727
1622
  #adapter;
1728
1623
  #validated;
1729
1624
  #normalizedRange;
1730
- #resumeCfg;
1625
+ #resumeStorage;
1731
1626
  #concurrency;
1732
1627
  #signal;
1733
1628
  #ctx;
1734
1629
  #bus;
1735
1630
  #logger;
1736
1631
  #state = null;
1737
- #metadata;
1738
1632
  #successes = [];
1739
1633
  #failures = [];
1740
1634
  #completedSet = /* @__PURE__ */ new Set();
@@ -1748,7 +1642,7 @@ var DownloadRun = class {
1748
1642
  this.#adapter = args.adapter;
1749
1643
  this.#validated = args.validated;
1750
1644
  this.#normalizedRange = args.normalizedRange;
1751
- this.#resumeCfg = args.resumeCfg;
1645
+ this.#resumeStorage = args.resumeStorage;
1752
1646
  this.#concurrency = args.concurrency;
1753
1647
  this.#signal = args.signal;
1754
1648
  this.#ctx = args.ctx;
@@ -1764,9 +1658,10 @@ var DownloadRun = class {
1764
1658
  async execute() {
1765
1659
  const initErr = await this.#initResume();
1766
1660
  if (initErr) return initErr;
1767
- const metaErr = await this.#resolveMetadata();
1768
- if (metaErr) return metaErr;
1769
- const ctxWithMeta = this.#startQueue();
1661
+ const resolved = await this.#resolveMetadata();
1662
+ if ("error" in resolved) return resolved.error;
1663
+ const meta = resolved.meta;
1664
+ const ctxWithMeta = this.#startQueue(meta);
1770
1665
  const onAbort = () => {
1771
1666
  this.#aborted = true;
1772
1667
  this.#queue.clear();
@@ -1776,16 +1671,16 @@ var DownloadRun = class {
1776
1671
  const stage2Error = await this.#runTocStage(ctxWithMeta);
1777
1672
  await this.#drainQueueOrAbort();
1778
1673
  await this.#flushQuietly();
1779
- return this.#finalize(stage2Error);
1674
+ return this.#finalize(meta, stage2Error);
1780
1675
  } finally {
1781
1676
  this.#signal.removeEventListener("abort", onAbort);
1782
1677
  }
1783
1678
  }
1784
1679
  async #initResume() {
1785
- const resumeCfg = this.#resumeCfg;
1680
+ const resumeCfg = this.#resumeStorage;
1786
1681
  if (resumeCfg) {
1787
1682
  try {
1788
- let state = await resumeCfg.store.load(resumeCfg.stateFile);
1683
+ let state = await resumeCfg.resume.load(resumeCfg.stateFile);
1789
1684
  if (state) {
1790
1685
  if (state.url !== this.#validated) {
1791
1686
  throw new ParseError("Stale resume state: URL mismatch");
@@ -1802,7 +1697,7 @@ var DownloadRun = class {
1802
1697
  }
1803
1698
  }
1804
1699
  if (resumeCfg && this.#state) {
1805
- const prior = await resumeCfg.chapterStore.loadAll();
1700
+ const prior = await resumeCfg.chapters.loadAll();
1806
1701
  this.#successes.push(...prior);
1807
1702
  const merged = new Set(this.#state.completed);
1808
1703
  for (const c of prior) merged.add(c.index);
@@ -1814,32 +1709,30 @@ var DownloadRun = class {
1814
1709
  return null;
1815
1710
  }
1816
1711
  async #persistState() {
1817
- const resumeCfg = this.#resumeCfg;
1712
+ const resumeCfg = this.#resumeStorage;
1818
1713
  if (!resumeCfg || !this.#state) return;
1819
1714
  const next = { ...this.#state, updatedAt: nowIso() };
1820
1715
  this.#state = next;
1821
- await resumeCfg.store.save(resumeCfg.stateFile, next);
1716
+ await resumeCfg.resume.save(resumeCfg.stateFile, next);
1822
1717
  }
1823
1718
  async #resolveMetadata() {
1824
1719
  try {
1720
+ let meta;
1825
1721
  if (this.#state?.metadata) {
1826
- this.#metadata = this.#state.metadata;
1722
+ meta = this.#state.metadata;
1827
1723
  } else {
1828
- this.#metadata = await fetchMetadataStage(this.#adapter, this.#validated, this.#ctx, this.#bus);
1724
+ meta = await fetchMetadataStage(this.#adapter, this.#validated, this.#ctx, this.#bus);
1829
1725
  if (this.#state) {
1830
- this.#state = { ...this.#state, metadata: this.#metadata, updatedAt: nowIso() };
1726
+ this.#state = { ...this.#state, metadata: meta, updatedAt: nowIso() };
1831
1727
  await this.#persistState();
1832
1728
  }
1833
1729
  }
1730
+ return { meta };
1834
1731
  } catch (err) {
1835
- return {
1836
- status: "error",
1837
- error: normalizeToEnvelopeError(err)
1838
- };
1732
+ return { error: { status: "error", error: normalizeToEnvelopeError(err) } };
1839
1733
  }
1840
- return null;
1841
1734
  }
1842
- #startQueue() {
1735
+ #startQueue(meta) {
1843
1736
  const state = this.#state;
1844
1737
  const completedSet = this.#completedSet;
1845
1738
  const completedList = this.#completedList;
@@ -1851,8 +1744,7 @@ var DownloadRun = class {
1851
1744
  if (!completedSet.has(f.index)) failedMap.set(f.index, f);
1852
1745
  }
1853
1746
  }
1854
- if (!this.#metadata) throw new Error("invariant: metadata must be set before #startQueue");
1855
- return { ...this.#ctx, novelMetadata: this.#metadata };
1747
+ return { ...this.#ctx, novelMetadata: meta };
1856
1748
  }
1857
1749
  async #enqueueRef(ref, ctxWithMeta) {
1858
1750
  if (this.#aborted || this.#signal.aborted) return;
@@ -1872,7 +1764,7 @@ var DownloadRun = class {
1872
1764
  try {
1873
1765
  const chapter = await fetchChapterTask(this.#adapter, ref, ctxWithMeta, this.#bus);
1874
1766
  if (this.#aborted) return;
1875
- if (this.#resumeCfg) await this.#resumeCfg.chapterStore.save(chapter);
1767
+ if (this.#resumeStorage) await this.#resumeStorage.chapters.save(chapter);
1876
1768
  this.#successes.push(chapter);
1877
1769
  this.#completedList.push(ref.index);
1878
1770
  this.#completedSet.add(ref.index);
@@ -1889,7 +1781,7 @@ var DownloadRun = class {
1889
1781
  this.#bus.emit({
1890
1782
  type: "progress",
1891
1783
  completed: this.#successes.length,
1892
- total: this.#metadata?.totalChapters ?? this.#enqueuedSet.size
1784
+ total: ctxWithMeta.novelMetadata?.totalChapters ?? this.#enqueuedSet.size
1893
1785
  });
1894
1786
  } catch (cause) {
1895
1787
  if (cause instanceof CancelledError) return;
@@ -1910,20 +1802,16 @@ var DownloadRun = class {
1910
1802
  let stage2Error;
1911
1803
  try {
1912
1804
  if (this.#state?.toc) {
1913
- const from = this.#normalizedRange?.from;
1914
- const to = this.#normalizedRange?.to;
1915
- let discovered = 0;
1916
- for (const ref of this.#state.toc) {
1917
- if (to !== void 0 && ref.index > to) break;
1918
- if (from !== void 0 && ref.index < from) continue;
1919
- await this.#enqueueRef(ref, ctxWithMeta);
1920
- discovered++;
1921
- if (discovered % TOC_PROGRESS_EMIT_EVERY2 === 0) {
1922
- this.#bus.emit({ type: "toc:progress", discovered });
1923
- }
1805
+ const toc = this.#state.toc;
1806
+ async function* fromState() {
1807
+ for (const ref of toc) yield ref;
1924
1808
  }
1925
- this.#bus.emit({ type: "toc:progress", discovered });
1926
- this.#bus.emit({ type: "toc:complete", total: discovered });
1809
+ await walkToc(fromState(), {
1810
+ ...this.#normalizedRange !== void 0 ? { range: this.#normalizedRange } : {},
1811
+ bus: this.#bus,
1812
+ signal: this.#signal,
1813
+ onRef: (ref) => this.#enqueueRef(ref, ctxWithMeta)
1814
+ });
1927
1815
  } else {
1928
1816
  const tocBuffer = [];
1929
1817
  await fetchTocStage(
@@ -1998,10 +1886,8 @@ var DownloadRun = class {
1998
1886
  this.#logger.warn({ err, url: this.#validated }, "resume state persist failed (non-fatal)");
1999
1887
  }
2000
1888
  }
2001
- #finalize(stage2Error) {
1889
+ #finalize(metadata, stage2Error) {
2002
1890
  if (stage2Error) return { status: "error", error: stage2Error };
2003
- if (!this.#metadata) throw new Error("invariant: metadata must be set before #finalize");
2004
- const metadata = this.#metadata;
2005
1891
  let data;
2006
1892
  try {
2007
1893
  data = assembleNovelData(metadata, this.#successes);
@@ -2012,8 +1898,8 @@ var DownloadRun = class {
2012
1898
  };
2013
1899
  }
2014
1900
  if (this.#failures.length > 0) {
2015
- if (this.#resumeCfg) {
2016
- const token = encodeResumeToken(this.#resumeCfg.stateFile, this.#validated);
1901
+ if (this.#resumeStorage) {
1902
+ const token = encodeResumeToken(this.#resumeStorage.stateFile, this.#validated);
2017
1903
  return { status: "partial", resumable: true, data, failures: this.#failures, resumeToken: token };
2018
1904
  }
2019
1905
  return { status: "partial", resumable: false, data, failures: this.#failures };
@@ -2029,6 +1915,185 @@ function normalizeChapterRange(range) {
2029
1915
  return out;
2030
1916
  }
2031
1917
 
1918
+ // src/storage/resume-storage.ts
1919
+ var import_node_path6 = require("path");
1920
+
1921
+ // src/storage/resume-store.ts
1922
+ var import_promises3 = require("fs/promises");
1923
+ var import_node_path4 = require("path");
1924
+ init_errors();
1925
+
1926
+ // src/storage/resume-state.ts
1927
+ var import_zod5 = require("zod");
1928
+ init_primitives();
1929
+ var ResumeFailureRecordSchema = import_zod5.z.object({
1930
+ index: ChapterIndexSchema,
1931
+ lastError: import_zod5.z.object({ code: import_zod5.z.string(), message: import_zod5.z.string() }).readonly()
1932
+ }).readonly();
1933
+ var ResumeStateSchema = import_zod5.z.object({
1934
+ schemaVersion: import_zod5.z.literal(1),
1935
+ url: UrlSchema,
1936
+ adapterId: AdapterIdSchema,
1937
+ metadata: NovelMetadataSchema.nullable(),
1938
+ toc: import_zod5.z.array(ChapterRefSchema).readonly().nullable(),
1939
+ completed: import_zod5.z.array(ChapterIndexSchema).readonly(),
1940
+ failed: import_zod5.z.array(ResumeFailureRecordSchema).readonly(),
1941
+ startedAt: IsoDateSchema,
1942
+ updatedAt: IsoDateSchema
1943
+ }).readonly();
1944
+
1945
+ // src/storage/resume-store.ts
1946
+ var MemoryResumeStore = class {
1947
+ #map = /* @__PURE__ */ new Map();
1948
+ async load(path) {
1949
+ return this.#map.get((0, import_node_path4.resolve)(path)) ?? null;
1950
+ }
1951
+ async save(path, state) {
1952
+ this.#map.set((0, import_node_path4.resolve)(path), state);
1953
+ }
1954
+ async delete(path) {
1955
+ this.#map.delete((0, import_node_path4.resolve)(path));
1956
+ }
1957
+ };
1958
+ var FilesystemResumeStore = class {
1959
+ async load(path) {
1960
+ let text;
1961
+ try {
1962
+ text = await (0, import_promises3.readFile)(path, "utf8");
1963
+ } catch (err) {
1964
+ if (isEnoent(err)) return null;
1965
+ throw err;
1966
+ }
1967
+ let raw;
1968
+ try {
1969
+ raw = JSON.parse(text);
1970
+ } catch (cause) {
1971
+ throw new ParseError("Resume state file is corrupt; delete it to start over", { cause, path });
1972
+ }
1973
+ const parsed = ResumeStateSchema.safeParse(raw);
1974
+ if (!parsed.success) {
1975
+ throw new ParseError("Resume state file is corrupt; delete it to start over", {
1976
+ cause: parsed.error,
1977
+ path
1978
+ });
1979
+ }
1980
+ return parsed.data;
1981
+ }
1982
+ async save(path, state) {
1983
+ await atomicWriteJson(path, state);
1984
+ }
1985
+ async delete(path) {
1986
+ try {
1987
+ await (0, import_promises3.unlink)(path);
1988
+ } catch (err) {
1989
+ if (!isEnoent(err)) throw err;
1990
+ }
1991
+ }
1992
+ };
1993
+
1994
+ // src/storage/chapter-store.ts
1995
+ var import_promises4 = require("fs/promises");
1996
+ var import_node_path5 = require("path");
1997
+ var MemoryChapterStore = class {
1998
+ #map = /* @__PURE__ */ new Map();
1999
+ async save(chapter) {
2000
+ this.#map.set(Number(chapter.index), chapter);
2001
+ }
2002
+ async loadAll() {
2003
+ return [...this.#map.values()].sort((a, b) => Number(a.index) - Number(b.index));
2004
+ }
2005
+ };
2006
+ var FilesystemChapterStore = class {
2007
+ #logger;
2008
+ #dir;
2009
+ constructor(dir, opts = {}) {
2010
+ this.#dir = (0, import_node_path5.resolve)(dir);
2011
+ this.#logger = opts.logger ?? silentLogger;
2012
+ }
2013
+ async save(chapter) {
2014
+ await atomicWriteJson((0, import_node_path5.join)(this.#dir, `${Number(chapter.index)}.json`), chapter);
2015
+ }
2016
+ async loadAll() {
2017
+ let entries;
2018
+ try {
2019
+ entries = await (0, import_promises4.readdir)(this.#dir);
2020
+ } catch (err) {
2021
+ if (isEnoent(err)) return [];
2022
+ throw err;
2023
+ }
2024
+ const chapters = [];
2025
+ for (const name of entries) {
2026
+ if (!/^\d+\.json$/.test(name)) continue;
2027
+ const path = (0, import_node_path5.join)(this.#dir, name);
2028
+ let text;
2029
+ try {
2030
+ text = await (0, import_promises4.readFile)(path, "utf8");
2031
+ } catch (err) {
2032
+ this.#logger.warn({ path, err }, "chapter-store: failed to read file, skipping");
2033
+ continue;
2034
+ }
2035
+ let raw;
2036
+ try {
2037
+ raw = JSON.parse(text);
2038
+ } catch (err) {
2039
+ this.#logger.warn({ path, err }, "chapter-store: invalid JSON, skipping");
2040
+ continue;
2041
+ }
2042
+ const parsed = ChapterSchema.safeParse(raw);
2043
+ if (!parsed.success) {
2044
+ this.#logger.warn(
2045
+ { path, issues: parsed.error.issues },
2046
+ "chapter-store: schema mismatch, skipping"
2047
+ );
2048
+ continue;
2049
+ }
2050
+ chapters.push(parsed.data);
2051
+ }
2052
+ chapters.sort((a, b) => Number(a.index) - Number(b.index));
2053
+ return chapters;
2054
+ }
2055
+ };
2056
+
2057
+ // src/storage/resume-storage.ts
2058
+ var ResumeStorage = class _ResumeStorage {
2059
+ resume;
2060
+ chapters;
2061
+ stateFile;
2062
+ constructor(resume, chapters, stateFile) {
2063
+ this.resume = resume;
2064
+ this.chapters = chapters;
2065
+ this.stateFile = stateFile;
2066
+ }
2067
+ static forDefault(url, logger) {
2068
+ const stateFile = (0, import_node_path6.join)(userCacheDir(), "webnovel-downloader", "state", `${sha1(url)}.json`);
2069
+ return _ResumeStorage.#filesystem(stateFile, logger);
2070
+ }
2071
+ static forStateFile(stateFile, logger) {
2072
+ return _ResumeStorage.#filesystem(stateFile, logger);
2073
+ }
2074
+ static forToken(token, url, logger) {
2075
+ const { stateFile } = verifyResumeToken(token, url);
2076
+ return _ResumeStorage.#filesystem(stateFile, logger);
2077
+ }
2078
+ static memory(stateFile) {
2079
+ return new _ResumeStorage(new MemoryResumeStore(), new MemoryChapterStore(), stateFile);
2080
+ }
2081
+ static #filesystem(stateFile, logger) {
2082
+ return new _ResumeStorage(
2083
+ new FilesystemResumeStore(),
2084
+ new FilesystemChapterStore(`${stateFile}.chapters`, { logger }),
2085
+ stateFile
2086
+ );
2087
+ }
2088
+ };
2089
+ function resolveResumeStorage(opt, url, logger) {
2090
+ if (opt === void 0) return null;
2091
+ if (opt === true) return ResumeStorage.forDefault(url, logger);
2092
+ if ("stateFile" in opt) return ResumeStorage.forStateFile(opt.stateFile, logger);
2093
+ if ("token" in opt) return ResumeStorage.forToken(opt.token, url, logger);
2094
+ return null;
2095
+ }
2096
+
2032
2097
  // src/core/downloader.ts
2033
2098
  var DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
2034
2099
  var DEFAULT_CONCURRENCY = 4;
@@ -2200,12 +2265,12 @@ var Downloader = class {
2200
2265
  };
2201
2266
  try {
2202
2267
  this.#transportListeners.add(transportListener);
2203
- const resumeCfg = resolveResumeConfig(options?.resume, validated, this.#logger);
2268
+ const resumeStorage = resolveResumeStorage(options?.resume, validated, this.#logger);
2204
2269
  const run = new DownloadRun({
2205
2270
  adapter,
2206
2271
  validated,
2207
2272
  normalizedRange,
2208
- resumeCfg,
2273
+ resumeStorage,
2209
2274
  concurrency,
2210
2275
  signal,
2211
2276
  ctx,
@@ -2307,13 +2372,6 @@ function normalizeText(html) {
2307
2372
  });
2308
2373
  return text.replace(new RegExp(LINE, "g"), " ").replace(new RegExp(PARA, "g"), "\n\n").split(/\n{2,}/).map((p) => p.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n\n").trim();
2309
2374
  }
2310
- function pickText($, selectors) {
2311
- for (const sel of selectors) {
2312
- const t = $(sel).first().text().trim();
2313
- if (t) return t;
2314
- }
2315
- return "";
2316
- }
2317
2375
  function wordCount(text) {
2318
2376
  return text.split(/\s+/).filter(Boolean).length;
2319
2377
  }
@@ -2385,6 +2443,9 @@ function refsFromAnchors($, opts) {
2385
2443
  return refs;
2386
2444
  }
2387
2445
 
2446
+ // src/adapters/shared/header.ts
2447
+ init_errors();
2448
+
2388
2449
  // src/adapters/shared/cover.ts
2389
2450
  function extractCoverUrl($, selector, baseUrl) {
2390
2451
  const el = $(selector).first();
@@ -2392,6 +2453,36 @@ function extractCoverUrl($, selector, baseUrl) {
2392
2453
  return raw ? resolveUrl(baseUrl, raw) : void 0;
2393
2454
  }
2394
2455
 
2456
+ // src/adapters/shared/header.ts
2457
+ function parseNovelHeader($, selectors, sourceUrl) {
2458
+ const title = $(selectors.title).first().text().trim();
2459
+ if (!title) throw new ParseError("Missing title on novel page", { url: sourceUrl });
2460
+ const author = $(selectors.author).first().text().trim();
2461
+ const descSelectors = typeof selectors.description === "string" ? [selectors.description] : selectors.description;
2462
+ let description = "";
2463
+ for (const sel of descSelectors) {
2464
+ const t = $(sel).first().text().trim();
2465
+ if (t) {
2466
+ description = t;
2467
+ break;
2468
+ }
2469
+ }
2470
+ description = description.replace(/\s+/g, " ").trim();
2471
+ const genres = [];
2472
+ $(selectors.genres).each((_, el) => {
2473
+ const t = $(el).text().trim();
2474
+ if (t && !genres.includes(t)) genres.push(t);
2475
+ });
2476
+ const coverUrl = extractCoverUrl($, selectors.cover, sourceUrl);
2477
+ return {
2478
+ title,
2479
+ author,
2480
+ description,
2481
+ genres,
2482
+ ...coverUrl !== void 0 ? { coverUrl } : {}
2483
+ };
2484
+ }
2485
+
2395
2486
  // src/adapters/shared/content.ts
2396
2487
  init_errors();
2397
2488
  function stripLeadingTitle(content, title) {
@@ -2432,31 +2523,16 @@ var TRUYENFULL_SELECTORS = {
2432
2523
 
2433
2524
  // src/adapters/truyenfull/parser.ts
2434
2525
  function parseNovelPage($, sourceUrl) {
2435
- const title = $(TRUYENFULL_SELECTORS.metadata.title).first().text().trim();
2436
- if (!title) {
2437
- throw new ParseError("Missing title on novel page", { url: sourceUrl });
2438
- }
2439
- const author = $(TRUYENFULL_SELECTORS.metadata.author).first().text().trim();
2440
- const description = $(TRUYENFULL_SELECTORS.metadata.description).text().replace(/\s+/g, " ").trim();
2441
- const genres = [];
2442
- $(TRUYENFULL_SELECTORS.metadata.genres).each((_, el) => {
2443
- const t = $(el).text().trim();
2444
- if (t) genres.push(t);
2445
- });
2446
- const coverUrl = extractCoverUrl($, TRUYENFULL_SELECTORS.metadata.cover, sourceUrl);
2526
+ const header = parseNovelHeader($, TRUYENFULL_SELECTORS.metadata, sourceUrl);
2447
2527
  let status = "unknown";
2448
2528
  if ($(TRUYENFULL_SELECTORS.metadata.statusFull).length > 0) status = "completed";
2449
2529
  else if ($(TRUYENFULL_SELECTORS.metadata.statusOngoing).length > 0) status = "ongoing";
2450
2530
  return {
2531
+ ...header,
2451
2532
  sourceUrl,
2452
2533
  sourceSite: unsafeBrandAdapterId("truyenfull"),
2453
- title,
2454
- author,
2455
- description,
2456
- genres,
2457
2534
  status,
2458
- fetchedAt: /* @__PURE__ */ new Date(),
2459
- ...coverUrl !== void 0 ? { coverUrl } : {}
2535
+ fetchedAt: /* @__PURE__ */ new Date()
2460
2536
  };
2461
2537
  }
2462
2538
  function parseTocPage($, startIndex, baseUrl) {
@@ -2622,32 +2698,17 @@ var METRUYENCHU_SELECTORS = {
2622
2698
  // src/adapters/metruyenchu/parser.ts
2623
2699
  var SOURCE_SITE = unsafeBrandAdapterId("metruyenchu-com-vn");
2624
2700
  function parseNovelPage2($, sourceUrl) {
2625
- const title = $(METRUYENCHU_SELECTORS.metadata.title).first().text().trim();
2626
- if (!title) {
2627
- throw new ParseError("Missing title on novel page", { url: sourceUrl });
2628
- }
2629
- const author = $(METRUYENCHU_SELECTORS.metadata.author).first().text().trim();
2630
- const description = pickText($, METRUYENCHU_SELECTORS.metadata.description).replace(/\s+/g, " ").trim();
2631
- const genres = [];
2632
- $(METRUYENCHU_SELECTORS.metadata.genres).each((_, el) => {
2633
- const t = $(el).text().trim();
2634
- if (t && !genres.includes(t)) genres.push(t);
2635
- });
2636
- const coverUrl = extractCoverUrl($, METRUYENCHU_SELECTORS.metadata.cover, sourceUrl);
2701
+ const header = parseNovelHeader($, METRUYENCHU_SELECTORS.metadata, sourceUrl);
2637
2702
  let status = "unknown";
2638
2703
  if ($(METRUYENCHU_SELECTORS.metadata.statusFull).length > 0) status = "completed";
2639
2704
  else if (METRUYENCHU_SELECTORS.metadata.statusOngoing.some((sel) => $(sel).length > 0)) status = "ongoing";
2640
2705
  const totalChapters = extractTotalChapters($);
2641
2706
  return {
2707
+ ...header,
2642
2708
  sourceUrl,
2643
2709
  sourceSite: SOURCE_SITE,
2644
- title,
2645
- author,
2646
- description,
2647
- genres,
2648
2710
  status,
2649
2711
  fetchedAt: /* @__PURE__ */ new Date(),
2650
- ...coverUrl ? { coverUrl } : {},
2651
2712
  ...totalChapters !== void 0 ? { totalChapters } : {}
2652
2713
  };
2653
2714
  }
@@ -3078,6 +3139,7 @@ var downloader = createDownloader();
3078
3139
  builtinAdapters,
3079
3140
  createDownloader,
3080
3141
  createLogger,
3081
- downloader
3142
+ downloader,
3143
+ parseNovelHeader
3082
3144
  });
3083
3145
  //# sourceMappingURL=index.cjs.map