@d-zero/page-cluster 0.5.7 → 0.6.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.
Files changed (2) hide show
  1. package/dist/cli.js +78 -69
  2. package/package.json +8 -4
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // animated header on a TTY, appended `[page-cluster] …` lines otherwise.
7
7
  import { writeFile } from 'node:fs/promises';
8
8
  import process from 'node:process';
9
+ import { unwrapSuppressedError } from '@d-zero/cli-core';
9
10
  import { Lanes } from '@d-zero/dealer';
10
11
  import { resolvePageClusterKeys } from './resolve-page-cluster-keys.js';
11
12
  const HELP_TEXT = `Usage:
@@ -298,6 +299,20 @@ function errorLine(message) {
298
299
  verbose: `error: ${message}`,
299
300
  };
300
301
  }
302
+ /**
303
+ * Formats a caught error for `errorLine()`. `SuppressedError` (thrown when a
304
+ * `using`-scoped body error and a disposal error occur together) hides the
305
+ * real cause behind a generic message, so its underlying causes are
306
+ * unwrapped and joined into one line — `errorLine()`/`renderProgress()` must
307
+ * still be called exactly once per catch site, since Lanes' TTY repaint only
308
+ * keeps the latest frame (see {@link errorLine}'s JSDoc).
309
+ * @param error
310
+ */
311
+ function formatErrorMessage(error) {
312
+ return unwrapSuppressedError(error)
313
+ .map((cause) => (cause instanceof Error ? cause.message : String(cause)))
314
+ .join(' / ');
315
+ }
301
316
  /**
302
317
  * Maps a library `ProgressEvent` to a human-facing `ProgressLine`. The
303
318
  * verbose arm keeps the historical `pass0:` / `pass1:` / `pass1b:` /
@@ -386,7 +401,10 @@ export async function runCli(options) {
386
401
  // pass in) does — reading it defensively lets both real usage and
387
402
  // unit-test doubles work without a separate `--no-progress` flag.
388
403
  const useTty = options.stderr.isTTY === true;
389
- const lanes = new Lanes({ stream: options.stderr, verbose: !useTty });
404
+ // `using` により、この関数を抜けるすべての経路(下記の各 `return`
405
+ // もちろん、想定外の例外を含む)で確実に lanes.close() が呼ばれ、
406
+ // Display の setTimeout タイマーが解放される。
407
+ using lanes = new Lanes({ stream: options.stderr, verbose: !useTty });
390
408
  // Verbose Lanes prepends `#header` to every `update()` line. Without
391
409
  // this seed call the header would be undefined and each progress line
392
410
  // would begin with the literal string `undefined ` — bug caught by
@@ -397,86 +415,77 @@ export async function runCli(options) {
397
415
  }
398
416
  const startTime = Date.now();
399
417
  const elapsed = () => Math.max(0, Math.round((Date.now() - startTime) / 1000));
400
- // Every early return past this point must run through the finally block
401
- // so `lanes.close()` releases the display's setTimeout timer without
402
- // it a `return 1` on a stdin parse error would leave the process
403
- // hanging on the timer's next tick.
418
+ renderProgress(lanes, useTty, READING_INPUT);
419
+ // Load every JSONL line into memory once so the ids array stays
420
+ // parallel to the pages array the streaming driver reads its
421
+ // factory twice, and stdin is a one-shot pipe.
422
+ const ids = [];
423
+ const pages = [];
424
+ try {
425
+ for await (const { id, page } of readJsonlPages(options.stdin)) {
426
+ ids.push(id);
427
+ pages.push(page);
428
+ }
429
+ }
430
+ catch (error) {
431
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
432
+ return 1;
433
+ }
434
+ renderProgress(lanes, useTty, readingDoneLine(pages.length));
435
+ // Only worth collecting when the caller asked for the file — a
436
+ // ClusterReason Map costs bookkeeping proportional to cluster count,
437
+ // not page count, but there's no reason to pay even that when unused.
438
+ const reasonsByClusterKey = args.clusterReasonsFile
439
+ ? new Map()
440
+ : undefined;
441
+ // Same opt-in gate as `reasonsByClusterKey` — `onPartitionReport`
442
+ // itself gates the underlying validation pass (see its own JSDoc).
443
+ let partitionReport;
444
+ const resolveOptions = {
445
+ contentBlockAttribute: args.contentBlockAttribute,
446
+ onProgress: (event) => {
447
+ renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
448
+ },
449
+ onClusterReason: reasonsByClusterKey
450
+ ? (key, reason) => reasonsByClusterKey.set(key, reason)
451
+ : undefined,
452
+ onPartitionReport: args.validationFile
453
+ ? (report) => (partitionReport = report)
454
+ : undefined,
455
+ };
456
+ let clusterKeys;
404
457
  try {
405
- renderProgress(lanes, useTty, READING_INPUT);
406
- // Load every JSONL line into memory once so the ids array stays
407
- // parallel to the pages array — the streaming driver reads its
408
- // factory twice, and stdin is a one-shot pipe.
409
- const ids = [];
410
- const pages = [];
458
+ clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions);
459
+ }
460
+ catch (error) {
461
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
462
+ return 1;
463
+ }
464
+ const clusterCount = new Set(clusterKeys).size;
465
+ renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
466
+ for (const [index, key] of clusterKeys.entries()) {
467
+ const row = { id: ids[index] ?? index, clusterKey: key };
468
+ options.stdout.write(`${JSON.stringify(row)}\n`);
469
+ }
470
+ if (args.clusterReasonsFile && reasonsByClusterKey) {
411
471
  try {
412
- for await (const { id, page } of readJsonlPages(options.stdin)) {
413
- ids.push(id);
414
- pages.push(page);
415
- }
472
+ await writeFile(args.clusterReasonsFile, JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2));
416
473
  }
417
474
  catch (error) {
418
- renderProgress(lanes, useTty, errorLine(error.message));
475
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
419
476
  return 1;
420
477
  }
421
- renderProgress(lanes, useTty, readingDoneLine(pages.length));
422
- // Only worth collecting when the caller asked for the file — a
423
- // ClusterReason Map costs bookkeeping proportional to cluster count,
424
- // not page count, but there's no reason to pay even that when unused.
425
- const reasonsByClusterKey = args.clusterReasonsFile
426
- ? new Map()
427
- : undefined;
428
- // Same opt-in gate as `reasonsByClusterKey` — `onPartitionReport`
429
- // itself gates the underlying validation pass (see its own JSDoc).
430
- let partitionReport;
431
- const resolveOptions = {
432
- contentBlockAttribute: args.contentBlockAttribute,
433
- onProgress: (event) => {
434
- renderProgress(lanes, useTty, formatProgressLine(event, elapsed()));
435
- },
436
- onClusterReason: reasonsByClusterKey
437
- ? (key, reason) => reasonsByClusterKey.set(key, reason)
438
- : undefined,
439
- onPartitionReport: args.validationFile
440
- ? (report) => (partitionReport = report)
441
- : undefined,
442
- };
443
- let clusterKeys;
478
+ }
479
+ if (args.validationFile && partitionReport) {
444
480
  try {
445
- clusterKeys = await resolvePageClusterKeys(() => pages, resolveOptions);
481
+ await writeFile(args.validationFile, JSON.stringify(toJsonSafePartitionReport(partitionReport), null, 2));
446
482
  }
447
483
  catch (error) {
448
- renderProgress(lanes, useTty, errorLine(error.message));
484
+ renderProgress(lanes, useTty, errorLine(formatErrorMessage(error)));
449
485
  return 1;
450
486
  }
451
- const clusterCount = new Set(clusterKeys).size;
452
- renderProgress(lanes, useTty, doneLine(pages.length, clusterCount, elapsed()));
453
- for (const [index, key] of clusterKeys.entries()) {
454
- const row = { id: ids[index] ?? index, clusterKey: key };
455
- options.stdout.write(`${JSON.stringify(row)}\n`);
456
- }
457
- if (args.clusterReasonsFile && reasonsByClusterKey) {
458
- try {
459
- await writeFile(args.clusterReasonsFile, JSON.stringify(Object.fromEntries(reasonsByClusterKey), null, 2));
460
- }
461
- catch (error) {
462
- renderProgress(lanes, useTty, errorLine(error.message));
463
- return 1;
464
- }
465
- }
466
- if (args.validationFile && partitionReport) {
467
- try {
468
- await writeFile(args.validationFile, JSON.stringify(toJsonSafePartitionReport(partitionReport), null, 2));
469
- }
470
- catch (error) {
471
- renderProgress(lanes, useTty, errorLine(error.message));
472
- return 1;
473
- }
474
- }
475
- return 0;
476
- }
477
- finally {
478
- lanes.close();
479
487
  }
488
+ return 0;
480
489
  }
481
490
  /**
482
491
  * Reads the package version out of `package.json` at runtime. Kept as a
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@d-zero/page-cluster",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
4
4
  "description": "Clusters crawled HTML pages by DOM-structure similarity — assigns the same key to pages sharing a template, ignoring text content. CLI-first, with library APIs.",
5
5
  "author": "D-ZERO",
6
6
  "license": "MIT",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
10
+ "engines": {
11
+ "node": ">=24.11.0"
12
+ },
10
13
  "type": "module",
11
14
  "exports": {
12
15
  ".": {
@@ -76,8 +79,9 @@
76
79
  "clean": "tsc --build --clean"
77
80
  },
78
81
  "dependencies": {
79
- "@d-zero/dealer": "1.10.4",
80
- "@d-zero/shared": "0.22.5",
82
+ "@d-zero/cli-core": "1.4.0",
83
+ "@d-zero/dealer": "1.12.0",
84
+ "@d-zero/shared": "0.23.0",
81
85
  "htmlparser2": "12.0.0"
82
86
  },
83
87
  "repository": {
@@ -85,5 +89,5 @@
85
89
  "url": "https://github.com/d-zero-dev/tools.git",
86
90
  "directory": "packages/@d-zero/page-cluster"
87
91
  },
88
- "gitHead": "1321d85f63db05896135599046924221f4f714c5"
92
+ "gitHead": "00f909c83bad65d568da711ebc4ca1c2f369cad2"
89
93
  }