@nitpicker/crawler 0.7.0 → 0.9.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/lib/archive/archive-lock.d.ts +38 -0
- package/lib/archive/archive-lock.js +147 -0
- package/lib/archive/archive.d.ts +17 -2
- package/lib/archive/archive.js +86 -31
- package/lib/archive/database.d.ts +41 -4
- package/lib/archive/database.js +176 -42
- package/lib/archive/init-schema.js +1 -1
- package/lib/archive/libsql-dialect.d.ts +25 -0
- package/lib/archive/libsql-dialect.js +28 -0
- package/lib/archive/migrate-info-roots.d.ts +15 -0
- package/lib/archive/migrate-info-roots.js +41 -0
- package/lib/archive/types.d.ts +6 -26
- package/lib/crawler/crawler.d.ts +18 -14
- package/lib/crawler/crawler.js +62 -54
- package/lib/crawler/find-scope-entry.d.ts +25 -0
- package/lib/crawler/find-scope-entry.js +45 -0
- package/lib/crawler/handle-scrape-end.js +15 -14
- package/lib/crawler/inject-scope-auth.d.ts +10 -7
- package/lib/crawler/inject-scope-auth.js +13 -14
- package/lib/crawler/is-external-url.d.ts +13 -6
- package/lib/crawler/is-external-url.js +14 -6
- package/lib/crawler/types.d.ts +2 -2
- package/lib/crawler-orchestrator.d.ts +21 -0
- package/lib/crawler-orchestrator.js +131 -10
- package/package.json +3 -3
- package/lib/crawler/find-best-matching-scope.d.ts +0 -13
- package/lib/crawler/find-best-matching-scope.js +0 -52
- package/lib/crawler/is-in-any-lower-layer.d.ts +0 -13
- package/lib/crawler/is-in-any-lower-layer.js +0 -15
package/lib/archive/database.js
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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`, `
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
*
|
|
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 -
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/archive/types.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
288
|
+
* Connection options for the archive's libsql-backed database.
|
|
289
289
|
*/
|
|
290
|
-
|
|
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
|
+
}
|
package/lib/crawler/crawler.d.ts
CHANGED
|
@@ -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
|
|
59
|
+
* Start crawling from one or more root URLs.
|
|
60
60
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
77
|
+
start(urls: ExURL[], opts?: {
|
|
78
|
+
recursive?: boolean;
|
|
79
|
+
}): void;
|
|
76
80
|
/**
|
|
77
81
|
* The default maximum number of concurrent scraping processes.
|
|
78
82
|
*
|