@jackwener/opencli 1.5.8 → 1.6.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.
Files changed (220) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +35 -1
  3. package/README.zh-CN.md +17 -1
  4. package/SKILL.md +31 -851
  5. package/autoresearch/baseline-browse.txt +1 -0
  6. package/autoresearch/baseline-skill.txt +1 -0
  7. package/autoresearch/browse-tasks.json +688 -0
  8. package/autoresearch/eval-browse.ts +185 -0
  9. package/autoresearch/eval-skill.ts +248 -0
  10. package/autoresearch/run-browse.sh +9 -0
  11. package/autoresearch/run-skill.sh +9 -0
  12. package/dist/browser/base-page.d.ts +48 -0
  13. package/dist/browser/base-page.js +160 -0
  14. package/dist/browser/cdp.js +4 -106
  15. package/dist/browser/daemon-client.d.ts +20 -7
  16. package/dist/browser/daemon-client.js +39 -39
  17. package/dist/browser/daemon-client.test.js +77 -0
  18. package/dist/browser/discover.d.ts +1 -4
  19. package/dist/browser/discover.js +9 -23
  20. package/dist/browser/errors.d.ts +4 -0
  21. package/dist/browser/errors.js +20 -0
  22. package/dist/browser/index.d.ts +1 -1
  23. package/dist/browser/index.js +1 -1
  24. package/dist/browser/page.d.ts +10 -35
  25. package/dist/browser/page.js +55 -187
  26. package/dist/browser/tabs.js +5 -5
  27. package/dist/browser.test.js +15 -15
  28. package/dist/cli-manifest.json +294 -22
  29. package/dist/cli.js +392 -0
  30. package/dist/clis/amazon/bestsellers.d.ts +21 -0
  31. package/dist/clis/amazon/bestsellers.js +130 -0
  32. package/dist/clis/amazon/bestsellers.test.js +20 -0
  33. package/dist/clis/amazon/discussion.d.ts +20 -0
  34. package/dist/clis/amazon/discussion.js +91 -0
  35. package/dist/clis/amazon/discussion.test.d.ts +1 -0
  36. package/dist/clis/amazon/discussion.test.js +36 -0
  37. package/dist/clis/amazon/offer.d.ts +23 -0
  38. package/dist/clis/amazon/offer.js +140 -0
  39. package/dist/clis/amazon/offer.test.d.ts +1 -0
  40. package/dist/clis/amazon/offer.test.js +29 -0
  41. package/dist/clis/amazon/product.d.ts +18 -0
  42. package/dist/clis/amazon/product.js +92 -0
  43. package/dist/clis/amazon/product.test.d.ts +1 -0
  44. package/dist/clis/amazon/product.test.js +24 -0
  45. package/dist/clis/amazon/search.d.ts +18 -0
  46. package/dist/clis/amazon/search.js +87 -0
  47. package/dist/clis/amazon/search.test.d.ts +1 -0
  48. package/dist/clis/amazon/search.test.js +22 -0
  49. package/dist/clis/amazon/shared.d.ts +64 -0
  50. package/dist/clis/amazon/shared.js +255 -0
  51. package/dist/clis/amazon/shared.test.d.ts +1 -0
  52. package/dist/clis/amazon/shared.test.js +33 -0
  53. package/dist/clis/gemini/ask.d.ts +1 -0
  54. package/dist/clis/gemini/ask.js +40 -0
  55. package/dist/clis/gemini/image.d.ts +1 -0
  56. package/dist/clis/gemini/image.js +105 -0
  57. package/dist/clis/gemini/new.d.ts +1 -0
  58. package/dist/clis/gemini/new.js +20 -0
  59. package/dist/clis/gemini/utils.d.ts +34 -0
  60. package/dist/clis/gemini/utils.js +463 -0
  61. package/dist/clis/gemini/utils.test.d.ts +1 -0
  62. package/dist/clis/gemini/utils.test.js +31 -0
  63. package/dist/clis/notebooklm/compat.test.d.ts +1 -1
  64. package/dist/clis/notebooklm/compat.test.js +3 -3
  65. package/dist/clis/notebooklm/current.js +2 -3
  66. package/dist/clis/notebooklm/get.js +2 -3
  67. package/dist/clis/notebooklm/history.js +2 -3
  68. package/dist/clis/notebooklm/note-list.js +2 -3
  69. package/dist/clis/notebooklm/notes-get.js +2 -3
  70. package/dist/clis/notebooklm/open.d.ts +1 -0
  71. package/dist/clis/notebooklm/open.js +41 -0
  72. package/dist/clis/notebooklm/open.test.d.ts +1 -0
  73. package/dist/clis/notebooklm/open.test.js +63 -0
  74. package/dist/clis/notebooklm/source-fulltext.js +2 -3
  75. package/dist/clis/notebooklm/source-get.js +2 -3
  76. package/dist/clis/notebooklm/source-guide.js +2 -3
  77. package/dist/clis/notebooklm/source-list.js +2 -3
  78. package/dist/clis/notebooklm/status.js +1 -2
  79. package/dist/clis/notebooklm/summary.js +2 -3
  80. package/dist/clis/notebooklm/utils.d.ts +2 -1
  81. package/dist/clis/notebooklm/utils.js +20 -21
  82. package/dist/clis/twitter/article.js +28 -1
  83. package/dist/clis/xiaohongshu/creator-note-detail.test.js +11 -11
  84. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +6 -6
  85. package/dist/clis/xiaohongshu/creator-notes.test.js +22 -22
  86. package/dist/clis/xiaohongshu/note.js +11 -0
  87. package/dist/clis/xiaohongshu/note.test.js +49 -0
  88. package/dist/commanderAdapter.js +7 -4
  89. package/dist/commanderAdapter.test.js +76 -0
  90. package/dist/commands/daemon.js +8 -47
  91. package/dist/commands/daemon.test.js +45 -70
  92. package/dist/discovery.js +27 -0
  93. package/dist/doctor.d.ts +1 -2
  94. package/dist/doctor.js +7 -8
  95. package/dist/explore.js +1 -1
  96. package/dist/output.js +28 -0
  97. package/dist/output.test.js +15 -0
  98. package/dist/pipeline/executor.js +2 -7
  99. package/dist/pipeline/steps/browser.js +1 -1
  100. package/dist/pipeline/template.js +25 -3
  101. package/dist/record.d.ts +50 -0
  102. package/dist/record.js +298 -57
  103. package/dist/record.test.d.ts +1 -0
  104. package/dist/record.test.js +293 -0
  105. package/dist/registry.d.ts +2 -0
  106. package/dist/registry.js +1 -0
  107. package/dist/registry.test.js +10 -0
  108. package/dist/runtime.js +3 -3
  109. package/dist/snapshotFormatter.d.ts +1 -1
  110. package/dist/snapshotFormatter.js +4 -4
  111. package/dist/snapshotFormatter.test.d.ts +1 -1
  112. package/dist/snapshotFormatter.test.js +2 -2
  113. package/dist/types.d.ts +11 -1
  114. package/dist/types.js +1 -1
  115. package/docs/.vitepress/config.mts +2 -0
  116. package/docs/adapters/browser/amazon.md +53 -0
  117. package/docs/adapters/browser/gemini.md +72 -0
  118. package/docs/adapters/browser/notebooklm.md +5 -5
  119. package/docs/adapters/index.md +3 -1
  120. package/docs/guide/getting-started.md +21 -0
  121. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  122. package/docs/zh/guide/getting-started.md +21 -0
  123. package/extension/package-lock.json +2 -2
  124. package/extension/src/background.test.ts +7 -163
  125. package/extension/src/background.ts +58 -161
  126. package/extension/src/cdp.ts +77 -124
  127. package/extension/src/protocol.ts +5 -5
  128. package/package.json +1 -1
  129. package/skills/opencli-explorer/SKILL.md +853 -0
  130. package/skills/opencli-oneshot/SKILL.md +222 -0
  131. package/skills/opencli-operate/SKILL.md +213 -0
  132. package/skills/opencli-usage/SKILL.md +152 -0
  133. package/skills/opencli-usage/browser.md +429 -0
  134. package/skills/opencli-usage/desktop.md +118 -0
  135. package/skills/opencli-usage/plugins.md +82 -0
  136. package/skills/opencli-usage/public-api.md +149 -0
  137. package/src/browser/base-page.ts +197 -0
  138. package/src/browser/cdp.ts +7 -131
  139. package/src/browser/daemon-client.test.ts +103 -0
  140. package/src/browser/daemon-client.ts +55 -43
  141. package/src/browser/discover.ts +9 -21
  142. package/src/browser/errors.ts +22 -0
  143. package/src/browser/index.ts +1 -1
  144. package/src/browser/page.ts +57 -209
  145. package/src/browser/tabs.ts +5 -5
  146. package/src/browser.test.ts +15 -15
  147. package/src/cli.ts +392 -0
  148. package/src/clis/amazon/bestsellers.test.ts +22 -0
  149. package/src/clis/amazon/bestsellers.ts +180 -0
  150. package/src/clis/amazon/discussion.test.ts +38 -0
  151. package/src/clis/amazon/discussion.ts +131 -0
  152. package/src/clis/amazon/offer.test.ts +35 -0
  153. package/src/clis/amazon/offer.ts +185 -0
  154. package/src/clis/amazon/product.test.ts +26 -0
  155. package/src/clis/amazon/product.ts +131 -0
  156. package/src/clis/amazon/search.test.ts +24 -0
  157. package/src/clis/amazon/search.ts +128 -0
  158. package/src/clis/amazon/shared.test.ts +37 -0
  159. package/src/clis/amazon/shared.ts +316 -0
  160. package/src/clis/gemini/ask.ts +46 -0
  161. package/src/clis/gemini/image.ts +115 -0
  162. package/src/clis/gemini/new.ts +22 -0
  163. package/src/clis/gemini/utils.test.ts +36 -0
  164. package/src/clis/gemini/utils.ts +523 -0
  165. package/src/clis/notebooklm/compat.test.ts +3 -3
  166. package/src/clis/notebooklm/current.ts +2 -3
  167. package/src/clis/notebooklm/get.ts +1 -3
  168. package/src/clis/notebooklm/history.ts +1 -3
  169. package/src/clis/notebooklm/note-list.ts +1 -3
  170. package/src/clis/notebooklm/notes-get.ts +1 -3
  171. package/src/clis/notebooklm/open.test.ts +78 -0
  172. package/src/clis/notebooklm/open.ts +61 -0
  173. package/src/clis/notebooklm/source-fulltext.ts +1 -3
  174. package/src/clis/notebooklm/source-get.ts +1 -3
  175. package/src/clis/notebooklm/source-guide.ts +1 -3
  176. package/src/clis/notebooklm/source-list.ts +1 -3
  177. package/src/clis/notebooklm/status.ts +1 -2
  178. package/src/clis/notebooklm/summary.ts +1 -3
  179. package/src/clis/notebooklm/utils.ts +29 -20
  180. package/src/clis/twitter/article.ts +31 -1
  181. package/src/clis/xiaohongshu/creator-note-detail.test.ts +11 -11
  182. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +6 -6
  183. package/src/clis/xiaohongshu/creator-notes.test.ts +22 -22
  184. package/src/clis/xiaohongshu/note.test.ts +51 -0
  185. package/src/clis/xiaohongshu/note.ts +18 -0
  186. package/src/commanderAdapter.test.ts +109 -0
  187. package/src/commanderAdapter.ts +8 -4
  188. package/src/commands/daemon.test.ts +50 -84
  189. package/src/commands/daemon.ts +8 -56
  190. package/src/discovery.ts +22 -0
  191. package/src/doctor.ts +8 -9
  192. package/src/explore.ts +1 -1
  193. package/src/output.test.ts +17 -0
  194. package/src/output.ts +27 -0
  195. package/src/pipeline/executor.ts +2 -7
  196. package/src/pipeline/steps/browser.ts +1 -1
  197. package/src/pipeline/template.ts +27 -4
  198. package/src/record.test.ts +362 -0
  199. package/src/record.ts +341 -62
  200. package/src/registry.test.ts +12 -0
  201. package/src/registry.ts +3 -0
  202. package/src/runtime.ts +3 -3
  203. package/src/snapshotFormatter.test.ts +2 -2
  204. package/src/snapshotFormatter.ts +4 -4
  205. package/src/types.ts +11 -1
  206. package/.agents/skills/cross-project-adapter-migration/SKILL.md +0 -249
  207. package/.agents/workflows/cross-project-adapter-migration.md +0 -54
  208. package/dist/clis/notebooklm/bind-current.js +0 -29
  209. package/dist/clis/notebooklm/bind-current.test.d.ts +0 -1
  210. package/dist/clis/notebooklm/bind-current.test.js +0 -35
  211. package/dist/clis/notebooklm/binding.test.js +0 -44
  212. package/extension/dist/background.js +0 -819
  213. package/src/clis/notebooklm/bind-current.test.ts +0 -43
  214. package/src/clis/notebooklm/bind-current.ts +0 -36
  215. package/src/clis/notebooklm/binding.test.ts +0 -53
  216. /package/dist/browser/{mcp.d.ts → bridge.d.ts} +0 -0
  217. /package/dist/browser/{mcp.js → bridge.js} +0 -0
  218. /package/dist/{clis/notebooklm/bind-current.d.ts → browser/daemon-client.test.d.ts} +0 -0
  219. /package/dist/clis/{notebooklm/binding.test.d.ts → amazon/bestsellers.test.d.ts} +0 -0
  220. /package/src/browser/{mcp.ts → bridge.ts} +0 -0
@@ -0,0 +1,41 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError, EmptyResultError } from '../../errors.js';
3
+ import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
+ import { buildNotebooklmNotebookUrl, getNotebooklmPageState, parseNotebooklmNotebookTarget, readCurrentNotebooklm, requireNotebooklmSession, } from './utils.js';
5
+ cli({
6
+ site: NOTEBOOKLM_SITE,
7
+ name: 'open',
8
+ aliases: ['select'],
9
+ description: 'Open one NotebookLM notebook in the automation workspace by id or URL',
10
+ domain: NOTEBOOKLM_DOMAIN,
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ args: [
15
+ {
16
+ name: 'notebook',
17
+ positional: true,
18
+ required: true,
19
+ help: 'Notebook id from list output, or a full NotebookLM notebook URL',
20
+ },
21
+ ],
22
+ columns: ['id', 'title', 'url', 'source'],
23
+ func: async (page, kwargs) => {
24
+ const notebookId = parseNotebooklmNotebookTarget(String(kwargs.notebook ?? ''));
25
+ await page.goto(buildNotebooklmNotebookUrl(notebookId));
26
+ await page.wait(2);
27
+ await requireNotebooklmSession(page);
28
+ const state = await getNotebooklmPageState(page);
29
+ if (state.kind !== 'notebook') {
30
+ throw new CliError('NOTEBOOKLM_OPEN_FAILED', `NotebookLM notebook "${notebookId}" did not open in the automation workspace`, 'Run `opencli notebooklm list -f json` first and pass a valid notebook id.');
31
+ }
32
+ if (state.notebookId !== notebookId) {
33
+ console.warn(`[notebooklm open] expected notebook "${notebookId}" but page reports "${state.notebookId}"; continuing`);
34
+ }
35
+ const current = await readCurrentNotebooklm(page);
36
+ if (!current) {
37
+ throw new EmptyResultError('opencli notebooklm open', 'NotebookLM notebook metadata was not found after navigation.');
38
+ }
39
+ return [current];
40
+ },
41
+ });
@@ -0,0 +1 @@
1
+ import './open.js';
@@ -0,0 +1,63 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const { mockGetNotebooklmPageState, mockReadCurrentNotebooklm, mockRequireNotebooklmSession, } = vi.hoisted(() => ({
3
+ mockGetNotebooklmPageState: vi.fn(),
4
+ mockReadCurrentNotebooklm: vi.fn(),
5
+ mockRequireNotebooklmSession: vi.fn(),
6
+ }));
7
+ vi.mock('./utils.js', async () => {
8
+ const actual = await vi.importActual('./utils.js');
9
+ return {
10
+ ...actual,
11
+ getNotebooklmPageState: mockGetNotebooklmPageState,
12
+ readCurrentNotebooklm: mockReadCurrentNotebooklm,
13
+ requireNotebooklmSession: mockRequireNotebooklmSession,
14
+ };
15
+ });
16
+ import { getRegistry } from '../../registry.js';
17
+ import './open.js';
18
+ describe('notebooklm open', () => {
19
+ const command = getRegistry().get('notebooklm/open');
20
+ beforeEach(() => {
21
+ mockGetNotebooklmPageState.mockReset();
22
+ mockReadCurrentNotebooklm.mockReset();
23
+ mockRequireNotebooklmSession.mockReset();
24
+ mockRequireNotebooklmSession.mockResolvedValue(undefined);
25
+ mockGetNotebooklmPageState.mockResolvedValue({
26
+ url: 'https://notebooklm.google.com/notebook/nb-demo',
27
+ title: 'Browser Automation',
28
+ hostname: 'notebooklm.google.com',
29
+ kind: 'notebook',
30
+ notebookId: 'nb-demo',
31
+ loginRequired: false,
32
+ notebookCount: 1,
33
+ });
34
+ mockReadCurrentNotebooklm.mockResolvedValue({
35
+ id: 'nb-demo',
36
+ title: 'Browser Automation',
37
+ url: 'https://notebooklm.google.com/notebook/nb-demo',
38
+ source: 'current-page',
39
+ });
40
+ });
41
+ it('opens a notebook by id in the automation workspace', async () => {
42
+ const page = {
43
+ goto: vi.fn(async () => { }),
44
+ wait: vi.fn(async () => { }),
45
+ };
46
+ const result = await command.func(page, { notebook: 'nb-demo' });
47
+ expect(page.goto).toHaveBeenCalledWith('https://notebooklm.google.com/notebook/nb-demo');
48
+ expect(result).toEqual([{
49
+ id: 'nb-demo',
50
+ title: 'Browser Automation',
51
+ url: 'https://notebooklm.google.com/notebook/nb-demo',
52
+ source: 'current-page',
53
+ }]);
54
+ });
55
+ it('accepts a full notebook url', async () => {
56
+ const page = {
57
+ goto: vi.fn(async () => { }),
58
+ wait: vi.fn(async () => { }),
59
+ };
60
+ await command.func(page, { notebook: 'https://notebooklm.google.com/notebook/nb-demo?pli=1' });
61
+ expect(page.goto).toHaveBeenCalledWith('https://notebooklm.google.com/notebook/nb-demo');
62
+ });
63
+ });
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { EmptyResultError } from '../../errors.js';
3
3
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
- import { ensureNotebooklmNotebookBinding, findNotebooklmSourceRow, getNotebooklmPageState, getNotebooklmSourceFulltextViaRpc, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
4
+ import { findNotebooklmSourceRow, getNotebooklmPageState, getNotebooklmSourceFulltextViaRpc, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
5
5
  cli({
6
6
  site: NOTEBOOKLM_SITE,
7
7
  name: 'source-fulltext',
@@ -20,11 +20,10 @@ cli({
20
20
  ],
21
21
  columns: ['title', 'kind', 'char_count', 'url', 'source'],
22
22
  func: async (page, kwargs) => {
23
- await ensureNotebooklmNotebookBinding(page);
24
23
  await requireNotebooklmSession(page);
25
24
  const state = await getNotebooklmPageState(page);
26
25
  if (state.kind !== 'notebook') {
27
- throw new EmptyResultError('opencli notebooklm source-fulltext', 'Open a specific NotebookLM notebook tab first, then retry.');
26
+ throw new EmptyResultError('opencli notebooklm source-fulltext', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
28
27
  }
29
28
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
30
29
  const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { EmptyResultError } from '../../errors.js';
3
3
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
- import { ensureNotebooklmNotebookBinding, findNotebooklmSourceRow, getNotebooklmPageState, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
4
+ import { findNotebooklmSourceRow, getNotebooklmPageState, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
5
5
  cli({
6
6
  site: NOTEBOOKLM_SITE,
7
7
  name: 'source-get',
@@ -20,11 +20,10 @@ cli({
20
20
  ],
21
21
  columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'],
22
22
  func: async (page, kwargs) => {
23
- await ensureNotebooklmNotebookBinding(page);
24
23
  await requireNotebooklmSession(page);
25
24
  const state = await getNotebooklmPageState(page);
26
25
  if (state.kind !== 'notebook') {
27
- throw new EmptyResultError('opencli notebooklm source-get', 'Open a specific NotebookLM notebook tab first, then retry.');
26
+ throw new EmptyResultError('opencli notebooklm source-get', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
28
27
  }
29
28
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
30
29
  const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { EmptyResultError } from '../../errors.js';
3
3
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
- import { ensureNotebooklmNotebookBinding, findNotebooklmSourceRow, getNotebooklmPageState, getNotebooklmSourceGuideViaRpc, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
4
+ import { findNotebooklmSourceRow, getNotebooklmPageState, getNotebooklmSourceGuideViaRpc, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
5
5
  cli({
6
6
  site: NOTEBOOKLM_SITE,
7
7
  name: 'source-guide',
@@ -20,11 +20,10 @@ cli({
20
20
  ],
21
21
  columns: ['source_id', 'notebook_id', 'title', 'type', 'summary', 'keywords', 'source'],
22
22
  func: async (page, kwargs) => {
23
- await ensureNotebooklmNotebookBinding(page);
24
23
  await requireNotebooklmSession(page);
25
24
  const state = await getNotebooklmPageState(page);
26
25
  if (state.kind !== 'notebook') {
27
- throw new EmptyResultError('opencli notebooklm source-guide', 'Open a specific NotebookLM notebook tab first, then retry.');
26
+ throw new EmptyResultError('opencli notebooklm source-guide', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
28
27
  }
29
28
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
30
29
  const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { EmptyResultError } from '../../errors.js';
3
3
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
- import { ensureNotebooklmNotebookBinding, getNotebooklmPageState, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
4
+ import { getNotebooklmPageState, listNotebooklmSourcesFromPage, listNotebooklmSourcesViaRpc, requireNotebooklmSession, } from './utils.js';
5
5
  cli({
6
6
  site: NOTEBOOKLM_SITE,
7
7
  name: 'source-list',
@@ -13,11 +13,10 @@ cli({
13
13
  args: [],
14
14
  columns: ['title', 'id', 'type', 'size', 'created_at', 'updated_at', 'url', 'source'],
15
15
  func: async (page) => {
16
- await ensureNotebooklmNotebookBinding(page);
17
16
  await requireNotebooklmSession(page);
18
17
  const state = await getNotebooklmPageState(page);
19
18
  if (state.kind !== 'notebook') {
20
- throw new EmptyResultError('opencli notebooklm source-list', 'Open a specific NotebookLM notebook tab first, then retry.');
19
+ throw new EmptyResultError('opencli notebooklm source-list', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
21
20
  }
22
21
  const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
23
22
  if (rpcRows.length > 0)
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, NOTEBOOKLM_SITE } from './shared.js';
3
- import { ensureNotebooklmNotebookBinding, getNotebooklmPageState } from './utils.js';
3
+ import { getNotebooklmPageState } from './utils.js';
4
4
  cli({
5
5
  site: NOTEBOOKLM_SITE,
6
6
  name: 'status',
@@ -12,7 +12,6 @@ cli({
12
12
  args: [],
13
13
  columns: ['status', 'login', 'page', 'url', 'title', 'notebooks'],
14
14
  func: async (page) => {
15
- await ensureNotebooklmNotebookBinding(page);
16
15
  const currentUrl = await page.getCurrentUrl?.().catch(() => null);
17
16
  if (!currentUrl || !currentUrl.includes(NOTEBOOKLM_DOMAIN)) {
18
17
  await page.goto(NOTEBOOKLM_HOME_URL);
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { EmptyResultError } from '../../errors.js';
3
3
  import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
- import { ensureNotebooklmNotebookBinding, getNotebooklmPageState, getNotebooklmSummaryViaRpc, readNotebooklmSummaryFromPage, requireNotebooklmSession, } from './utils.js';
4
+ import { getNotebooklmPageState, getNotebooklmSummaryViaRpc, readNotebooklmSummaryFromPage, requireNotebooklmSession, } from './utils.js';
5
5
  cli({
6
6
  site: NOTEBOOKLM_SITE,
7
7
  name: 'summary',
@@ -13,11 +13,10 @@ cli({
13
13
  args: [],
14
14
  columns: ['title', 'summary', 'source', 'url'],
15
15
  func: async (page) => {
16
- await ensureNotebooklmNotebookBinding(page);
17
16
  await requireNotebooklmSession(page);
18
17
  const state = await getNotebooklmPageState(page);
19
18
  if (state.kind !== 'notebook') {
20
- throw new EmptyResultError('opencli notebooklm summary', 'Open a specific NotebookLM notebook tab first, then retry.');
19
+ throw new EmptyResultError('opencli notebooklm summary', 'No NotebookLM notebook is open in the automation workspace. Run `opencli notebooklm open <notebook>` first.');
21
20
  }
22
21
  const domSummary = await readNotebooklmSummaryFromPage(page);
23
22
  if (domSummary)
@@ -2,6 +2,8 @@ import type { IPage } from '../../types.js';
2
2
  import { type NotebooklmHistoryRow, type NotebooklmNotebookDetailRow, type NotebooklmNoteDetailRow, type NotebooklmNoteRow, type NotebooklmPageKind, type NotebooklmPageState, type NotebooklmRow, type NotebooklmSourceFulltextRow, type NotebooklmSourceGuideRow, type NotebooklmSourceRow, type NotebooklmSummaryRow } from './shared.js';
3
3
  export { buildNotebooklmRpcBody, extractNotebooklmRpcResult, fetchNotebooklmInPage, getNotebooklmPageAuth, parseNotebooklmChunkedResponse, stripNotebooklmAntiXssi, } from './rpc.js';
4
4
  export declare function parseNotebooklmIdFromUrl(url: string): string;
5
+ export declare function parseNotebooklmNotebookTarget(value: string): string;
6
+ export declare function buildNotebooklmNotebookUrl(notebookId: string): string;
5
7
  export declare function classifyNotebooklmPage(url: string): NotebooklmPageKind;
6
8
  export declare function normalizeNotebooklmTitle(value: unknown, fallback?: string): string;
7
9
  type NotebooklmRawNoteRow = {
@@ -29,7 +31,6 @@ export declare function getNotebooklmSourceFulltextViaRpc(page: IPage, sourceId:
29
31
  export declare function getNotebooklmSourceGuideViaRpc(page: IPage, source: Pick<NotebooklmSourceRow, 'id' | 'notebook_id' | 'title' | 'type'>): Promise<NotebooklmSourceGuideRow | null>;
30
32
  export declare function readNotebooklmVisibleNoteFromPage(page: IPage): Promise<NotebooklmNoteDetailRow | null>;
31
33
  export declare function ensureNotebooklmHome(page: IPage): Promise<void>;
32
- export declare function ensureNotebooklmNotebookBinding(page: IPage): Promise<boolean>;
33
34
  export declare function getNotebooklmPageState(page: IPage): Promise<NotebooklmPageState>;
34
35
  export declare function readCurrentNotebooklm(page: IPage): Promise<NotebooklmRow | null>;
35
36
  export declare function listNotebooklmLinks(page: IPage): Promise<NotebooklmRow[]>;
@@ -1,6 +1,5 @@
1
1
  import { AuthRequiredError, CliError } from '../../errors.js';
2
- import { bindCurrentTab } from '../../browser/daemon-client.js';
3
- import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, NOTEBOOKLM_SITE, } from './shared.js';
2
+ import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_HOME_URL, } from './shared.js';
4
3
  import { callNotebooklmRpc, getNotebooklmPageAuth, } from './rpc.js';
5
4
  export { buildNotebooklmRpcBody, extractNotebooklmRpcResult, fetchNotebooklmInPage, getNotebooklmPageAuth, parseNotebooklmChunkedResponse, stripNotebooklmAntiXssi, } from './rpc.js';
6
5
  const NOTEBOOKLM_LIST_RPC_ID = 'wXbhsf';
@@ -18,6 +17,25 @@ export function parseNotebooklmIdFromUrl(url) {
18
17
  const match = url.match(/\/notebook\/([^/?#]+)/);
19
18
  return match?.[1] ?? '';
20
19
  }
20
+ export function parseNotebooklmNotebookTarget(value) {
21
+ const normalized = value.trim();
22
+ if (!normalized) {
23
+ 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
+ }
25
+ if (/^https?:\/\//i.test(normalized)) {
26
+ 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>.');
30
+ }
31
+ const pathMatch = normalized.match(/(?:^|\/)notebook\/([^/?#]+)/);
32
+ if (pathMatch?.[1])
33
+ return pathMatch[1];
34
+ return normalized;
35
+ }
36
+ export function buildNotebooklmNotebookUrl(notebookId) {
37
+ return new URL(`/notebook/${encodeURIComponent(notebookId)}`, NOTEBOOKLM_HOME_URL).toString();
38
+ }
21
39
  export function classifyNotebooklmPage(url) {
22
40
  try {
23
41
  const parsed = new URL(url);
@@ -511,25 +529,6 @@ export async function ensureNotebooklmHome(page) {
511
529
  await page.goto(NOTEBOOKLM_HOME_URL);
512
530
  await page.wait(2);
513
531
  }
514
- export async function ensureNotebooklmNotebookBinding(page) {
515
- if (!page.getCurrentUrl)
516
- return false;
517
- if (process.env.OPENCLI_CDP_ENDPOINT)
518
- return false;
519
- const currentUrl = await page.getCurrentUrl().catch(() => null);
520
- if (currentUrl && classifyNotebooklmPage(currentUrl) === 'notebook')
521
- return false;
522
- try {
523
- await bindCurrentTab(`site:${NOTEBOOKLM_SITE}`, {
524
- matchDomain: NOTEBOOKLM_DOMAIN,
525
- matchPathPrefix: '/notebook/',
526
- });
527
- return true;
528
- }
529
- catch {
530
- return false;
531
- }
532
- }
533
532
  export async function getNotebooklmPageState(page) {
534
533
  const raw = await page.evaluate(`(() => {
535
534
  const url = window.location.href;
@@ -14,11 +14,38 @@ cli({
14
14
  ],
15
15
  columns: ['title', 'author', 'content', 'url'],
16
16
  func: async (page, kwargs) => {
17
- // Extract tweet ID from URL if needed
17
+ // Extract tweet ID from URL if needed.
18
+ // Article URLs (x.com/i/article/{articleId}) use a different ID than
19
+ // tweet status URLs — the GraphQL endpoint needs the parent tweet ID.
18
20
  let tweetId = kwargs['tweet-id'];
21
+ const isArticleUrl = /\/article\/\d+/.test(tweetId);
19
22
  const urlMatch = tweetId.match(/\/(?:status|article)\/(\d+)/);
20
23
  if (urlMatch)
21
24
  tweetId = urlMatch[1];
25
+ if (isArticleUrl) {
26
+ // Navigate to the article page and resolve the parent tweet ID from DOM
27
+ await page.goto(`https://x.com/i/article/${tweetId}`);
28
+ await page.wait(3);
29
+ const resolvedId = await page.evaluate(`
30
+ (function() {
31
+ var links = document.querySelectorAll('a[href*="/status/"]');
32
+ for (var i = 0; i < links.length; i++) {
33
+ var m = links[i].href.match(/\\/status\\/(\\d+)/);
34
+ if (m) return m[1];
35
+ }
36
+ var og = document.querySelector('meta[property="og:url"]');
37
+ if (og && og.content) {
38
+ var m2 = og.content.match(/\\/status\\/(\\d+)/);
39
+ if (m2) return m2[1];
40
+ }
41
+ return null;
42
+ })()
43
+ `);
44
+ if (!resolvedId || typeof resolvedId !== 'string') {
45
+ throw new CommandExecutionError(`Could not resolve article ${tweetId} to a tweet ID. The article page may not contain a linked tweet.`);
46
+ }
47
+ tweetId = resolvedId;
48
+ }
22
49
  // Navigate to the tweet page for cookie context
23
50
  await page.goto(`https://x.com/i/status/${tweetId}`);
24
51
  await page.wait(3);
@@ -36,9 +36,9 @@ function createPageMock(evaluateResult) {
36
36
  describe('xiaohongshu creator-note-detail', () => {
37
37
  it('parses note detail page text into info and metric rows', () => {
38
38
  const bodyText = `笔记数据详情
39
- 一张图讲清 诡秘之主·耕种者途径
40
- #诡秘之主
41
- #耕种者序列
39
+ 示例内容复盘
40
+ #测试标签
41
+ #内容分析
42
42
  2026-03-18 20:01
43
43
  切换笔记
44
44
  笔记诊断
@@ -81,9 +81,9 @@ describe('xiaohongshu creator-note-detail', () => {
81
81
  分享数
82
82
  6
83
83
  粉丝占比 0%`;
84
- expect(parseCreatorNoteDetailText(bodyText, '69ba940500000000200384db')).toEqual([
85
- { section: '笔记信息', metric: 'note_id', value: '69ba940500000000200384db', extra: '' },
86
- { section: '笔记信息', metric: 'title', value: '一张图讲清 诡秘之主·耕种者途径', extra: '' },
84
+ expect(parseCreatorNoteDetailText(bodyText, 'cccccccccccccccccccccccc')).toEqual([
85
+ { section: '笔记信息', metric: 'note_id', value: 'cccccccccccccccccccccccc', extra: '' },
86
+ { section: '笔记信息', metric: 'title', value: '示例内容复盘', extra: '' },
87
87
  { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
88
88
  { section: '基础数据', metric: '曝光数', value: '1733', extra: '粉丝占比 6.6%' },
89
89
  { section: '基础数据', metric: '观看数', value: '544', extra: '粉丝占比 7.2%' },
@@ -98,8 +98,8 @@ describe('xiaohongshu creator-note-detail', () => {
98
98
  });
99
99
  it('parses structured note detail dom data into rows', () => {
100
100
  expect(parseCreatorNoteDetailDomData({
101
- title: '神雕侠侣战力金字塔',
102
- infoText: '神雕侠侣战力金字塔\n#武侠\n2025-12-04 19:45\n切换笔记',
101
+ title: '测试笔记一',
102
+ infoText: '测试笔记一\n#测试标签\n2025-12-04 19:45\n切换笔记',
103
103
  sections: [
104
104
  {
105
105
  title: '基础数据',
@@ -121,9 +121,9 @@ describe('xiaohongshu creator-note-detail', () => {
121
121
  ],
122
122
  },
123
123
  ],
124
- }, '693155fc000000000d03b42c')).toEqual([
125
- { section: '笔记信息', metric: 'note_id', value: '693155fc000000000d03b42c', extra: '' },
126
- { section: '笔记信息', metric: 'title', value: '神雕侠侣战力金字塔', extra: '' },
124
+ }, 'bbbbbbbbbbbbbbbbbbbbbbbb')).toEqual([
125
+ { section: '笔记信息', metric: 'note_id', value: 'bbbbbbbbbbbbbbbbbbbbbbbb', extra: '' },
126
+ { section: '笔记信息', metric: 'title', value: '测试笔记一', extra: '' },
127
127
  { section: '笔记信息', metric: 'published_at', value: '2025-12-04 19:45', extra: '' },
128
128
  { section: '基础数据', metric: '曝光数', value: '898204', extra: '粉丝占比 0.5%' },
129
129
  { section: '基础数据', metric: '观看数', value: '148284', extra: '粉丝占比 0.6%' },
@@ -4,14 +4,14 @@ import './creator-notes-summary.js';
4
4
  describe('xiaohongshu creator-notes-summary', () => {
5
5
  it('summarizes note list row and detail rows into one compact row', () => {
6
6
  const note = {
7
- id: '69ba940500000000200384db',
8
- title: '一张图讲清 诡秘之主·耕种者途径',
7
+ id: 'cccccccccccccccccccccccc',
8
+ title: '示例内容复盘',
9
9
  date: '2026年03月18日 20:01',
10
10
  views: 549,
11
11
  likes: 19,
12
12
  collects: 10,
13
13
  comments: 7,
14
- url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
14
+ url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc',
15
15
  };
16
16
  const rows = [
17
17
  { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
@@ -29,8 +29,8 @@ describe('xiaohongshu creator-notes-summary', () => {
29
29
  ];
30
30
  expect(summarizeCreatorNote(note, rows, 1)).toEqual({
31
31
  rank: 1,
32
- id: '69ba940500000000200384db',
33
- title: '一张图讲清 诡秘之主·耕种者途径',
32
+ id: 'cccccccccccccccccccccccc',
33
+ title: '示例内容复盘',
34
34
  published_at: '2026-03-18 20:01',
35
35
  views: '549',
36
36
  likes: '19',
@@ -43,7 +43,7 @@ describe('xiaohongshu creator-notes-summary', () => {
43
43
  top_source_pct: '89.9%',
44
44
  top_interest: '二次元',
45
45
  top_interest_pct: '13%',
46
- url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
46
+ url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc',
47
47
  });
48
48
  });
49
49
  });
@@ -41,7 +41,7 @@ describe('xiaohongshu creator-notes', () => {
41
41
  const bodyText = `笔记管理
42
42
  全部笔记(366)
43
43
  已发布
44
- 神雕侠侣战力金字塔
44
+ 测试笔记一
45
45
  发布于 2025年12月04日 19:45
46
46
  148208
47
47
  324
@@ -53,7 +53,7 @@ describe('xiaohongshu creator-notes', () => {
53
53
  编辑
54
54
  删除
55
55
  仅自己可见
56
- 终于等到了!!!
56
+ 测试笔记二
57
57
  发布于 2026年03月18日 12:39
58
58
  10
59
59
  0
@@ -64,7 +64,7 @@ describe('xiaohongshu creator-notes', () => {
64
64
  expect(parseCreatorNotesText(bodyText)).toEqual([
65
65
  {
66
66
  id: '',
67
- title: '神雕侠侣战力金字塔',
67
+ title: '测试笔记一',
68
68
  date: '2025年12月04日 19:45',
69
69
  views: 148208,
70
70
  likes: 2279,
@@ -74,7 +74,7 @@ describe('xiaohongshu creator-notes', () => {
74
74
  },
75
75
  {
76
76
  id: '',
77
- title: '终于等到了!!!',
77
+ title: '测试笔记二',
78
78
  date: '2026年03月18日 12:39',
79
79
  views: 10,
80
80
  likes: 0,
@@ -98,7 +98,7 @@ describe('xiaohongshu creator-notes', () => {
98
98
  4
99
99
  5
100
100
  权限设置`,
101
- html: '&quot;noteId&quot;:&quot;69ba940500000000200384db&quot;',
101
+ html: '&quot;noteId&quot;:&quot;aaaaaaaaaaaaaaaaaaaaaaaa&quot;',
102
102
  },
103
103
  ]);
104
104
  const result = await cmd.func(page, { limit: 1 });
@@ -106,14 +106,14 @@ describe('xiaohongshu creator-notes', () => {
106
106
  expect(result).toEqual([
107
107
  {
108
108
  rank: 1,
109
- id: '69ba940500000000200384db',
109
+ id: 'aaaaaaaaaaaaaaaaaaaaaaaa',
110
110
  title: '示例笔记',
111
111
  date: '2026年03月19日 12:00',
112
112
  views: 10,
113
113
  likes: 3,
114
114
  collects: 4,
115
115
  comments: 2,
116
- url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
116
+ url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=aaaaaaaaaaaaaaaaaaaaaaaa',
117
117
  },
118
118
  ]);
119
119
  });
@@ -124,8 +124,8 @@ describe('xiaohongshu creator-notes', () => {
124
124
  undefined,
125
125
  [
126
126
  {
127
- id: '693155fc000000000d03b42c',
128
- title: '神雕侠侣战力金字塔',
127
+ id: 'bbbbbbbbbbbbbbbbbbbbbbbb',
128
+ title: '测试笔记一',
129
129
  date: '2025年12月04日 19:45',
130
130
  metrics: [148284, 319, 2280, 466, 33],
131
131
  },
@@ -135,14 +135,14 @@ describe('xiaohongshu creator-notes', () => {
135
135
  expect(result).toEqual([
136
136
  {
137
137
  rank: 1,
138
- id: '693155fc000000000d03b42c',
139
- title: '神雕侠侣战力金字塔',
138
+ id: 'bbbbbbbbbbbbbbbbbbbbbbbb',
139
+ title: '测试笔记一',
140
140
  date: '2025年12月04日 19:45',
141
141
  views: 148284,
142
142
  likes: 2280,
143
143
  collects: 466,
144
144
  comments: 319,
145
- url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=693155fc000000000d03b42c',
145
+ url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=bbbbbbbbbbbbbbbbbbbbbbbb',
146
146
  },
147
147
  ]);
148
148
  });
@@ -153,8 +153,8 @@ describe('xiaohongshu creator-notes', () => {
153
153
  data: {
154
154
  note_infos: [
155
155
  {
156
- id: '69ba940500000000200384db',
157
- title: '一张图讲清 诡秘之主·耕种者途径',
156
+ id: 'cccccccccccccccccccccccc',
157
+ title: '示例内容复盘',
158
158
  post_time: new Date('2026-03-18T20:01:00+08:00').getTime(),
159
159
  read_count: 521,
160
160
  like_count: 18,
@@ -169,26 +169,26 @@ describe('xiaohongshu creator-notes', () => {
169
169
  expect(result).toEqual([
170
170
  {
171
171
  rank: 1,
172
- id: '69ba940500000000200384db',
173
- title: '一张图讲清 诡秘之主·耕种者途径',
172
+ id: 'cccccccccccccccccccccccc',
173
+ title: '示例内容复盘',
174
174
  date: '2026年03月18日 20:01',
175
175
  views: 521,
176
176
  likes: 18,
177
177
  collects: 10,
178
178
  comments: 7,
179
- url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
179
+ url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc',
180
180
  },
181
181
  ]);
182
182
  });
183
183
  it('extracts note ids from creator note-manager html', () => {
184
184
  const html = `
185
- <div>&quot;noteId&quot;:&quot;69ba940500000000200384db&quot;</div>
186
- <div>&quot;noteId&quot;:&quot;69ba2c98000000001a026e0f&quot;</div>
187
- <div>&quot;noteId&quot;:&quot;69ba940500000000200384db&quot;</div>
185
+ <div>&quot;noteId&quot;:&quot;aaaaaaaaaaaaaaaaaaaaaaaa&quot;</div>
186
+ <div>&quot;noteId&quot;:&quot;dddddddddddddddddddddddd&quot;</div>
187
+ <div>&quot;noteId&quot;:&quot;aaaaaaaaaaaaaaaaaaaaaaaa&quot;</div>
188
188
  `;
189
189
  expect(parseCreatorNoteIdsFromHtml(html)).toEqual([
190
- '69ba940500000000200384db',
191
- '69ba2c98000000001a026e0f',
190
+ 'aaaaaaaaaaaaaaaaaaaaaaaa',
191
+ 'dddddddddddddddddddddddd',
192
192
  ]);
193
193
  });
194
194
  });
@@ -19,6 +19,7 @@ cli({
19
19
  columns: ['field', 'value'],
20
20
  func: async (page, kwargs) => {
21
21
  const raw = String(kwargs['note-id']);
22
+ const isBareNoteId = !/^https?:\/\//.test(raw.trim());
22
23
  const noteId = parseNoteId(raw);
23
24
  const url = buildNoteUrl(raw);
24
25
  await page.goto(url);
@@ -60,6 +61,16 @@ cli({
60
61
  // XHS renders placeholder text like "赞"/"收藏"/"评论" when count is 0;
61
62
  // normalize to '0' unless the value looks numeric.
62
63
  const numOrZero = (v) => /^\d+/.test(v) ? v : '0';
64
+ // XHS sometimes renders an empty shell page for bare /explore/<id> visits
65
+ // when the request lacks a valid xsec_token. Title + author are always
66
+ // present on a real note, so their absence is the simplest reliable signal.
67
+ const emptyShell = !d.title && !d.author;
68
+ if (emptyShell) {
69
+ if (isBareNoteId) {
70
+ throw new EmptyResultError('xiaohongshu/note', 'Pass the full search_result URL with xsec_token, for example from `opencli xiaohongshu search`, instead of a bare note ID.');
71
+ }
72
+ throw new EmptyResultError('xiaohongshu/note', 'The note page loaded without visible content. Retry with a fresh URL or run with --verbose; if it persists, the page structure may have changed.');
73
+ }
63
74
  const rows = [
64
75
  { field: 'title', value: d.title || '' },
65
76
  { field: 'author', value: d.author || '' },