@nitpicker/crawler 0.17.0 → 0.18.1

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.
@@ -15,6 +15,26 @@ import type { Knex } from 'knex';
15
15
  * anything reachable via the crawled chain must be labelled `'crawled'`
16
16
  * even if previously labelled `'inventory-*'`.
17
17
  *
18
+ * `is_external`, by contrast, IS overwritten on every call — but demotion is
19
+ * guarded. `updatePage` keys the row by the redirect DESTINATION url while
20
+ * passing the REQUESTING url's `isExternal`, so the value written here
21
+ * describes the requester, not necessarily this row. Inheriting it is
22
+ * deliberate when PROMOTING (an out-of-scope soft-404 page reached from an
23
+ * in-scope request counts as covered by the crawl, and the viewer relies on
24
+ * that — see `@nitpicker/query`'s `build-directory-tree-rows.ts`). It would be
25
+ * wrong when DEMOTING: an out-of-scope url redirecting to an in-scope page
26
+ * that was already taken on as a target must not flip that page to
27
+ * `is_external = 1` — no reading of the column justifies erasing a real
28
+ * observation with an inherited one. `crawler.ts`'s `#scrapedDestinations`
29
+ * blocks this within one run, but that is per-`#runDeal` memory, so a later
30
+ * `--append` / `--retry-failed` process starts blind to what the DB already
31
+ * knows — hence the CASE below, which checks the ROW's own prior state
32
+ * instead: once `scraped = 1 AND is_external = 0` is true, no later call can
33
+ * flip it back to `1`. Promotion (`0 → 1` before the row has been scraped, or
34
+ * `1 → 0` at any time) is untouched. This mirrors how `first_crawled_at`'s `COALESCE` below
35
+ * protects an established value — deliberately NOT by re-deriving scope from
36
+ * the destination url, which would also kill the wanted promoting case.
37
+ *
18
38
  * The page's response headers are decomposed and written into the
19
39
  * header dictionary tables here — per response, not deferred to
20
40
  * crawl-end — and the resulting `header_set_id` lands on the same
@@ -24,6 +24,26 @@ import { upsertUrlRef } from '../../_shared/upsert-url-ref.js';
24
24
  * anything reachable via the crawled chain must be labelled `'crawled'`
25
25
  * even if previously labelled `'inventory-*'`.
26
26
  *
27
+ * `is_external`, by contrast, IS overwritten on every call — but demotion is
28
+ * guarded. `updatePage` keys the row by the redirect DESTINATION url while
29
+ * passing the REQUESTING url's `isExternal`, so the value written here
30
+ * describes the requester, not necessarily this row. Inheriting it is
31
+ * deliberate when PROMOTING (an out-of-scope soft-404 page reached from an
32
+ * in-scope request counts as covered by the crawl, and the viewer relies on
33
+ * that — see `@nitpicker/query`'s `build-directory-tree-rows.ts`). It would be
34
+ * wrong when DEMOTING: an out-of-scope url redirecting to an in-scope page
35
+ * that was already taken on as a target must not flip that page to
36
+ * `is_external = 1` — no reading of the column justifies erasing a real
37
+ * observation with an inherited one. `crawler.ts`'s `#scrapedDestinations`
38
+ * blocks this within one run, but that is per-`#runDeal` memory, so a later
39
+ * `--append` / `--retry-failed` process starts blind to what the DB already
40
+ * knows — hence the CASE below, which checks the ROW's own prior state
41
+ * instead: once `scraped = 1 AND is_external = 0` is true, no later call can
42
+ * flip it back to `1`. Promotion (`0 → 1` before the row has been scraped, or
43
+ * `1 → 0` at any time) is untouched. This mirrors how `first_crawled_at`'s `COALESCE` below
44
+ * protects an established value — deliberately NOT by re-deriving scope from
45
+ * the destination url, which would also kill the wanted promoting case.
46
+ *
27
47
  * The page's response headers are decomposed and written into the
28
48
  * header dictionary tables here — per response, not deferred to
29
49
  * crawl-end — and the resulting `header_set_id` lands on the same
@@ -86,8 +106,23 @@ export async function insertPage(knex, caches, page, isTarget, trx, source) {
86
106
  .where('id', pageId)
87
107
  .update({
88
108
  scraped: 1,
89
- is_target: isTarget ? 1 : 0,
90
- is_external: page.isExternal ? 1 : 0,
109
+ // Once a row has been scraped as a real crawl target, no later call
110
+ // may flip it back off — same inheritance-from-the-requester bug as
111
+ // `is_external` below (`setExternalPage` always passes `isTarget:
112
+ // false`), and the same fix: guard on the row's own prior state
113
+ // instead of per-run memory. Demoting is_target away from an
114
+ // established value would under-count `getScrapedHtmlPageCount`'s
115
+ // resume offset and silently break the "isTarget=1 means covered by
116
+ // the crawl" contract `accessor.getPages('page')` documents.
117
+ is_target: qb.raw('CASE WHEN scraped = 1 AND is_target = 1 THEN 1 ELSE ? END', [
118
+ isTarget ? 1 : 0,
119
+ ]),
120
+ // Once a row has been scraped as internal, no later call may flip it
121
+ // back to external — see this function's docs for why the write this
122
+ // guards against happens at all.
123
+ is_external: qb.raw('CASE WHEN scraped = 1 AND is_external = 0 THEN 0 ELSE ? END', [
124
+ page.isExternal ? 1 : 0,
125
+ ]),
91
126
  status: page.status,
92
127
  status_text: page.statusText,
93
128
  content_type_id: contentTypeId,
@@ -20,7 +20,13 @@ import type { Knex } from 'knex';
20
20
  * @param destId - `content_items.id` of the redirect destination page.
21
21
  * @param destUrlNormalized - Normalised destination URL, used to detect and
22
22
  * skip self-redirects.
23
- * @param isExternal - Whether the sources are external to the crawl scope.
23
+ * @param isExternal - Whether the sources are external to the crawl scope, as
24
+ * a single value applied to every hop in `sources` — the same
25
+ * requester-describes-a-different-row shape `insertPage` guards against
26
+ * (see its docs in `insert-page.ts`), since a hop can independently be a
27
+ * real, already-scraped internal page in its own right (reached earlier as
28
+ * a direct crawl target) before it is later observed as an intermediate hop
29
+ * in some other chain. The UPDATE below carries the same CASE guard.
24
30
  * @param chainLineageSource - Lineage label propagated to each intermediate
25
31
  * hop's row (passed through to {@link resolveContentItemId}). Derived by
26
32
  * the caller from the **originating** page's source (`page.url`), not from
@@ -19,7 +19,13 @@ import { resolveContentItemId } from '../../_shared/resolve-content-item-id.js';
19
19
  * @param destId - `content_items.id` of the redirect destination page.
20
20
  * @param destUrlNormalized - Normalised destination URL, used to detect and
21
21
  * skip self-redirects.
22
- * @param isExternal - Whether the sources are external to the crawl scope.
22
+ * @param isExternal - Whether the sources are external to the crawl scope, as
23
+ * a single value applied to every hop in `sources` — the same
24
+ * requester-describes-a-different-row shape `insertPage` guards against
25
+ * (see its docs in `insert-page.ts`), since a hop can independently be a
26
+ * real, already-scraped internal page in its own right (reached earlier as
27
+ * a direct crawl target) before it is later observed as an intermediate hop
28
+ * in some other chain. The UPDATE below carries the same CASE guard.
23
29
  * @param chainLineageSource - Lineage label propagated to each intermediate
24
30
  * hop's row (passed through to {@link resolveContentItemId}). Derived by
25
31
  * the caller from the **originating** page's source (`page.url`), not from
@@ -53,7 +59,7 @@ export async function linkRedirectSources(trx, caches, sources, destId, destUrlN
53
59
  .update({
54
60
  scraped: 1,
55
61
  redirect_dest_id: destId,
56
- is_external: isExternal ? 1 : 0,
62
+ is_external: trx.raw('CASE WHEN scraped = 1 AND is_external = 0 THEN 0 ELSE ? END', [isExternal ? 1 : 0]),
57
63
  });
58
64
  // Conditional `301 Moved Permanently` stamp — applied ONLY when the
59
65
  // row carries no definitive status yet (NULL or the `-1`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitpicker/crawler",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "Web crawler engine with headless browser rendering and archive storage",
5
5
  "author": "D-ZERO",
6
6
  "license": "Apache-2.0",
@@ -27,10 +27,10 @@
27
27
  "clean": "tsc --build --clean"
28
28
  },
29
29
  "dependencies": {
30
- "@d-zero/beholder": "4.1.0",
31
- "@d-zero/dealer": "1.9.4",
30
+ "@d-zero/beholder": "4.2.2",
31
+ "@d-zero/dealer": "1.10.4",
32
32
  "@d-zero/fs": "0.2.6",
33
- "@d-zero/shared": "0.22.2",
33
+ "@d-zero/shared": "0.22.5",
34
34
  "ansi-colors": "4.1.3",
35
35
  "debug": "4.4.3",
36
36
  "follow-redirects": "1.16.0",
@@ -39,7 +39,7 @@
39
39
  "libsql": "0.5.29",
40
40
  "puppeteer": "25.3.0",
41
41
  "robots-parser": "3.0.1",
42
- "tar": "7.5.21"
42
+ "tar": "7.5.22"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/debug": "4.1.13",
@@ -48,5 +48,5 @@
48
48
  "@types/tar": "7.0.87",
49
49
  "@types/unzipper": "0.10.11"
50
50
  },
51
- "gitHead": "2cdef7cbb4da489270e0f1c9ec2e8166b3d84e91"
51
+ "gitHead": "cf366bfa2ca1e0528623b3fbe1c17ac4237ca4e4"
52
52
  }