@jackwener/opencli 1.8.0 → 1.8.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 (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
- import { AuthRequiredError, CliError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, } from './shared.js';
3
- import { callNotebooklmRpc, getNotebooklmPageAuth, } from './rpc.js';
3
+ import { callNotebooklmRpc, getNotebooklmPageAuth, unwrapNotebooklmEvaluateResult, } from './rpc.js';
4
4
  export { buildNotebooklmRpcBody, extractNotebooklmRpcResult, fetchNotebooklmInPage, getNotebooklmPageAuth, parseNotebooklmChunkedResponse, stripNotebooklmAntiXssi, } from './rpc.js';
5
5
  const NOTEBOOKLM_LIST_RPC_ID = 'wXbhsf';
6
6
  const NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID = 'rLM1Ne';
@@ -13,28 +13,61 @@ function unwrapNotebooklmSingletonResult(result) {
13
13
  }
14
14
  return current;
15
15
  }
16
+ export function isPlainObject(value) {
17
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
18
+ }
16
19
  export function parseNotebooklmIdFromUrl(url) {
17
20
  const match = url.match(/\/notebook\/([^/?#]+)/);
18
21
  return match?.[1] ?? '';
19
22
  }
23
+ const NOTEBOOK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ function ensureNotebookUuid(candidate) {
25
+ if (!NOTEBOOK_UUID_RE.test(candidate)) {
26
+ throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', `NotebookLM notebook id "${candidate}" is not a valid UUID`, 'Pass a notebook id from `opencli notebooklm list` or a full notebook URL like https://notebooklm.google.com/notebook/<uuid>.');
27
+ }
28
+ return candidate;
29
+ }
20
30
  export function parseNotebooklmNotebookTarget(value) {
21
31
  const normalized = value.trim();
22
32
  if (!normalized) {
23
33
  throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook id is required', 'Pass a notebook id from `opencli notebooklm list` or a full notebook URL.');
24
34
  }
25
35
  if (/^https?:\/\//i.test(normalized)) {
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(normalized);
39
+ }
40
+ catch {
41
+ throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook URL is invalid', 'Pass a full NotebookLM notebook URL like https://notebooklm.google.com/notebook/<uuid>.');
42
+ }
43
+ if (parsed.protocol !== 'https:' || parsed.hostname !== NOTEBOOKLM_DOMAIN || parsed.username || parsed.password || parsed.port) {
44
+ throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook URL must be a canonical https://notebooklm.google.com URL', 'Pass a notebook id from `opencli notebooklm list` or a full NotebookLM notebook URL.');
45
+ }
26
46
  const notebookId = parseNotebooklmIdFromUrl(normalized);
27
- if (notebookId)
28
- return notebookId;
29
- throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook URL is invalid', 'Pass a full NotebookLM notebook URL like https://notebooklm.google.com/notebook/<id>.');
47
+ if (!notebookId) {
48
+ throw new CliError('NOTEBOOKLM_INVALID_NOTEBOOK', 'NotebookLM notebook URL is invalid', 'Pass a full NotebookLM notebook URL like https://notebooklm.google.com/notebook/<uuid>.');
49
+ }
50
+ return ensureNotebookUuid(notebookId);
30
51
  }
31
52
  const pathMatch = normalized.match(/(?:^|\/)notebook\/([^/?#]+)/);
32
53
  if (pathMatch?.[1])
33
- return pathMatch[1];
34
- return normalized;
54
+ return ensureNotebookUuid(pathMatch[1]);
55
+ return ensureNotebookUuid(normalized);
56
+ }
57
+ export function getNotebooklmAuthuser() {
58
+ const v = process.env.OPENCLI_NOTEBOOKLM_AUTHUSER;
59
+ return typeof v === 'string' && /^\d+$/.test(v) ? v : '';
60
+ }
61
+ export function requireNotebooklmExecute(value, action) {
62
+ if (value !== true) {
63
+ throw new ArgumentError(`Refusing to ${action}: pass --execute to perform this NotebookLM write`);
64
+ }
35
65
  }
36
66
  export function buildNotebooklmNotebookUrl(notebookId) {
37
- return new URL(`/notebook/${encodeURIComponent(notebookId)}`, NOTEBOOKLM_HOME_URL).toString();
67
+ const u = new URL(`/notebook/${encodeURIComponent(notebookId)}`, NOTEBOOKLM_HOME_URL);
68
+ const authuser = getNotebooklmAuthuser();
69
+ if (authuser) u.searchParams.set('authuser', authuser);
70
+ return u.toString();
38
71
  }
39
72
  export function classifyNotebooklmPage(url) {
40
73
  try {
@@ -401,6 +434,42 @@ export async function getNotebooklmDetailViaRpc(page) {
401
434
  const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID, [state.notebookId, null, [2], null, 0]);
402
435
  return parseNotebooklmNotebookDetailResult(rpc.result);
403
436
  }
437
+ export async function getNotebooklmNotebookDetailById(page, notebookId) {
438
+ const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_NOTEBOOK_DETAIL_RPC_ID, [notebookId, null, [2], null, 0]);
439
+ return { detail: parseNotebooklmNotebookDetailResult(rpc.result), sources: parseNotebooklmSourceListResult(rpc.result) };
440
+ }
441
+ export async function verifyNotebooklmNotebookExists(page, notebookId, action) {
442
+ try {
443
+ const { detail } = await getNotebooklmNotebookDetailById(page, notebookId);
444
+ if (!detail || detail.id !== notebookId) {
445
+ throw new CommandExecutionError(`NotebookLM ${action} succeeded but the notebook ${notebookId} was not found in the post-write verification`);
446
+ }
447
+ return detail;
448
+ }
449
+ catch (error) {
450
+ if (error instanceof AuthRequiredError || error instanceof CommandExecutionError)
451
+ throw error;
452
+ throw new CommandExecutionError(`NotebookLM ${action} post-write verification failed: ${error?.message || error}`);
453
+ }
454
+ }
455
+ export async function verifyNotebooklmSourceAdded(page, notebookId, sourceId, action) {
456
+ try {
457
+ const { detail, sources } = await getNotebooklmNotebookDetailById(page, notebookId);
458
+ if (!detail || detail.id !== notebookId) {
459
+ throw new CommandExecutionError(`NotebookLM ${action} succeeded but the notebook ${notebookId} was not found in the post-write verification`);
460
+ }
461
+ const matched = sources.find((s) => s.id === sourceId);
462
+ if (!matched) {
463
+ throw new CommandExecutionError(`NotebookLM ${action} succeeded but source ${sourceId} did not appear in the notebook's source list`);
464
+ }
465
+ return matched;
466
+ }
467
+ catch (error) {
468
+ if (error instanceof AuthRequiredError || error instanceof CommandExecutionError)
469
+ throw error;
470
+ throw new CommandExecutionError(`NotebookLM ${action} post-write verification failed: ${error?.message || error}`);
471
+ }
472
+ }
404
473
  export async function listNotebooklmSourcesViaRpc(page) {
405
474
  const state = await getNotebooklmPageState(page);
406
475
  if (state.kind !== 'notebook' || !state.notebookId)
@@ -434,7 +503,7 @@ export async function listNotebooklmNotesFromPage(page) {
434
503
  const state = await getNotebooklmPageState(page);
435
504
  if (state.kind !== 'notebook' || !state.notebookId)
436
505
  return [];
437
- const raw = await page.evaluate(`(() => {
506
+ const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => {
438
507
  return Array.from(document.querySelectorAll('artifact-library-note')).map((node) => {
439
508
  const titleNode = node.querySelector('.artifact-title');
440
509
  return {
@@ -442,7 +511,7 @@ export async function listNotebooklmNotesFromPage(page) {
442
511
  text: (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim(),
443
512
  };
444
513
  });
445
- })()`);
514
+ })()`));
446
515
  if (!Array.isArray(raw) || raw.length === 0)
447
516
  return [];
448
517
  return parseNotebooklmNoteListRawRows(raw, state.notebookId, state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`);
@@ -451,13 +520,13 @@ export async function readNotebooklmSummaryFromPage(page) {
451
520
  const state = await getNotebooklmPageState(page);
452
521
  if (state.kind !== 'notebook' || !state.notebookId)
453
522
  return null;
454
- const raw = await page.evaluate(`(() => {
523
+ const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => {
455
524
  const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
456
525
  const title = normalize(document.querySelector('.notebook-title, h1, [data-testid="notebook-title"]')?.textContent || document.title || '');
457
526
  const summaryNode = document.querySelector('.notebook-summary, .summary-content, [class*="summary"]');
458
527
  const summary = normalize(summaryNode?.textContent || '');
459
528
  return { title, summary };
460
- })()`);
529
+ })()`));
461
530
  return parseNotebooklmSummaryRawRow(raw, state.notebookId, state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`);
462
531
  }
463
532
  export async function getNotebooklmSummaryViaRpc(page) {
@@ -499,7 +568,7 @@ export async function readNotebooklmVisibleNoteFromPage(page) {
499
568
  const state = await getNotebooklmPageState(page);
500
569
  if (state.kind !== 'notebook' || !state.notebookId)
501
570
  return null;
502
- const raw = await page.evaluate(`(() => {
571
+ const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => {
503
572
  const normalizeText = (value) => (value || '').replace(/\\u00a0/g, ' ').replace(/\\r\\n/g, '\\n').trim();
504
573
  const titleNode = document.querySelector('.note-header__editable-title');
505
574
  const title = titleNode instanceof HTMLInputElement || titleNode instanceof HTMLTextAreaElement
@@ -516,7 +585,7 @@ export async function readNotebooklmVisibleNoteFromPage(page) {
516
585
  title: normalizeText(title),
517
586
  content: normalizeText(content),
518
587
  };
519
- })()`);
588
+ })()`));
520
589
  return parseNotebooklmVisibleNoteRawRow(raw, state.notebookId, state.url || `https://${NOTEBOOKLM_DOMAIN}/notebook/${state.notebookId}`);
521
590
  }
522
591
  export async function ensureNotebooklmHome(page) {
@@ -526,11 +595,18 @@ export async function ensureNotebooklmHome(page) {
526
595
  const currentKind = currentUrl ? classifyNotebooklmPage(currentUrl) : 'unknown';
527
596
  if (currentKind === 'home')
528
597
  return;
529
- await page.goto(NOTEBOOKLM_HOME_URL);
530
- await page.wait(2);
598
+ const authuser = getNotebooklmAuthuser();
599
+ const target = authuser ? `${NOTEBOOKLM_HOME_URL}?authuser=${encodeURIComponent(authuser)}` : NOTEBOOKLM_HOME_URL;
600
+ try {
601
+ await page.goto(target);
602
+ await page.wait(2);
603
+ }
604
+ catch (error) {
605
+ throw new CommandExecutionError(`Failed to open NotebookLM home: ${error?.message || error}`);
606
+ }
531
607
  }
532
608
  export async function getNotebooklmPageState(page) {
533
- const raw = await page.evaluate(`(() => {
609
+ const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => {
534
610
  const url = window.location.href;
535
611
  const title = document.title || '';
536
612
  const hostname = window.location.hostname || '';
@@ -557,7 +633,7 @@ export async function getNotebooklmPageState(page) {
557
633
  .reduce((count, href, index, list) => list.indexOf(href) === index ? count + 1 : count, 0);
558
634
 
559
635
  return { url, title, hostname, kind, notebookId, loginRequired, notebookCount, path };
560
- })()`);
636
+ })()`));
561
637
  const state = {
562
638
  url: String(raw?.url ?? ''),
563
639
  title: normalizeNotebooklmTitle(raw?.title, 'NotebookLM'),
@@ -582,7 +658,7 @@ export async function getNotebooklmPageState(page) {
582
658
  return state;
583
659
  }
584
660
  export async function readCurrentNotebooklm(page) {
585
- const raw = await page.evaluate(`(() => {
661
+ const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => {
586
662
  const url = window.location.href;
587
663
  const match = url.match(/\\/notebook\\/([^/?#]+)/);
588
664
  if (!match) return null;
@@ -595,7 +671,7 @@ export async function readCurrentNotebooklm(page) {
595
671
  url,
596
672
  source: 'current-page',
597
673
  };
598
- })()`);
674
+ })()`));
599
675
  if (!raw)
600
676
  return null;
601
677
  return {
@@ -608,7 +684,7 @@ export async function readCurrentNotebooklm(page) {
608
684
  };
609
685
  }
610
686
  export async function listNotebooklmLinks(page) {
611
- const raw = await page.evaluate(`(() => {
687
+ const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => {
612
688
  const rows = [];
613
689
  const seen = new Set();
614
690
 
@@ -656,7 +732,7 @@ export async function listNotebooklmLinks(page) {
656
732
  }
657
733
 
658
734
  return rows;
659
- })()`);
735
+ })()`));
660
736
  if (!Array.isArray(raw))
661
737
  return [];
662
738
  return raw
@@ -671,7 +747,7 @@ export async function listNotebooklmLinks(page) {
671
747
  .filter((row) => row.id && row.url);
672
748
  }
673
749
  export async function listNotebooklmSourcesFromPage(page) {
674
- const raw = await page.evaluate(`(() => {
750
+ const raw = unwrapNotebooklmEvaluateResult(await page.evaluate(`(() => {
675
751
  const notebookMatch = window.location.href.match(/\\/notebook\\/([^/?#]+)/);
676
752
  const notebookId = notebookMatch ? notebookMatch[1] : '';
677
753
  if (!notebookId) return [];
@@ -721,7 +797,7 @@ export async function listNotebooklmSourcesFromPage(page) {
721
797
  });
722
798
  }
723
799
  return rows;
724
- })()`);
800
+ })()`));
725
801
  if (!Array.isArray(raw))
726
802
  return [];
727
803
  return raw.filter((row) => row.id && row.title);
@@ -1,6 +1,40 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildNotebooklmRpcBody, classifyNotebooklmPage, extractNotebooklmHistoryPreview, extractNotebooklmRpcResult, getNotebooklmPageState, normalizeNotebooklmTitle, parseNotebooklmHistoryThreadIdsResult, parseNotebooklmIdFromUrl, parseNotebooklmListResult, parseNotebooklmNoteListRawRows, parseNotebooklmNotebookDetailResult, parseNotebooklmSourceFulltextResult, parseNotebooklmSourceGuideResult, parseNotebooklmSourceListResult, } from './utils.js';
2
+ import { buildNotebooklmRpcBody, classifyNotebooklmPage, extractNotebooklmHistoryPreview, extractNotebooklmRpcResult, getNotebooklmPageState, isPlainObject, normalizeNotebooklmTitle, parseNotebooklmHistoryThreadIdsResult, parseNotebooklmIdFromUrl, parseNotebooklmListResult, parseNotebooklmNoteListRawRows, parseNotebooklmNotebookDetailResult, parseNotebooklmNotebookTarget, parseNotebooklmSourceFulltextResult, parseNotebooklmSourceGuideResult, parseNotebooklmSourceListResult, } from './utils.js';
3
+ import { CliError } from '@jackwener/opencli/errors';
3
4
  describe('notebooklm utils', () => {
5
+ it('isPlainObject distinguishes objects from arrays / null / primitives', () => {
6
+ expect(isPlainObject({})).toBe(true);
7
+ expect(isPlainObject({ a: 1 })).toBe(true);
8
+ expect(isPlainObject([])).toBe(false);
9
+ expect(isPlainObject(null)).toBe(false);
10
+ expect(isPlainObject('x')).toBe(false);
11
+ expect(isPlainObject(0)).toBe(false);
12
+ });
13
+ it('parseNotebooklmNotebookTarget accepts canonical uuid input', () => {
14
+ const id = '17e2b882-aaaa-bbbb-cccc-abcdef012345';
15
+ expect(parseNotebooklmNotebookTarget(id)).toBe(id);
16
+ });
17
+ it('parseNotebooklmNotebookTarget accepts a notebook url with uuid', () => {
18
+ const id = '17e2b882-aaaa-bbbb-cccc-abcdef012345';
19
+ expect(parseNotebooklmNotebookTarget(`https://notebooklm.google.com/notebook/${id}?pli=1`)).toBe(id);
20
+ });
21
+ it('parseNotebooklmNotebookTarget rejects non-uuid bare ids', () => {
22
+ expect(() => parseNotebooklmNotebookTarget('nb-demo')).toThrow(CliError);
23
+ });
24
+ it('parseNotebooklmNotebookTarget rejects malformed notebook urls', () => {
25
+ expect(() => parseNotebooklmNotebookTarget('https://notebooklm.google.com/notebook/not-a-uuid')).toThrow(CliError);
26
+ });
27
+ it('parseNotebooklmNotebookTarget rejects off-domain or non-canonical notebook urls', () => {
28
+ const id = '17e2b882-aaaa-bbbb-cccc-abcdef012345';
29
+ expect(() => parseNotebooklmNotebookTarget(`https://evil.test/notebook/${id}`)).toThrow(CliError);
30
+ expect(() => parseNotebooklmNotebookTarget(`http://notebooklm.google.com/notebook/${id}`)).toThrow(CliError);
31
+ expect(() => parseNotebooklmNotebookTarget(`https://notebooklm.google.com:444/notebook/${id}`)).toThrow(CliError);
32
+ expect(() => parseNotebooklmNotebookTarget(`https://user:notsecret@notebooklm.google.com/notebook/${id}`)).toThrow(CliError);
33
+ });
34
+ it('parseNotebooklmNotebookTarget rejects empty input', () => {
35
+ expect(() => parseNotebooklmNotebookTarget('')).toThrow(CliError);
36
+ expect(() => parseNotebooklmNotebookTarget(' ')).toThrow(CliError);
37
+ });
4
38
  it('parses notebook id from a notebook url', () => {
5
39
  expect(parseNotebooklmIdFromUrl('https://notebooklm.google.com/notebook/abc-123')).toBe('abc-123');
6
40
  });
@@ -387,4 +421,29 @@ describe('notebooklm utils', () => {
387
421
  notebookCount: 0,
388
422
  });
389
423
  });
424
+ it('reads page state through Browser Bridge evaluate envelopes', async () => {
425
+ const page = {
426
+ evaluate: async () => ({
427
+ session: 'site:notebooklm:abc',
428
+ data: {
429
+ url: 'https://notebooklm.google.com/notebook/nb-demo',
430
+ title: 'Demo Notebook - NotebookLM',
431
+ hostname: 'notebooklm.google.com',
432
+ kind: 'notebook',
433
+ notebookId: 'nb-demo',
434
+ loginRequired: false,
435
+ notebookCount: 0,
436
+ },
437
+ }),
438
+ };
439
+ await expect(getNotebooklmPageState(page)).resolves.toEqual({
440
+ url: 'https://notebooklm.google.com/notebook/nb-demo',
441
+ title: 'Demo Notebook - NotebookLM',
442
+ hostname: 'notebooklm.google.com',
443
+ kind: 'notebook',
444
+ notebookId: 'nb-demo',
445
+ loginRequired: false,
446
+ notebookCount: 0,
447
+ });
448
+ });
390
449
  });
@@ -0,0 +1,103 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
+ import { callNotebooklmRpc } from './rpc.js';
5
+ import { buildNotebooklmNotebookUrl, ensureNotebooklmHome, parseNotebooklmNotebookTarget, requireNotebooklmExecute, requireNotebooklmSession } from './utils.js';
6
+
7
+ const NOTEBOOKLM_CREATE_NOTE_RPC_ID = 'CYK0Xb';
8
+ const NOTEBOOKLM_MUTATE_NOTE_RPC_ID = 'cYAfTb';
9
+ const MAX_TITLE_LEN = 200;
10
+ const MAX_CONTENT_LEN = 1_000_000;
11
+ const NOTE_UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
12
+
13
+ export function parseNoteTitle(value) {
14
+ const title = String(value ?? '').trim();
15
+ if (!title) throw new ArgumentError('--title is required');
16
+ if (title.length > MAX_TITLE_LEN) {
17
+ throw new ArgumentError(`--title must be at most ${MAX_TITLE_LEN} characters (got ${title.length})`);
18
+ }
19
+ return title;
20
+ }
21
+
22
+ export function parseNoteContent(value) {
23
+ const content = String(value ?? '');
24
+ if (!content) throw new ArgumentError('--content is required');
25
+ if (content.length > MAX_CONTENT_LEN) {
26
+ throw new ArgumentError(`--content exceeds ${MAX_CONTENT_LEN} characters; split into smaller notes.`);
27
+ }
28
+ return content;
29
+ }
30
+
31
+ export function buildCreateNoteShellArgs(projectId) {
32
+ return [projectId, '', [1], null, 'New Note', null, [2]];
33
+ }
34
+
35
+ export function buildMutateNoteArgs(projectId, noteId, content, title) {
36
+ return [projectId, noteId, [[[content, title, [], 0]]], [2]];
37
+ }
38
+
39
+ function toExcludedUuidSet(excludedIds) {
40
+ return new Set(excludedIds.map((id) => String(id ?? '').toLowerCase()).filter(Boolean));
41
+ }
42
+
43
+ export function parseNoteIdFromResult(result, excludedIds = []) {
44
+ const excluded = toExcludedUuidSet(excludedIds);
45
+ if (typeof result === 'string') return NOTE_UUID_RE.test(result) && !excluded.has(result.toLowerCase()) ? result : '';
46
+ const stack = [result];
47
+ while (stack.length) {
48
+ const node = stack.shift();
49
+ if (typeof node === 'string') {
50
+ if (NOTE_UUID_RE.test(node) && !excluded.has(node.toLowerCase())) return node;
51
+ continue;
52
+ }
53
+ if (Array.isArray(node)) for (const child of node) stack.push(child);
54
+ else if (node && typeof node === 'object') for (const v of Object.values(node)) stack.push(v);
55
+ }
56
+ return '';
57
+ }
58
+
59
+ cli({
60
+ site: NOTEBOOKLM_SITE,
61
+ name: 'write-note',
62
+ access: 'write',
63
+ description: 'Create a Studio note in an existing NotebookLM notebook with the given title and Markdown content',
64
+ domain: NOTEBOOKLM_DOMAIN,
65
+ strategy: Strategy.COOKIE,
66
+ browser: true,
67
+ navigateBefore: false,
68
+ args: [
69
+ { name: 'notebook', positional: true, required: true, help: 'Notebook id from `notebooklm list` or full notebook URL' },
70
+ { name: 'title', required: true, help: 'Note title (1-200 chars)' },
71
+ { name: 'content', required: true, help: 'Note body as Markdown' },
72
+ { name: 'execute', type: 'boolean', help: 'Actually create the remote NotebookLM note' },
73
+ ],
74
+ columns: ['notebook_id', 'note_id', 'title', 'notebook_url'],
75
+ func: async (page, kwargs) => {
76
+ const notebookId = parseNotebooklmNotebookTarget(String(kwargs.notebook ?? ''));
77
+ const title = parseNoteTitle(kwargs.title);
78
+ const content = parseNoteContent(kwargs.content);
79
+ requireNotebooklmExecute(kwargs.execute, 'create a NotebookLM note');
80
+ await ensureNotebooklmHome(page);
81
+ await requireNotebooklmSession(page);
82
+ const shellRpc = await callNotebooklmRpc(page, NOTEBOOKLM_CREATE_NOTE_RPC_ID, buildCreateNoteShellArgs(notebookId));
83
+ const noteId = parseNoteIdFromResult(shellRpc.result, [notebookId]);
84
+ if (!noteId) {
85
+ throw new CommandExecutionError('NotebookLM CreateNote RPC returned no note id');
86
+ }
87
+ await callNotebooklmRpc(page, NOTEBOOKLM_MUTATE_NOTE_RPC_ID, buildMutateNoteArgs(notebookId, noteId, content, title));
88
+ return [{
89
+ notebook_id: notebookId,
90
+ note_id: noteId,
91
+ title,
92
+ notebook_url: buildNotebooklmNotebookUrl(notebookId),
93
+ }];
94
+ },
95
+ });
96
+
97
+ export const __test__ = {
98
+ parseNoteTitle,
99
+ parseNoteContent,
100
+ buildCreateNoteShellArgs,
101
+ buildMutateNoteArgs,
102
+ parseNoteIdFromResult,
103
+ };
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import { __test__ } from './write-note.js';
5
+
6
+ const { parseNoteTitle, parseNoteContent, buildCreateNoteShellArgs, buildMutateNoteArgs, parseNoteIdFromResult } = __test__;
7
+
8
+ describe('notebooklm write-note', () => {
9
+ it('parseNoteTitle accepts 1-200 char titles', () => {
10
+ expect(parseNoteTitle('Note A')).toBe('Note A');
11
+ expect(parseNoteTitle(' spaced ')).toBe('spaced');
12
+ expect(parseNoteTitle('x'.repeat(200))).toHaveLength(200);
13
+ });
14
+
15
+ it('parseNoteTitle rejects empty / too-long titles', () => {
16
+ expect(() => parseNoteTitle('')).toThrow(ArgumentError);
17
+ expect(() => parseNoteTitle(' ')).toThrow(ArgumentError);
18
+ expect(() => parseNoteTitle('x'.repeat(201))).toThrow(ArgumentError);
19
+ });
20
+
21
+ it('parseNoteContent accepts non-empty content', () => {
22
+ expect(parseNoteContent('# heading\n\nbody')).toBe('# heading\n\nbody');
23
+ });
24
+
25
+ it('parseNoteContent rejects empty content', () => {
26
+ expect(() => parseNoteContent('')).toThrow(ArgumentError);
27
+ expect(() => parseNoteContent(undefined)).toThrow(ArgumentError);
28
+ });
29
+
30
+ it('buildCreateNoteShellArgs matches the HAR-verified wire format', () => {
31
+ expect(buildCreateNoteShellArgs('nb-123')).toEqual([
32
+ 'nb-123', '', [1], null, 'New Note', null, [2],
33
+ ]);
34
+ });
35
+
36
+ it('buildMutateNoteArgs puts content before title in the inner tuple', () => {
37
+ expect(buildMutateNoteArgs('nb-123', 'note-7', 'body content', 'title-x')).toEqual([
38
+ 'nb-123', 'note-7', [[['body content', 'title-x', [], 0]]], [2],
39
+ ]);
40
+ });
41
+
42
+ it('parseNoteIdFromResult walks the tree for the note-id UUID', () => {
43
+ const id = '0312fc89-075e-4b3a-810d-141fc8d5af6d';
44
+ expect(parseNoteIdFromResult([[[ [id] ]]])).toBe(id);
45
+ expect(parseNoteIdFromResult({ shell: { noteId: id } })).toBe(id);
46
+ });
47
+
48
+ it('parseNoteIdFromResult ignores non-UUID strings', () => {
49
+ expect(parseNoteIdFromResult([ 'project-id', 'not-a-uuid' ])).toBe('');
50
+ expect(parseNoteIdFromResult(null)).toBe('');
51
+ expect(parseNoteIdFromResult([])).toBe('');
52
+ });
53
+
54
+ it('parseNoteIdFromResult skips the input notebook id before selecting the created note id', () => {
55
+ const notebookId = '17e2b882-6a01-4c6c-9262-0738dfa2abee';
56
+ const noteId = '0312fc89-075e-4b3a-810d-141fc8d5af6d';
57
+ expect(parseNoteIdFromResult([notebookId, [[noteId]]], [notebookId])).toBe(noteId);
58
+ });
59
+
60
+ it('refuses to create a remote note without --execute', async () => {
61
+ const command = getRegistry().get('notebooklm/write-note');
62
+ const page = { goto: vi.fn() };
63
+ await expect(command.func(page, {
64
+ notebook: '17e2b882-6a01-4c6c-9262-0738dfa2abee',
65
+ title: 'Draft note',
66
+ content: 'body',
67
+ })).rejects.toThrow(ArgumentError);
68
+ expect(page.goto).not.toHaveBeenCalled();
69
+ });
70
+ });
@@ -1,4 +1,21 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { pixivFetch } from './utils.js';
4
+
5
+ function requireIllustBody(body, id) {
6
+ if (!body || Array.isArray(body) || typeof body !== 'object') {
7
+ throw new CommandExecutionError(`Pixiv illustration ${id} returned malformed detail payload`);
8
+ }
9
+ const illustId = String(body.illustId ?? '').trim();
10
+ const title = String(body.illustTitle ?? '').trim();
11
+ const userName = String(body.userName ?? '').trim();
12
+ const userId = String(body.userId ?? '').trim();
13
+ if (!/^\d+$/.test(illustId) || illustId !== id || !title || !userName || !/^\d+$/.test(userId)) {
14
+ throw new CommandExecutionError(`Pixiv illustration ${id} returned malformed detail payload`);
15
+ }
16
+ return { ...body, illustId, illustTitle: title, userName, userId };
17
+ }
18
+
2
19
  cli({
3
20
  site: 'pixiv',
4
21
  name: 'detail',
@@ -6,7 +23,6 @@ cli({
6
23
  description: 'View illustration details (tags, stats, URLs)',
7
24
  domain: 'www.pixiv.net',
8
25
  strategy: Strategy.COOKIE,
9
- browser: true,
10
26
  args: [
11
27
  { name: 'id', required: true, positional: true, help: 'Illustration ID' },
12
28
  ],
@@ -23,37 +39,28 @@ cli({
23
39
  'created',
24
40
  'url',
25
41
  ],
26
- pipeline: [
27
- { navigate: 'https://www.pixiv.net' },
28
- { evaluate: `(async () => {
29
- const id = \${{ args.id | json }};
30
- const res = await fetch(
31
- 'https://www.pixiv.net/ajax/illust/' + id,
32
- { credentials: 'include' }
33
- );
34
- if (!res.ok) {
35
- if (res.status === 401 || res.status === 403) throw new Error('Authentication required — please log in to Pixiv in Chrome');
36
- if (res.status === 404) throw new Error('Illustration not found: ' + id);
37
- throw new Error('Pixiv request failed (HTTP ' + res.status + ')');
38
- }
39
- const data = await res.json();
40
- const b = data?.body;
41
- if (!b) throw new Error('Illustration not found');
42
- return [{
43
- illust_id: b.illustId,
44
- title: b.illustTitle,
45
- author: b.userName,
46
- user_id: b.userId,
47
- type: b.illustType === 0 ? 'illust' : b.illustType === 1 ? 'manga' : b.illustType === 2 ? 'ugoira' : String(b.illustType),
48
- pages: b.pageCount,
49
- bookmarks: b.bookmarkCount,
50
- likes: b.likeCount,
51
- views: b.viewCount,
52
- tags: (b.tags?.tags || []).map(t => t.tag).join(', '),
53
- created: b.createDate?.split('T')[0] || '',
54
- url: 'https://www.pixiv.net/artworks/' + b.illustId
55
- }];
56
- })()
57
- ` },
58
- ],
42
+ func: async (page, kwargs) => {
43
+ const id = String(kwargs.id ?? '');
44
+ if (!/^\d+$/.test(id)) {
45
+ throw new ArgumentError(`Invalid illustration ID: ${id}`, 'Example: opencli pixiv detail 123456');
46
+ }
47
+ const body = await pixivFetch(page, `/ajax/illust/${id}`, {
48
+ notFoundMsg: `Illustration not found: ${id}`,
49
+ });
50
+ const b = requireIllustBody(body, id);
51
+ return [{
52
+ illust_id: b.illustId,
53
+ title: b.illustTitle,
54
+ author: b.userName,
55
+ user_id: b.userId,
56
+ type: b.illustType === 0 ? 'illust' : b.illustType === 1 ? 'manga' : b.illustType === 2 ? 'ugoira' : String(b.illustType),
57
+ pages: b.pageCount,
58
+ bookmarks: b.bookmarkCount,
59
+ likes: b.likeCount,
60
+ views: b.viewCount,
61
+ tags: (b.tags?.tags || []).map(t => t.tag).join(', '),
62
+ created: b.createDate?.split('T')[0] || '',
63
+ url: `https://www.pixiv.net/artworks/${b.illustId}`,
64
+ }];
65
+ },
59
66
  });