@nitpicker/crawler 0.7.0 → 0.8.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.
@@ -38,18 +38,57 @@ import { retry } from '@d-zero/shared/retry';
38
38
  import { pathComparator } from '@d-zero/shared/sort/path';
39
39
  import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-await-event-emitter';
40
40
  import knex from 'knex';
41
+ import { findScopeEntry } from '../crawler/find-scope-entry.js';
41
42
  import { eachSplitted } from '../utils/array/each-splitted.js';
42
43
  import { ErrorEmitter } from '../utils/error/error-emitter.js';
43
44
  import { dbLog } from './debug.js';
44
45
  import { mkdir } from './filesystem/mkdir.js';
45
46
  import { getJSON } from './get-json.js';
46
47
  import { initSchema } from './init-schema.js';
48
+ import { LibsqlDialect } from './libsql-dialect.js';
47
49
  import { limitedPageIds } from './limited-page-ids.js';
50
+ import { migrateInfoRoots } from './migrate-info-roots.js';
48
51
  import { redirectTable } from './redirect-table.js';
49
52
  const retrySetting = {
50
53
  interval: 300,
51
54
  retries: 3,
52
55
  };
56
+ /**
57
+ * Columns of the `info` table that `setConfig` / `updateConfig` are allowed to
58
+ * write. Any key outside this set is silently dropped so callers can splat a
59
+ * wider runtime config (with extras like `cwd`) without hitting "no such
60
+ * column" at the SQL layer.
61
+ */
62
+ const INFO_COLUMN_ALLOWLIST = new Set([
63
+ 'version',
64
+ 'name',
65
+ 'baseUrl',
66
+ 'roots',
67
+ 'recursive',
68
+ 'interval',
69
+ 'image',
70
+ 'fetchExternal',
71
+ 'parallels',
72
+ 'excludes',
73
+ 'excludeKeywords',
74
+ 'excludeUrls',
75
+ 'maxExcludedDepth',
76
+ 'retry',
77
+ 'fromList',
78
+ 'disableQueries',
79
+ 'userAgent',
80
+ 'ignoreRobots',
81
+ ]);
82
+ /**
83
+ * Subset of {@link INFO_COLUMN_ALLOWLIST} that is stored as a JSON-encoded
84
+ * string and therefore needs `JSON.stringify` on write.
85
+ */
86
+ const INFO_JSON_COLUMNS = new Set([
87
+ 'roots',
88
+ 'excludes',
89
+ 'excludeKeywords',
90
+ 'excludeUrls',
91
+ ]);
53
92
  /**
54
93
  * Low-level database abstraction layer for the archive's SQLite database.
55
94
  *
@@ -81,8 +120,10 @@ let Database = (() => {
81
120
  let _getResourceUrlList_decorators;
82
121
  let _insertResource_decorators;
83
122
  let _insertResourceReferrers_decorators;
123
+ let _repromoteExternalPages_decorators;
84
124
  let _setConfig_decorators;
85
125
  let _setSkippedPage_decorators;
126
+ let _updateConfig_decorators;
86
127
  let _updatePage_decorators;
87
128
  return class Database extends _classSuper {
88
129
  static {
@@ -104,8 +145,10 @@ let Database = (() => {
104
145
  _getResourceUrlList_decorators = [ErrorEmitter(), retry(retrySetting)];
105
146
  _insertResource_decorators = [ErrorEmitter(), retry(retrySetting)];
106
147
  _insertResourceReferrers_decorators = [ErrorEmitter(), retry(retrySetting)];
148
+ _repromoteExternalPages_decorators = [ErrorEmitter(), retry(retrySetting)];
107
149
  _setConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
108
150
  _setSkippedPage_decorators = [ErrorEmitter(), retry(retrySetting)];
151
+ _updateConfig_decorators = [ErrorEmitter(), retry(retrySetting)];
109
152
  _updatePage_decorators = [ErrorEmitter(), retry(retrySetting)];
110
153
  __esDecorate(this, null, _clearHtmlPath_decorators, { kind: "method", name: "clearHtmlPath", static: false, private: false, access: { has: obj => "clearHtmlPath" in obj, get: obj => obj.clearHtmlPath }, metadata: _metadata }, null, _instanceExtraInitializers);
111
154
  __esDecorate(this, null, _getAnchorsOnPage_decorators, { kind: "method", name: "getAnchorsOnPage", static: false, private: false, access: { has: obj => "getAnchorsOnPage" in obj, get: obj => obj.getAnchorsOnPage }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -124,8 +167,10 @@ let Database = (() => {
124
167
  __esDecorate(this, null, _getResourceUrlList_decorators, { kind: "method", name: "getResourceUrlList", static: false, private: false, access: { has: obj => "getResourceUrlList" in obj, get: obj => obj.getResourceUrlList }, metadata: _metadata }, null, _instanceExtraInitializers);
125
168
  __esDecorate(this, null, _insertResource_decorators, { kind: "method", name: "insertResource", static: false, private: false, access: { has: obj => "insertResource" in obj, get: obj => obj.insertResource }, metadata: _metadata }, null, _instanceExtraInitializers);
126
169
  __esDecorate(this, null, _insertResourceReferrers_decorators, { kind: "method", name: "insertResourceReferrers", static: false, private: false, access: { has: obj => "insertResourceReferrers" in obj, get: obj => obj.insertResourceReferrers }, metadata: _metadata }, null, _instanceExtraInitializers);
170
+ __esDecorate(this, null, _repromoteExternalPages_decorators, { kind: "method", name: "repromoteExternalPages", static: false, private: false, access: { has: obj => "repromoteExternalPages" in obj, get: obj => obj.repromoteExternalPages }, metadata: _metadata }, null, _instanceExtraInitializers);
127
171
  __esDecorate(this, null, _setConfig_decorators, { kind: "method", name: "setConfig", static: false, private: false, access: { has: obj => "setConfig" in obj, get: obj => obj.setConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
128
172
  __esDecorate(this, null, _setSkippedPage_decorators, { kind: "method", name: "setSkippedPage", static: false, private: false, access: { has: obj => "setSkippedPage" in obj, get: obj => obj.setSkippedPage }, metadata: _metadata }, null, _instanceExtraInitializers);
173
+ __esDecorate(this, null, _updateConfig_decorators, { kind: "method", name: "updateConfig", static: false, private: false, access: { has: obj => "updateConfig" in obj, get: obj => obj.updateConfig }, metadata: _metadata }, null, _instanceExtraInitializers);
129
174
  __esDecorate(this, null, _updatePage_decorators, { kind: "method", name: "updatePage", static: false, private: false, access: { has: obj => "updatePage" in obj, get: obj => obj.updatePage }, metadata: _metadata }, null, _instanceExtraInitializers);
130
175
  if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
131
176
  }
@@ -137,24 +182,16 @@ let Database = (() => {
137
182
  constructor(options) {
138
183
  super();
139
184
  this.#workingDir = options.workingDir;
140
- switch (options.type) {
141
- case 'sqlite3': {
142
- this.#instance = knex({
143
- client: options.type,
144
- connection: {
145
- filename: options.filename,
146
- },
147
- useNullAsDefault: true,
148
- pool: {
149
- acquireTimeoutMillis: 600_000,
150
- },
151
- });
152
- break;
153
- }
154
- case 'mysql': {
155
- throw new Error("Don't support MySQL yet.");
156
- }
157
- }
185
+ this.#instance = knex({
186
+ client: LibsqlDialect,
187
+ connection: {
188
+ filename: options.filename,
189
+ },
190
+ useNullAsDefault: true,
191
+ pool: {
192
+ acquireTimeoutMillis: 600_000,
193
+ },
194
+ });
158
195
  }
159
196
  /**
160
197
  * Adds the `order` column to the `pages` table for URL sort ordering.
@@ -222,7 +259,7 @@ let Database = (() => {
222
259
  }
223
260
  /**
224
261
  * Retrieves the full crawl configuration from the `info` table.
225
- * Deserializes JSON-encoded fields (`excludes`, `excludeKeywords`, `scope`).
262
+ * Deserializes JSON-encoded fields (`roots`, `excludes`, `excludeKeywords`, `excludeUrls`).
226
263
  * @returns The parsed {@link Config} object.
227
264
  * @throws {Error} If no configuration is found in the database.
228
265
  */
@@ -236,10 +273,10 @@ let Database = (() => {
236
273
  excludes: getJSON(config.excludes, []),
237
274
  excludeKeywords: getJSON(config.excludeKeywords, []),
238
275
  excludeUrls: getJSON(config.excludeUrls, []),
239
- scope: getJSON(config.scope, []),
276
+ roots: getJSON(config.roots, []),
240
277
  retry: config.retry ?? 3,
241
278
  };
242
- // @ts-expect-error
279
+ // @ts-expect-error — `id` is the primary key, not part of the public Config shape
243
280
  delete opt.id;
244
281
  dbLog('Table `info`: %O => %O', config, opt);
245
282
  return opt;
@@ -574,23 +611,88 @@ let Database = (() => {
574
611
  .onConflict(['resourceId', 'pageId'])
575
612
  .ignore();
576
613
  }
614
+ /**
615
+ * Promote previously-external pages whose URL falls under any of the new scope
616
+ * entries back to a "needs scraping" state so that the next crawl picks them up
617
+ * as full internal pages.
618
+ *
619
+ * For each matching page:
620
+ * - clears the scrape metadata (status, headers, snapshot path, etc.),
621
+ * - flips `isExternal` to `0` and `scraped` to `0`,
622
+ * - removes stale `anchors`, `images`, and `resources-referrers` rows so that
623
+ * the re-scrape can re-insert fresh ones without duplicates.
624
+ *
625
+ * The page row itself is kept (id is preserved) so existing referrers via
626
+ * `anchors.hrefId` remain valid. SELECT and UPDATE/DELETE statements are
627
+ * chunked to stay below SQLite's `SQLITE_LIMIT_VARIABLE_NUMBER`.
628
+ * @param scopes - The hostname-indexed scope map after the new roots are merged.
629
+ * @param options - URL parsing options forwarded to {@link findScopeEntry}.
630
+ * @returns The URLs of the pages that were promoted.
631
+ */
632
+ async repromoteExternalPages(scopes, options) {
633
+ if (scopes.size === 0) {
634
+ return [];
635
+ }
636
+ const candidates = await this.#instance
637
+ .select('id', 'url')
638
+ .from('pages')
639
+ .where('isExternal', 1);
640
+ const promotedIds = [];
641
+ const promotedUrls = [];
642
+ for (const row of candidates) {
643
+ const parsed = parseUrl(row.url, options);
644
+ if (!parsed) {
645
+ continue;
646
+ }
647
+ if (findScopeEntry(parsed, scopes, options) === null) {
648
+ continue;
649
+ }
650
+ promotedIds.push(row.id);
651
+ promotedUrls.push(row.url);
652
+ }
653
+ if (promotedIds.length === 0) {
654
+ return [];
655
+ }
656
+ const chunkSize = 500;
657
+ for (let i = 0; i < promotedIds.length; i += chunkSize) {
658
+ const chunk = promotedIds.slice(i, i + chunkSize);
659
+ await this.#instance('pages').whereIn('id', chunk).update({
660
+ scraped: 0,
661
+ isExternal: 0,
662
+ isSkipped: 0,
663
+ skipReason: null,
664
+ html: null,
665
+ status: null,
666
+ statusText: null,
667
+ contentType: null,
668
+ contentLength: null,
669
+ responseHeaders: '{}',
670
+ redirectDestId: null,
671
+ });
672
+ await this.#instance('anchors').whereIn('pageId', chunk).delete();
673
+ await this.#instance('images').whereIn('pageId', chunk).delete();
674
+ await this.#instance('resources-referrers').whereIn('pageId', chunk).delete();
675
+ }
676
+ dbLog('Repromoted %d external pages back to pending', promotedUrls.length);
677
+ return promotedUrls;
678
+ }
577
679
  /**
578
680
  * Stores the crawl configuration in the `info` table.
579
- * Serializes array fields (`excludes`, `excludeKeywords`, `scope`) as JSON strings.
681
+ * Only fields in {@link INFO_COLUMN_ALLOWLIST} are forwarded — any extra
682
+ * runtime-only field on the input is silently dropped so callers can splat
683
+ * a wider config object without producing SQL errors. JSON-array fields
684
+ * are serialized via `JSON.stringify`.
580
685
  * @param config - The {@link Config} object to store.
581
686
  */
582
687
  async setConfig(config) {
583
- return this.#instance.from('info').insert({
584
- ...config,
585
- // @ts-expect-error
586
- excludes: JSON.stringify(config.excludes),
587
- // @ts-expect-error
588
- excludeKeywords: JSON.stringify(config.excludeKeywords),
589
- // @ts-expect-error
590
- excludeUrls: JSON.stringify(config.excludeUrls),
591
- // @ts-expect-error
592
- scope: JSON.stringify(config.scope),
593
- });
688
+ const payload = {};
689
+ for (const [key, value] of Object.entries(config)) {
690
+ if (!INFO_COLUMN_ALLOWLIST.has(key)) {
691
+ continue;
692
+ }
693
+ payload[key] = INFO_JSON_COLUMNS.has(key) ? JSON.stringify(value) : value;
694
+ }
695
+ return this.#instance.from('info').insert(payload);
594
696
  }
595
697
  /**
596
698
  * Marks a page as skipped in the database with the given reason.
@@ -637,6 +739,39 @@ let Database = (() => {
637
739
  await this.#instance.raw(`UPDATE pages SET \`order\` = CASE id ${cases} END WHERE id IN (${placeholders})`, [...bindings, ...ids]);
638
740
  }
639
741
  }
742
+ /**
743
+ * Update the single row in the `info` table with a partial config patch.
744
+ *
745
+ * Used by the append flow to extend `roots` (and any other tweakable
746
+ * field) without replacing the entire row. JSON-array fields are serialized on
747
+ * the fly; primitive fields are written verbatim. Unspecified fields stay as-is.
748
+ *
749
+ * Unknown keys (anything outside the allow-list of `info`-table columns) are
750
+ * silently dropped instead of being passed to SQL, so callers that splat a
751
+ * wider runtime config (e.g. `CrawlConfig` with `cwd` / `executablePath`)
752
+ * cannot accidentally trigger a "no such column" SQL error.
753
+ * @param patch - Partial {@link Config} fields to overwrite. `undefined` values are skipped.
754
+ */
755
+ async updateConfig(patch) {
756
+ const payload = {};
757
+ for (const [key, value] of Object.entries(patch)) {
758
+ if (value === undefined) {
759
+ continue;
760
+ }
761
+ if (!INFO_COLUMN_ALLOWLIST.has(key)) {
762
+ continue;
763
+ }
764
+ if (INFO_JSON_COLUMNS.has(key)) {
765
+ payload[key] = JSON.stringify(value);
766
+ continue;
767
+ }
768
+ payload[key] = value;
769
+ }
770
+ if (Object.keys(payload).length === 0) {
771
+ return;
772
+ }
773
+ await this.#instance.from('info').update(payload);
774
+ }
640
775
  /**
641
776
  * Inserts or updates a crawled page in the database, including its redirect chain,
642
777
  * anchors, and images. Optionally creates an HTML snapshot file path entry.
@@ -750,11 +885,15 @@ let Database = (() => {
750
885
  return insertedId;
751
886
  }
752
887
  /**
753
- * Initializes the database schema if tables do not exist.
754
- * Delegates to {@link initSchema} for the actual table creation.
888
+ * Initializes the database schema if tables do not exist, then runs lightweight
889
+ * migrations that bring older archives up to the current schema.
890
+ *
891
+ * Migrations are idempotent and run on every {@link Database.connect}, so the
892
+ * same DB can be opened safely from both writer and reader code paths.
755
893
  */
756
894
  async #init() {
757
895
  await initSchema(this.#instance);
896
+ await migrateInfoRoots(this.#instance);
758
897
  }
759
898
  /**
760
899
  * Upserts page data into the `pages` table (inserts if new, updates if existing).
@@ -818,16 +957,11 @@ let Database = (() => {
818
957
  * Creates and initializes a new Database instance.
819
958
  * Creates the parent directory for the database file if needed,
820
959
  * establishes the connection, and initializes tables if they do not exist.
821
- * @param options - The database connection options specifying the type and file path.
960
+ * @param options - Database connection options (working directory + SQLite file path).
822
961
  * @returns A fully initialized Database instance.
823
962
  */
824
963
  static async connect(options) {
825
- switch (options.type) {
826
- case 'sqlite3': {
827
- mkdir(options.filename);
828
- break;
829
- }
830
- }
964
+ mkdir(options.filename);
831
965
  const db = new Database(options);
832
966
  await db.#init();
833
967
  return db;
@@ -18,12 +18,12 @@ export async function initSchema(instance) {
18
18
  t.string('version');
19
19
  t.string('name');
20
20
  t.string('baseUrl');
21
+ t.json('roots');
21
22
  t.boolean('recursive');
22
23
  t.integer('interval');
23
24
  t.boolean('image');
24
25
  t.boolean('fetchExternal');
25
26
  t.integer('parallels');
26
- t.json('scope');
27
27
  t.json('excludes');
28
28
  t.json('excludeKeywords');
29
29
  t.json('excludeUrls');
@@ -0,0 +1,25 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Untyped reference to Knex's internal `better-sqlite3` dialect.
4
+ *
5
+ * Knex's published `exports` do not include this subpath, so TypeScript cannot
6
+ * resolve a declaration file for it. The dialect is a concrete subclass of
7
+ * `Knex.Client` at runtime, so the safe cast is intentional.
8
+ */
9
+ declare const Base: typeof Knex.Client;
10
+ /**
11
+ * Knex dialect that reuses the `better-sqlite3` adapter logic but swaps the
12
+ * underlying driver for `libsql`. `libsql` ships pre-compiled binaries via the
13
+ * `@libsql/<platform>-<arch>` optionalDependencies pattern, so no postinstall
14
+ * download from GitHub Releases is required.
15
+ */
16
+ export declare class LibsqlDialect extends Base {
17
+ /**
18
+ * Returns the `libsql` constructor as the SQLite driver.
19
+ *
20
+ * Typed as `unknown` to avoid leaking the `Libsql.DatabaseConstructor`
21
+ * namespace from a non-re-exported module across the public API surface.
22
+ */
23
+ protected _driver(): unknown;
24
+ }
25
+ export {};
@@ -0,0 +1,28 @@
1
+ // @ts-expect-error - Internal Knex subpath without published type declarations
2
+ import Client_BetterSQLite3 from 'knex/lib/dialects/better-sqlite3/index.js';
3
+ import libsql from 'libsql';
4
+ /**
5
+ * Untyped reference to Knex's internal `better-sqlite3` dialect.
6
+ *
7
+ * Knex's published `exports` do not include this subpath, so TypeScript cannot
8
+ * resolve a declaration file for it. The dialect is a concrete subclass of
9
+ * `Knex.Client` at runtime, so the safe cast is intentional.
10
+ */
11
+ const Base = Client_BetterSQLite3;
12
+ /**
13
+ * Knex dialect that reuses the `better-sqlite3` adapter logic but swaps the
14
+ * underlying driver for `libsql`. `libsql` ships pre-compiled binaries via the
15
+ * `@libsql/<platform>-<arch>` optionalDependencies pattern, so no postinstall
16
+ * download from GitHub Releases is required.
17
+ */
18
+ export class LibsqlDialect extends Base {
19
+ /**
20
+ * Returns the `libsql` constructor as the SQLite driver.
21
+ *
22
+ * Typed as `unknown` to avoid leaking the `Libsql.DatabaseConstructor`
23
+ * namespace from a non-re-exported module across the public API surface.
24
+ */
25
+ _driver() {
26
+ return libsql;
27
+ }
28
+ }
@@ -0,0 +1,15 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Bring an archive's `info` table to the current shape: add `roots` (seeded
4
+ * from `baseUrl`) and drop the obsolete `scope` column.
5
+ *
6
+ * Idempotent: calling this multiple times on an up-to-date schema is a no-op.
7
+ * Archives where `baseUrl` is NULL receive an empty `roots` array rather than
8
+ * throwing.
9
+ *
10
+ * When the migration actually runs (i.e. either column transition was
11
+ * performed), a single notice is written to stderr so the user knows the
12
+ * file was upgraded.
13
+ * @param instance - The Knex query builder instance connected to the database.
14
+ */
15
+ export declare function migrateInfoRoots(instance: Knex): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Bring an archive's `info` table to the current shape: add `roots` (seeded
3
+ * from `baseUrl`) and drop the obsolete `scope` column.
4
+ *
5
+ * Idempotent: calling this multiple times on an up-to-date schema is a no-op.
6
+ * Archives where `baseUrl` is NULL receive an empty `roots` array rather than
7
+ * throwing.
8
+ *
9
+ * When the migration actually runs (i.e. either column transition was
10
+ * performed), a single notice is written to stderr so the user knows the
11
+ * file was upgraded.
12
+ * @param instance - The Knex query builder instance connected to the database.
13
+ */
14
+ export async function migrateInfoRoots(instance) {
15
+ const hasInfo = await instance.schema.hasTable('info');
16
+ if (!hasInfo) {
17
+ return;
18
+ }
19
+ const hasRoots = await instance.schema.hasColumn('info', 'roots');
20
+ const hasScope = await instance.schema.hasColumn('info', 'scope');
21
+ if (hasRoots && !hasScope) {
22
+ return;
23
+ }
24
+ const changes = [];
25
+ if (!hasRoots) {
26
+ await instance.schema.table('info', (t) => {
27
+ t.json('roots');
28
+ });
29
+ await instance.raw(`UPDATE info SET roots = json_array(baseUrl) WHERE roots IS NULL AND baseUrl IS NOT NULL`);
30
+ await instance.raw(`UPDATE info SET roots = '[]' WHERE roots IS NULL`);
31
+ changes.push('roots seeded');
32
+ }
33
+ if (hasScope) {
34
+ await instance.schema.table('info', (t) => {
35
+ t.dropColumn('scope');
36
+ });
37
+ changes.push('scope dropped');
38
+ }
39
+ // eslint-disable-next-line no-console
40
+ console.error(`[migrate] info table upgraded (${changes.join(', ')})`);
41
+ }
@@ -11,12 +11,12 @@ export interface DatabaseEvent {
11
11
  * Represents all crawling options that were used for the crawl session.
12
12
  */
13
13
  export interface Config extends Required<Pick<ParseURLOptions, 'disableQueries'>> {
14
- /** The starting URL for the crawl. */
14
+ /** The starting URL for the crawl. Stored as a denormalised mirror of `roots[0]` so summary consumers can read a single URL without parsing the array. */
15
15
  baseUrl: string;
16
+ /** The user-provided root URLs that seeded the crawl. Each root is both a recursive starting point and a scope entry. Always non-empty. */
17
+ roots: string[];
16
18
  /** Maximum directory depth for excluded paths. */
17
19
  maxExcludedDepth: number;
18
- /** URL patterns defining the crawl scope. */
19
- scope: string[];
20
20
  /** Keywords used to exclude pages from crawling. */
21
21
  excludeKeywords: string[];
22
22
  /** URL patterns to exclude from crawling. */
@@ -285,31 +285,11 @@ export interface DB_Resource {
285
285
  responseHeaders: string | null;
286
286
  }
287
287
  /**
288
- * Base options shared by all database connection configurations.
288
+ * Connection options for the archive's libsql-backed database.
289
289
  */
290
- type AbsDatabaseOption = {
290
+ export interface DatabaseOption {
291
291
  /** The working directory for the database (used for resolving relative paths). */
292
292
  workingDir: string;
293
- };
294
- /**
295
- * Union type for all supported database connection options.
296
- */
297
- export type DatabaseOption = DatabaseSqlite3Option | DatabaseMySqlOption;
298
- /**
299
- * Connection options for a SQLite3 database.
300
- */
301
- type DatabaseSqlite3Option = AbsDatabaseOption & {
302
- /** The database type identifier. */
303
- type: 'sqlite3';
304
293
  /** The absolute file path to the SQLite database file. */
305
294
  filename: string;
306
- };
307
- /**
308
- * Connection options for a MySQL database.
309
- * Note: MySQL support is not yet implemented.
310
- */
311
- type DatabaseMySqlOption = AbsDatabaseOption & {
312
- /** The database type identifier. */
313
- type: 'mysql';
314
- };
315
- export {};
295
+ }
@@ -56,23 +56,27 @@ export default class Crawler extends EventEmitter<CrawlerEventTypes> {
56
56
  */
57
57
  resume(pending: string[], scraped: string[], resources: string[]): void;
58
58
  /**
59
- * Start crawling from a single root URL.
59
+ * Start crawling from one or more root URLs.
60
60
  *
61
- * Adds the root URL to the scope (if not already present) and the link list,
62
- * then begins the deal-based concurrent crawl. Discovered child pages are
63
- * automatically added to the queue when recursive mode is enabled.
64
- * @param url - The root URL to begin crawling from.
65
- */
66
- start(url: ExURL): void;
67
- /**
68
- * Start crawling a pre-defined list of URLs in non-recursive mode.
61
+ * Each URL is registered as a scope entry (if not already present) and added
62
+ * to the link list. When `opts.recursive` is `false`, recursion is disabled
63
+ * and the crawler behaves like the former `startMultiple` (list mode);
64
+ * otherwise discovered child pages within the scope are followed.
69
65
  *
70
- * Each URL in the list is added to the scope and the link list. Recursive
71
- * crawling is disabled; only the provided URLs will be scraped.
72
- * @param pageList - The list of URLs to crawl. Must contain at least one URL.
73
- * @throws {Error} If the page list is empty.
66
+ * When resume state is present, the resumed pending URLs are merged with the
67
+ * newly-provided roots. The merge is deduplicated by protocol-agnostic key
68
+ * before reaching the dealer so a URL that exists in both sources which
69
+ * is common in append-mode when a new root coincides with a repromoted
70
+ * previously-external page — does not race on two parallel slots.
71
+ * @param urls - The list of root URLs to begin crawling from. Must be non-empty.
72
+ * @param opts - Optional overrides; currently only `recursive` is honoured.
73
+ * @param opts.recursive - When `false`, disables recursive discovery and forces list-mode.
74
+ * Defaults to the constructor option's `recursive` value.
75
+ * @throws {Error} If the URL list is empty.
74
76
  */
75
- startMultiple(pageList: ExURL[]): void;
77
+ start(urls: ExURL[], opts?: {
78
+ recursive?: boolean;
79
+ }): void;
76
80
  /**
77
81
  * The default maximum number of concurrent scraping processes.
78
82
  *