@link-assistant/web-capture 1.1.2 → 1.4.2

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 (55) hide show
  1. package/CHANGELOG.md +17 -14
  2. package/README.md +256 -146
  3. package/bin/web-capture.js +403 -6
  4. package/package.json +11 -4
  5. package/src/animation.js +283 -0
  6. package/src/archive.js +212 -0
  7. package/src/batch.js +180 -0
  8. package/src/browser.js +150 -111
  9. package/src/docx.js +182 -0
  10. package/src/figures.js +208 -0
  11. package/src/gdocs.js +333 -0
  12. package/src/image.js +65 -8
  13. package/src/index.js +15 -0
  14. package/src/latex.js +127 -0
  15. package/src/lib.js +226 -1
  16. package/src/localize-images.js +202 -0
  17. package/src/metadata.js +333 -0
  18. package/src/pdf.js +76 -0
  19. package/src/popups.js +171 -0
  20. package/src/postprocess.js +265 -0
  21. package/src/retry.js +48 -0
  22. package/src/themed-image.js +167 -0
  23. package/src/verify.js +312 -0
  24. package/tests/e2e/docker.test.js +51 -12
  25. package/tests/e2e/process.test.js +34 -10
  26. package/tests/fixtures/habr-articles.js +45 -0
  27. package/tests/integration/api-endpoints.test.js +231 -0
  28. package/tests/integration/habr-article.test.js +337 -0
  29. package/tests/mock/index.test.js +5 -3
  30. package/tests/unit/animation.test.js +33 -0
  31. package/tests/unit/batch.test.js +114 -0
  32. package/tests/unit/browser.test.js +38 -2
  33. package/tests/unit/figures.test.js +74 -0
  34. package/tests/unit/gdocs.test.js +203 -0
  35. package/tests/unit/latex.test.js +123 -0
  36. package/tests/unit/localize-images.test.js +78 -0
  37. package/tests/unit/metadata.test.js +140 -0
  38. package/tests/unit/postprocess.test.js +108 -0
  39. package/tests/unit/retry.test.js +111 -0
  40. package/tests/unit/verify.test.js +182 -0
  41. package/.github/workflows/release.yml +0 -294
  42. package/ARCHITECTURE.md +0 -982
  43. package/LICENSE +0 -24
  44. package/apply.sh +0 -166
  45. package/docs/market-analysis-2025-05-13.md +0 -64
  46. package/scripts/changeset-version.mjs +0 -38
  47. package/scripts/create-github-release.mjs +0 -93
  48. package/scripts/create-manual-changeset.mjs +0 -88
  49. package/scripts/format-github-release.mjs +0 -83
  50. package/scripts/format-release-notes.mjs +0 -218
  51. package/scripts/instant-version-bump.mjs +0 -121
  52. package/scripts/publish-to-npm.mjs +0 -128
  53. package/scripts/setup-npm.mjs +0 -37
  54. package/scripts/validate-changeset.mjs +0 -106
  55. package/scripts/version-and-commit.mjs +0 -237
@@ -32,9 +32,47 @@ const config = makeConfig({
32
32
  .option('format', {
33
33
  alias: 'f',
34
34
  type: 'string',
35
- description: 'Output format: html, markdown, md, image, png',
35
+ description:
36
+ 'Output format: html, markdown, md, image, png, jpeg, pdf, docx, archive',
36
37
  default: 'html',
37
38
  })
39
+ .option('theme', {
40
+ alias: 't',
41
+ type: 'string',
42
+ description:
43
+ 'Color scheme for screenshots/PDF: light, dark, no-preference',
44
+ })
45
+ .option('width', {
46
+ type: 'number',
47
+ description: 'Viewport width in pixels (default: 1280)',
48
+ default: 1280,
49
+ })
50
+ .option('height', {
51
+ type: 'number',
52
+ description: 'Viewport height in pixels (default: 800)',
53
+ default: 800,
54
+ })
55
+ .option('quality', {
56
+ type: 'number',
57
+ description: 'JPEG quality 0-100 (default: 80, only for jpeg format)',
58
+ default: 80,
59
+ })
60
+ .option('fullPage', {
61
+ type: 'boolean',
62
+ description: 'Capture full scrollable page (default: false)',
63
+ default: false,
64
+ })
65
+ .option('localImages', {
66
+ type: 'boolean',
67
+ description: 'Download images locally in archive mode (default: true)',
68
+ default: true,
69
+ })
70
+ .option('documentFormat', {
71
+ type: 'string',
72
+ description:
73
+ 'Document format in archive mode: markdown (default) or html',
74
+ default: 'markdown',
75
+ })
38
76
  .option('output', {
39
77
  alias: 'o',
40
78
  type: 'string',
@@ -51,6 +89,67 @@ const config = makeConfig({
51
89
  type: 'string',
52
90
  description: 'Path to .lenv configuration file',
53
91
  })
92
+ .option('enhanced', {
93
+ type: 'boolean',
94
+ description:
95
+ 'Use enhanced markdown conversion with LaTeX extraction, metadata, and post-processing (default: false)',
96
+ default: false,
97
+ })
98
+ .option('extractLatex', {
99
+ type: 'boolean',
100
+ description:
101
+ 'Extract LaTeX formulas from img.formula, KaTeX, MathJax (default: true when --enhanced)',
102
+ default: true,
103
+ })
104
+ .option('extractMetadata', {
105
+ type: 'boolean',
106
+ description:
107
+ 'Extract article metadata (author, date, hubs, tags) (default: true when --enhanced)',
108
+ default: true,
109
+ })
110
+ .option('postProcess', {
111
+ type: 'boolean',
112
+ description:
113
+ 'Apply post-processing (unicode normalization, LaTeX spacing) (default: true when --enhanced)',
114
+ default: true,
115
+ })
116
+ .option('detectCodeLanguage', {
117
+ type: 'boolean',
118
+ description:
119
+ 'Detect and correct code block languages (default: true when --enhanced)',
120
+ default: true,
121
+ })
122
+ .option('dualTheme', {
123
+ type: 'boolean',
124
+ description:
125
+ 'Capture both light and dark theme screenshots (default: false)',
126
+ default: false,
127
+ })
128
+ .option('configFile', {
129
+ type: 'string',
130
+ description: 'Path to batch configuration file (JSON or MJS)',
131
+ })
132
+ .option('all', {
133
+ type: 'boolean',
134
+ description: 'Process all articles in batch configuration',
135
+ default: false,
136
+ })
137
+ .option('dryRun', {
138
+ type: 'boolean',
139
+ description: 'Show what would be done without making changes',
140
+ default: false,
141
+ })
142
+ .option('verbose', {
143
+ type: 'boolean',
144
+ description: 'Show detailed output',
145
+ default: false,
146
+ })
147
+ .option('apiToken', {
148
+ type: 'string',
149
+ description:
150
+ 'API token for authenticated capture (e.g., Google Docs private documents). Can also be set via API_TOKEN env variable.',
151
+ default: getenv('API_TOKEN', undefined),
152
+ })
54
153
  .help('help')
55
154
  .alias('help', 'h')
56
155
  .version()
@@ -68,12 +167,26 @@ const config = makeConfig({
68
167
  'web-capture https://example.com --format markdown --output page.md',
69
168
  'Capture URL as Markdown to file'
70
169
  )
170
+ .option('capture', {
171
+ type: 'string',
172
+ description:
173
+ 'Capture method: browser (default, uses Puppeteer/Playwright) or api (direct HTTP fetch, for Google Docs etc.)',
174
+ default: 'browser',
175
+ })
71
176
  .example(
72
177
  'web-capture https://example.com --format png --engine playwright -o screenshot.png',
73
178
  'Capture screenshot using Playwright'
74
179
  )
180
+ .example(
181
+ 'web-capture https://docs.google.com/document/d/DOC_ID/edit --format markdown',
182
+ 'Capture Google Doc as Markdown (API-based, public doc)'
183
+ )
184
+ .example(
185
+ 'web-capture https://docs.google.com/document/d/DOC_ID/edit --format markdown --apiToken YOUR_TOKEN',
186
+ 'Capture private Google Doc with API token'
187
+ )
75
188
  .epilogue(
76
- 'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /image?url=<URL>&engine=<ENGINE> Get PNG screenshot\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy'
189
+ 'API Endpoints (in server mode):\n GET /html?url=<URL>&engine=<ENGINE> Get rendered HTML\n GET /markdown?url=<URL> Get Markdown conversion\n GET /image?url=<URL>&format=png|jpeg&theme=light|dark Screenshot\n GET /archive?url=<URL>&localImages=true&documentFormat=markdown|html ZIP archive\n GET /pdf?url=<URL>&theme=light|dark PDF with embedded images\n GET /docx?url=<URL> DOCX with embedded images\n GET /fetch?url=<URL> Proxy fetch\n GET /stream?url=<URL> Streaming proxy\n GET /gdocs?url=<URL>&format=markdown|html|txt Google Docs capture'
77
190
  )
78
191
  .strict(),
79
192
  lenv: {
@@ -93,9 +206,15 @@ async function startServer(port) {
93
206
  console.log('Available endpoints:');
94
207
  console.log(` GET /html?url=<URL> - Render page as HTML`);
95
208
  console.log(` GET /markdown?url=<URL> - Convert page to Markdown`);
96
- console.log(` GET /image?url=<URL> - Screenshot page as PNG`);
209
+ console.log(` GET /image?url=<URL> - Screenshot (PNG/JPEG)`);
210
+ console.log(
211
+ ` GET /archive?url=<URL> - ZIP archive with markdown + images`
212
+ );
213
+ console.log(` GET /pdf?url=<URL> - PDF with embedded images`);
214
+ console.log(` GET /docx?url=<URL> - DOCX with embedded images`);
97
215
  console.log(` GET /fetch?url=<URL> - Proxy fetch content`);
98
216
  console.log(` GET /stream?url=<URL> - Stream content`);
217
+ console.log(` GET /gdocs?url=<URL> - Capture Google Docs document`);
99
218
  console.log('');
100
219
  console.log('Press Ctrl+C to stop the server');
101
220
  resolve(server);
@@ -123,7 +242,19 @@ async function startServer(port) {
123
242
  }
124
243
 
125
244
  async function captureUrl(url, options) {
126
- const { format, output, engine } = options;
245
+ const {
246
+ format,
247
+ output,
248
+ engine,
249
+ theme,
250
+ width,
251
+ height,
252
+ quality,
253
+ fullPage,
254
+ // localImages is used by archive format
255
+ // eslint-disable-next-line no-unused-vars
256
+ localImages,
257
+ } = options;
127
258
 
128
259
  // Ensure URL is absolute
129
260
  let absoluteUrl = url;
@@ -147,14 +278,266 @@ async function captureUrl(url, options) {
147
278
  convertRelativeUrls,
148
279
  } = await import('../src/lib.js');
149
280
  const { createBrowser } = await import('../src/browser.js');
281
+ const { isGoogleDocsUrl, fetchGoogleDoc, fetchGoogleDocAsMarkdown } =
282
+ await import('../src/gdocs.js');
150
283
 
151
284
  const normalizedFormat = format.toLowerCase();
152
285
 
286
+ // Auto-detect Google Docs URLs and use API-based capture
287
+ if (isGoogleDocsUrl(absoluteUrl)) {
288
+ try {
289
+ const apiToken = options.apiToken;
290
+ if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
291
+ const result = await fetchGoogleDocAsMarkdown(absoluteUrl, {
292
+ apiToken,
293
+ });
294
+ if (output) {
295
+ fs.writeFileSync(output, result.markdown, 'utf-8');
296
+ console.error(`Google Doc Markdown saved to: ${output}`);
297
+ } else {
298
+ process.stdout.write(result.markdown);
299
+ }
300
+ } else {
301
+ const gdocsFormat =
302
+ normalizedFormat === 'png' || normalizedFormat === 'image'
303
+ ? 'html'
304
+ : normalizedFormat;
305
+ const result = await fetchGoogleDoc(absoluteUrl, {
306
+ format: gdocsFormat,
307
+ apiToken,
308
+ });
309
+ if (output) {
310
+ fs.writeFileSync(output, result.content, 'utf-8');
311
+ console.error(`Google Doc (${gdocsFormat}) saved to: ${output}`);
312
+ } else {
313
+ process.stdout.write(result.content);
314
+ }
315
+ }
316
+ return;
317
+ } catch (err) {
318
+ console.error(`Error capturing Google Doc: ${err.message}`);
319
+ process.exit(1);
320
+ }
321
+ }
322
+
153
323
  try {
154
- if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
324
+ if (normalizedFormat === 'jpeg') {
325
+ // JPEG screenshot
326
+ const { createBrowser } = await import('../src/browser.js');
327
+ const { dismissPopups, scrollToLoadContent } =
328
+ await import('../src/popups.js');
329
+ const browserOpts = theme ? { colorScheme: theme } : {};
330
+ const browser = await createBrowser(engine, browserOpts);
331
+ try {
332
+ const page = await browser.newPage();
333
+ await page.setViewport({ width, height });
334
+ await page.goto(absoluteUrl, {
335
+ waitUntil: 'networkidle0',
336
+ timeout: 30000,
337
+ });
338
+ await new Promise((resolve) => setTimeout(resolve, 5000));
339
+ if (fullPage) {
340
+ await scrollToLoadContent(page);
341
+ }
342
+ await dismissPopups(page);
343
+ const buffer = await page.screenshot({
344
+ type: 'jpeg',
345
+ quality,
346
+ fullPage,
347
+ });
348
+ const outPath =
349
+ output ||
350
+ `${new URL(absoluteUrl).hostname.replace(/\./g, '_')}_${Date.now()}.jpg`;
351
+ fs.writeFileSync(outPath, buffer);
352
+ console.error(`JPEG screenshot saved to: ${outPath}`);
353
+ } finally {
354
+ await browser.close();
355
+ }
356
+ } else if (normalizedFormat === 'pdf') {
357
+ // PDF export
358
+ const { createBrowser } = await import('../src/browser.js');
359
+ const { dismissPopups, scrollToLoadContent } =
360
+ await import('../src/popups.js');
361
+ const browserOpts = theme ? { colorScheme: theme } : {};
362
+ const browser = await createBrowser(engine, browserOpts);
363
+ try {
364
+ const page = await browser.newPage();
365
+ await page.setViewport({ width, height });
366
+ await page.goto(absoluteUrl, {
367
+ waitUntil: 'networkidle0',
368
+ timeout: 30000,
369
+ });
370
+ await new Promise((resolve) => setTimeout(resolve, 5000));
371
+ await scrollToLoadContent(page);
372
+ await dismissPopups(page);
373
+ const rawPage = page.rawPage || page;
374
+ const pdfBuffer = await rawPage.pdf({
375
+ format: 'A4',
376
+ printBackground: true,
377
+ margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' },
378
+ });
379
+ const outPath = output || 'page.pdf';
380
+ fs.writeFileSync(outPath, pdfBuffer);
381
+ console.error(`PDF saved to: ${outPath}`);
382
+ } finally {
383
+ await browser.close();
384
+ }
385
+ } else if (normalizedFormat === 'docx') {
386
+ // DOCX export – delegate to the docx handler logic
387
+ const { fetchHtml, convertRelativeUrls } = await import('../src/lib.js');
388
+ const cheerio = await import('cheerio');
389
+ // eslint-disable-next-line no-unused-vars
390
+ const { Document, Packer, Paragraph, TextRun, HeadingLevel } =
391
+ await import('docx');
392
+ const html = await fetchHtml(absoluteUrl);
393
+ const absHtml = convertRelativeUrls(html, absoluteUrl);
394
+ const $ = cheerio.load(absHtml);
395
+ $('style, script, noscript, nav, footer, header').remove();
396
+ const children = [];
397
+ const titleText =
398
+ $('h1').first().text().trim() || $('title').text().trim();
399
+ if (titleText) {
400
+ children.push(
401
+ new Paragraph({ text: titleText, heading: HeadingLevel.TITLE })
402
+ );
403
+ }
404
+ const body = $('article').length ? $('article') : $('body');
405
+ for (const el of body.children().toArray()) {
406
+ const text = $(el).text().trim();
407
+ if (text) {
408
+ children.push(new Paragraph({ text }));
409
+ }
410
+ }
411
+ if (children.length === 0) {
412
+ children.push(new Paragraph({ text: 'No content extracted.' }));
413
+ }
414
+ const doc = new Document({ sections: [{ children }] });
415
+ const buffer = await Packer.toBuffer(doc);
416
+ const outPath = output || 'page.docx';
417
+ fs.writeFileSync(outPath, buffer);
418
+ console.error(`DOCX saved to: ${outPath}`);
419
+ } else if (normalizedFormat === 'archive' || normalizedFormat === 'zip') {
420
+ // ZIP archive
421
+ const { default: archiver } = await import('archiver');
422
+ const { fetchHtml, convertHtmlToMarkdown, convertRelativeUrls } =
423
+ await import('../src/lib.js');
424
+ const { retry } = await import('../src/retry.js');
425
+ const cheerio = await import('cheerio');
426
+ const nodeFetch = await import('node-fetch');
427
+
428
+ const html = await retry(() => fetchHtml(absoluteUrl), {
429
+ retries: 3,
430
+ baseDelay: 1000,
431
+ onRetry: (err, attempt) => {
432
+ console.error(`Retry ${attempt} fetching page: ${err.message}`);
433
+ },
434
+ });
435
+
436
+ const docFormat = options.documentFormat === 'html' ? 'html' : 'markdown';
437
+ const outPath =
438
+ output ||
439
+ `${new URL(absoluteUrl).hostname.replace(/\./g, '-')}-archive.zip`;
440
+ const outStream = fs.createWriteStream(outPath);
441
+ const archive = archiver.default
442
+ ? archiver.default('zip', { zlib: { level: 9 } })
443
+ : archiver('zip', { zlib: { level: 9 } });
444
+ archive.pipe(outStream);
445
+
446
+ // Collect images
447
+ const $ = cheerio.load(html);
448
+ const images = [];
449
+ $('img').each(function () {
450
+ const src = $(this).attr('src');
451
+ if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
452
+ try {
453
+ images.push(new URL(src, absoluteUrl).href);
454
+ } catch {
455
+ /* skip */
456
+ }
457
+ }
458
+ });
459
+ const uniqueImages = [...new Set(images)];
460
+
461
+ // Build image map for local downloads
462
+ const imageMap = new Map();
463
+ if (options.localImages && uniqueImages.length > 0) {
464
+ let idx = 1;
465
+ for (const imgUrl of uniqueImages) {
466
+ const ext = imgUrl.match(/\.(jpe?g|gif|webp|svg|png)/i)?.[1] || 'png';
467
+ imageMap.set(imgUrl, `images/image-${idx}.${ext}`);
468
+ idx++;
469
+ }
470
+ }
471
+
472
+ if (docFormat === 'html') {
473
+ const outputHtml = convertRelativeUrls(html, absoluteUrl);
474
+ const $out = cheerio.load(outputHtml);
475
+ $out('script, noscript').remove();
476
+ if (imageMap.size > 0) {
477
+ $out('img').each(function () {
478
+ const src = $out(this).attr('src');
479
+ if (src) {
480
+ try {
481
+ const resolved = new URL(src, absoluteUrl).href;
482
+ if (imageMap.has(resolved)) {
483
+ $out(this).attr('src', imageMap.get(resolved));
484
+ }
485
+ } catch {
486
+ /* skip */
487
+ }
488
+ }
489
+ });
490
+ }
491
+ archive.append($out.html(), { name: 'article.html' });
492
+ } else {
493
+ let markdown = convertHtmlToMarkdown(html, absoluteUrl);
494
+ if (imageMap.size > 0) {
495
+ for (const [remoteUrl, localPath] of imageMap) {
496
+ markdown = markdown.split(remoteUrl).join(localPath);
497
+ }
498
+ }
499
+ archive.append(markdown, { name: 'article.md' });
500
+ }
501
+
502
+ // Download images
503
+ if (imageMap.size > 0) {
504
+ for (const [imgUrl, localPath] of imageMap) {
505
+ try {
506
+ const fetchFn = nodeFetch.default || nodeFetch;
507
+ const resp = await retry(() => fetchFn(imgUrl), {
508
+ retries: 2,
509
+ baseDelay: 500,
510
+ });
511
+ if (resp.ok) {
512
+ const buffer = await resp.buffer();
513
+ archive.append(buffer, { name: localPath });
514
+ }
515
+ } catch {
516
+ /* skip failed image downloads */
517
+ }
518
+ }
519
+ }
520
+
521
+ await archive.finalize();
522
+ await new Promise((resolve) => outStream.on('close', resolve));
523
+ console.error(`Archive saved to: ${outPath}`);
524
+ } else if (normalizedFormat === 'markdown' || normalizedFormat === 'md') {
155
525
  // Markdown format
156
526
  const html = await fetchHtml(absoluteUrl);
157
- const markdown = convertHtmlToMarkdown(html, absoluteUrl);
527
+ let markdown;
528
+
529
+ if (options.enhanced) {
530
+ const { convertHtmlToMarkdownEnhanced } = await import('../src/lib.js');
531
+ const result = convertHtmlToMarkdownEnhanced(html, absoluteUrl, {
532
+ extractLatex: options.extractLatex,
533
+ extractMetadata: options.extractMetadata,
534
+ postProcess: options.postProcess,
535
+ detectCodeLanguage: options.detectCodeLanguage,
536
+ });
537
+ markdown = result.markdown;
538
+ } else {
539
+ markdown = convertHtmlToMarkdown(html, absoluteUrl);
540
+ }
158
541
 
159
542
  if (output) {
160
543
  fs.writeFileSync(output, markdown, 'utf-8');
@@ -270,6 +653,20 @@ async function main() {
270
653
  format: config.format,
271
654
  output: config.output,
272
655
  engine: config.engine,
656
+ theme: config.theme,
657
+ width: config.width,
658
+ height: config.height,
659
+ quality: config.quality,
660
+ fullPage: config.fullPage,
661
+ localImages: config.localImages,
662
+ documentFormat: config.documentFormat,
663
+ enhanced: config.enhanced,
664
+ extractLatex: config.extractLatex,
665
+ extractMetadata: config.extractMetadata,
666
+ postProcess: config.postProcess,
667
+ detectCodeLanguage: config.detectCodeLanguage,
668
+ dualTheme: config.dualTheme,
669
+ apiToken: config.apiToken,
273
670
  });
274
671
  } else {
275
672
  // No arguments - show error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.1.2",
3
+ "version": "1.4.2",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -27,23 +27,30 @@
27
27
  "check": "npm run lint && npm run format:check && npm run check:duplication",
28
28
  "prepare": "husky || true",
29
29
  "changeset": "changeset",
30
- "changeset:version": "node scripts/changeset-version.mjs",
30
+ "changeset:version": "node ../scripts/changeset-version.mjs",
31
31
  "changeset:publish": "changeset publish",
32
32
  "changeset:status": "changeset status --since=origin/main"
33
33
  },
34
34
  "dependencies": {
35
+ "archiver": "^7.0.1",
36
+ "browser-commander": "^0.8.0",
35
37
  "cheerio": "^1.0.0",
38
+ "docx": "^9.6.1",
36
39
  "express": "^4.18.2",
37
- "lino-arguments": "^0.2.1",
40
+ "he": "^1.2.0",
41
+ "iconv-lite": "^0.6.3",
42
+ "lino-arguments": "^0.3.0",
38
43
  "node-fetch": "^2.7.0",
39
44
  "playwright": "^1.49.0",
40
45
  "puppeteer": "^24.8.2",
46
+ "puppeteer-core": "^24.40.0",
41
47
  "turndown": "^7.1.1"
42
48
  },
43
49
  "devDependencies": {
44
50
  "@babel/core": "^7.24.0",
45
51
  "@babel/preset-env": "^7.24.0",
46
52
  "@changesets/cli": "^2.29.7",
53
+ "@types/archiver": "^7.0.0",
47
54
  "babel-jest": "^29.7.0",
48
55
  "eslint": "^9.38.0",
49
56
  "eslint-config-prettier": "^10.1.8",
@@ -65,7 +72,7 @@
65
72
  "screenshot",
66
73
  "microservice"
67
74
  ],
68
- "license": "UNLICENSED",
75
+ "license": "Unlicense",
69
76
  "engines": {
70
77
  "node": ">=22.0.0 <23.0.0"
71
78
  },