@jackwener/opencli 1.7.22 → 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 (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,269 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { cli, Strategy } from '@jackwener/opencli/registry';
4
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
5
+ import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
6
+ import { callNotebooklmRpc } from './rpc.js';
7
+ import { buildNotebooklmNotebookUrl, ensureNotebooklmHome, getNotebooklmAuthuser, parseNotebooklmNotebookTarget, requireNotebooklmExecute, requireNotebooklmSession, verifyNotebooklmSourceAdded } from './utils.js';
8
+
9
+ const NOTEBOOKLM_ADD_SOURCES_RPC_ID = 'izAoDd';
10
+ const NOTEBOOKLM_ADD_FILE_SOURCE_RPC_ID = 'o4cbdc';
11
+ const MAX_TEXT_SOURCE_BYTES = 10 * 1024 * 1024;
12
+ const MAX_FILE_SOURCE_BYTES = 50 * 1024 * 1024;
13
+ const SOURCE_UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
14
+
15
+ const MIME_BY_EXT = {
16
+ '.pdf': 'application/pdf',
17
+ '.txt': 'text/plain',
18
+ '.md': 'text/markdown',
19
+ '.markdown': 'text/markdown',
20
+ '.html': 'text/html',
21
+ '.htm': 'text/html',
22
+ '.csv': 'text/csv',
23
+ '.json': 'application/json',
24
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
25
+ '.doc': 'application/msword',
26
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
27
+ '.epub': 'application/epub+zip',
28
+ '.mp3': 'audio/mpeg',
29
+ '.m4a': 'audio/mp4',
30
+ '.wav': 'audio/wav',
31
+ };
32
+
33
+ export function inferMimeType(filename, override) {
34
+ if (override) return String(override);
35
+ const ext = path.extname(filename).toLowerCase();
36
+ return MIME_BY_EXT[ext] || 'application/octet-stream';
37
+ }
38
+
39
+ export function readFileForUpload(filePath) {
40
+ const abs = path.resolve(filePath);
41
+ let stat;
42
+ try {
43
+ stat = fs.statSync(abs);
44
+ } catch {
45
+ throw new ArgumentError(`--file path does not exist: ${filePath}`);
46
+ }
47
+ if (!stat.isFile()) {
48
+ throw new ArgumentError(`--file path is not a regular file: ${filePath}`);
49
+ }
50
+ if (stat.size > MAX_FILE_SOURCE_BYTES) {
51
+ throw new ArgumentError(`--file exceeds ${MAX_FILE_SOURCE_BYTES} bytes (got ${stat.size}); use a smaller file or upload via the NotebookLM UI for now.`);
52
+ }
53
+ const buf = fs.readFileSync(abs);
54
+ return { base64: buf.toString('base64'), filename: path.basename(abs), size: stat.size };
55
+ }
56
+
57
+ export function buildRegisterFileSourceArgs(projectId, filename) {
58
+ return [
59
+ [[filename]],
60
+ projectId,
61
+ [2],
62
+ [1, null, null, null, null, null, null, null, null, null, [1]],
63
+ ];
64
+ }
65
+
66
+ export function parseSourceUrl(value) {
67
+ const url = String(value ?? '').trim();
68
+ if (!url) return '';
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(url);
72
+ } catch {
73
+ throw new ArgumentError(`Invalid source URL: "${url}"`, 'URL must be a valid http:// or https:// URL.');
74
+ }
75
+ if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || !parsed.hostname) {
76
+ throw new ArgumentError(`Invalid source URL: "${url}"`, 'URL must start with http:// or https://.');
77
+ }
78
+ return parsed.toString();
79
+ }
80
+
81
+ export function parseSourceText(value) {
82
+ if (value === undefined || value === null) return null;
83
+ const text = String(value);
84
+ if (!text.trim()) throw new ArgumentError('--content must not be empty');
85
+ if (text.length > MAX_TEXT_SOURCE_BYTES) {
86
+ throw new ArgumentError(`--content exceeds ${MAX_TEXT_SOURCE_BYTES} bytes; split into smaller sources or upload as a file.`);
87
+ }
88
+ return text;
89
+ }
90
+
91
+ export function parseSourceTitle(value, fallback) {
92
+ const title = String(value ?? '').trim();
93
+ return title || fallback;
94
+ }
95
+
96
+ export function buildAddSourceFromUrlArgs(projectId, url) {
97
+ return [[[null, null, [url]]], projectId];
98
+ }
99
+
100
+ export function buildAddSourceFromTextArgs(projectId, title, content) {
101
+ return [[[null, [title, content], null, 2]], projectId];
102
+ }
103
+
104
+ function toExcludedUuidSet(excludedIds) {
105
+ return new Set(excludedIds.map((id) => String(id ?? '').toLowerCase()).filter(Boolean));
106
+ }
107
+
108
+ export function parseAddSourceResult(result, excludedIds = []) {
109
+ const excluded = toExcludedUuidSet(excludedIds);
110
+ if (typeof result === 'string') return SOURCE_UUID_RE.test(result) && !excluded.has(result.toLowerCase()) ? result : '';
111
+ if (!Array.isArray(result) && (typeof result !== 'object' || result === null)) return '';
112
+ const stack = [result];
113
+ while (stack.length) {
114
+ const node = stack.shift();
115
+ if (typeof node === 'string') {
116
+ if (SOURCE_UUID_RE.test(node) && !excluded.has(node.toLowerCase())) return node;
117
+ continue;
118
+ }
119
+ if (Array.isArray(node)) {
120
+ for (const child of node) stack.push(child);
121
+ continue;
122
+ }
123
+ if (node && typeof node === 'object') {
124
+ for (const value of Object.values(node)) stack.push(value);
125
+ }
126
+ }
127
+ return '';
128
+ }
129
+
130
+ async function uploadFileViaDriveResumable(page, projectId, sourceId, filename, base64, size) {
131
+ const authuser = getNotebooklmAuthuser() || '0';
132
+ const metadataJson = `{"PROJECT_ID":${JSON.stringify(projectId)},"SOURCE_NAME":${JSON.stringify(filename)},"SOURCE_ID":${JSON.stringify(sourceId)}}`;
133
+ const script = String.raw`(async () => {
134
+ try {
135
+ const initRes = await fetch(${JSON.stringify('https://notebooklm.google.com/upload/_/?authuser=' + authuser)}, {
136
+ method: 'POST',
137
+ credentials: 'include',
138
+ headers: {
139
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
140
+ 'X-Goog-Upload-Command': 'start',
141
+ 'X-Goog-Upload-Protocol': 'resumable',
142
+ 'X-Goog-Upload-Header-Content-Length': ${JSON.stringify(String(size))},
143
+ 'X-Goog-AuthUser': ${JSON.stringify(authuser)},
144
+ },
145
+ body: ${JSON.stringify(metadataJson)},
146
+ });
147
+ if (!initRes.ok) {
148
+ const status = initRes.headers.get('X-Goog-Upload-Status') || '';
149
+ const text = (await initRes.text()).slice(0, 400);
150
+ return { error: 'init failed HTTP ' + initRes.status + (status ? ' upload-status=' + status : '') + (text ? ' body=' + text : '') };
151
+ }
152
+ const uploadURL = initRes.headers.get('X-Goog-Upload-Url') || initRes.headers.get('x-goog-upload-url');
153
+ if (!uploadURL) return { error: 'no X-Goog-Upload-URL header in init response' };
154
+ // Decode base64 to Uint8Array for binary PUT
155
+ const bin = atob(${JSON.stringify(base64)});
156
+ const bytes = new Uint8Array(bin.length);
157
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
158
+ const uploadRes = await fetch(uploadURL, {
159
+ method: 'POST',
160
+ credentials: 'include',
161
+ headers: {
162
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
163
+ 'X-Goog-Upload-Command': 'upload, finalize',
164
+ 'X-Goog-Upload-Offset': '0',
165
+ 'X-Goog-AuthUser': ${JSON.stringify(authuser)},
166
+ },
167
+ body: bytes,
168
+ });
169
+ if (!uploadRes.ok) {
170
+ const status = uploadRes.headers.get('X-Goog-Upload-Status') || '';
171
+ const text = (await uploadRes.text()).slice(0, 400);
172
+ return { error: 'upload failed HTTP ' + uploadRes.status + (status ? ' upload-status=' + status : '') + (text ? ' body=' + text : '') };
173
+ }
174
+ return { ok: true, status: uploadRes.status };
175
+ } catch (e) {
176
+ return { error: 'upload exception: ' + ((e && e.message) || String(e)) };
177
+ }
178
+ })()`;
179
+ const raw = await page.evaluate(script);
180
+ const result = raw && typeof raw === 'object' && 'data' in raw && 'session' in raw ? raw.data : raw;
181
+ if (!result || result.error) {
182
+ throw new CommandExecutionError('NotebookLM file upload failed: ' + (result?.error || 'unknown error'));
183
+ }
184
+ return result;
185
+ }
186
+
187
+ cli({
188
+ site: NOTEBOOKLM_SITE,
189
+ name: 'add-source',
190
+ access: 'write',
191
+ description: 'Add a URL, text, or local file source to an existing NotebookLM notebook',
192
+ domain: NOTEBOOKLM_DOMAIN,
193
+ strategy: Strategy.COOKIE,
194
+ browser: true,
195
+ navigateBefore: false,
196
+ args: [
197
+ { name: 'notebook', positional: true, required: true, help: 'Notebook id from `notebooklm list` or full notebook URL' },
198
+ { name: 'url', help: 'Source URL to add (http/https). Pass exactly one of --url, --content, --file.' },
199
+ { name: 'content', help: 'Raw text content to add as a Text source (max 10 MB).' },
200
+ { name: 'file', help: `Local file path to upload as a source (max ${MAX_FILE_SOURCE_BYTES} bytes; pdf / txt / md / html / docx / etc.). Uses Google Drive's 3-step resumable upload protocol.` },
201
+ { name: 'title', help: 'Title for the text source (default "Text Source"). Ignored for --url and --file.' },
202
+ { name: 'mime-type', help: 'Override the auto-detected MIME type when --file is given.' },
203
+ { name: 'execute', type: 'boolean', help: 'Actually add the remote source to the NotebookLM notebook' },
204
+ ],
205
+ columns: ['notebook_id', 'source_id', 'kind', 'identifier', 'notebook_url'],
206
+ func: async (page, kwargs) => {
207
+ const notebookId = parseNotebooklmNotebookTarget(String(kwargs.notebook ?? ''));
208
+ const url = parseSourceUrl(kwargs.url);
209
+ const content = parseSourceText(kwargs.content);
210
+ const filePath = typeof kwargs.file === 'string' && kwargs.file.trim() ? kwargs.file.trim() : '';
211
+ const modes = [url ? 'url' : '', content !== null ? 'text' : '', filePath ? 'file' : ''].filter(Boolean);
212
+ if (modes.length === 0) {
213
+ throw new ArgumentError('Pass exactly one of --url <url>, --content <text>, or --file <path>');
214
+ }
215
+ if (modes.length > 1) {
216
+ throw new ArgumentError('Pass exactly one of --url, --content, --file (got: ' + modes.join(' + ') + ')');
217
+ }
218
+ requireNotebooklmExecute(kwargs.execute, 'add a NotebookLM source');
219
+ const title = parseSourceTitle(kwargs.title, 'Text Source');
220
+ await ensureNotebooklmHome(page);
221
+ await requireNotebooklmSession(page);
222
+ if (filePath) {
223
+ const file = readFileForUpload(filePath);
224
+ const mime = inferMimeType(file.filename, kwargs['mime-type']);
225
+ const registerRpc = await callNotebooklmRpc(page, NOTEBOOKLM_ADD_FILE_SOURCE_RPC_ID, buildRegisterFileSourceArgs(notebookId, file.filename));
226
+ const sourceId = parseAddSourceResult(registerRpc.result, [notebookId]);
227
+ if (!sourceId) {
228
+ throw new CommandExecutionError('NotebookLM AddFileSource (o4cbdc) RPC returned no source id; cannot start file upload.');
229
+ }
230
+ await uploadFileViaDriveResumable(page, notebookId, sourceId, file.filename, file.base64, file.size);
231
+ await verifyNotebooklmSourceAdded(page, notebookId, sourceId, 'add-source --file');
232
+ return [{
233
+ notebook_id: notebookId,
234
+ source_id: sourceId,
235
+ kind: 'file',
236
+ identifier: `${file.filename} (${mime}, ${file.size} bytes)`,
237
+ notebook_url: buildNotebooklmNotebookUrl(notebookId),
238
+ }];
239
+ }
240
+ const args = url
241
+ ? buildAddSourceFromUrlArgs(notebookId, url)
242
+ : buildAddSourceFromTextArgs(notebookId, title, content);
243
+ const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_ADD_SOURCES_RPC_ID, args);
244
+ const sourceId = parseAddSourceResult(rpc.result, [notebookId]);
245
+ if (!sourceId) {
246
+ throw new CommandExecutionError('NotebookLM AddSources RPC returned no source id; verify the input reaches the NotebookLM backend.');
247
+ }
248
+ await verifyNotebooklmSourceAdded(page, notebookId, sourceId, `add-source --${url ? 'url' : 'content'}`);
249
+ return [{
250
+ notebook_id: notebookId,
251
+ source_id: sourceId,
252
+ kind: url ? 'url' : 'text',
253
+ identifier: url || title,
254
+ notebook_url: buildNotebooklmNotebookUrl(notebookId),
255
+ }];
256
+ },
257
+ });
258
+
259
+ export const __test__ = {
260
+ parseSourceUrl,
261
+ parseSourceText,
262
+ parseSourceTitle,
263
+ inferMimeType,
264
+ buildAddSourceFromUrlArgs,
265
+ buildAddSourceFromTextArgs,
266
+ buildRegisterFileSourceArgs,
267
+ readFileForUpload,
268
+ parseAddSourceResult,
269
+ };
@@ -0,0 +1,97 @@
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 './add-source.js';
5
+
6
+ const {
7
+ parseSourceUrl,
8
+ parseSourceText,
9
+ parseSourceTitle,
10
+ buildAddSourceFromUrlArgs,
11
+ buildAddSourceFromTextArgs,
12
+ parseAddSourceResult,
13
+ } = __test__;
14
+
15
+ describe('notebooklm add-source', () => {
16
+ it('parseSourceUrl accepts http and https URLs', () => {
17
+ expect(parseSourceUrl('https://en.wikipedia.org/wiki/Reti_Opening'))
18
+ .toBe('https://en.wikipedia.org/wiki/Reti_Opening');
19
+ expect(parseSourceUrl(' http://example.com/page ')).toBe('http://example.com/page');
20
+ });
21
+
22
+ it('parseSourceUrl returns empty string for unset, rejects bad schemes', () => {
23
+ expect(parseSourceUrl('')).toBe('');
24
+ expect(parseSourceUrl(undefined)).toBe('');
25
+ expect(parseSourceUrl(' ')).toBe('');
26
+ expect(() => parseSourceUrl('ftp://example.com')).toThrow(ArgumentError);
27
+ expect(() => parseSourceUrl('javascript:alert(1)')).toThrow(ArgumentError);
28
+ expect(() => parseSourceUrl('https://')).toThrow(ArgumentError);
29
+ });
30
+
31
+ it('parseSourceText returns the content for non-empty, null for unset', () => {
32
+ expect(parseSourceText('hello body')).toBe('hello body');
33
+ expect(parseSourceText(undefined)).toBeNull();
34
+ expect(parseSourceText(null)).toBeNull();
35
+ });
36
+
37
+ it('parseSourceText rejects whitespace-only and oversized content', () => {
38
+ expect(() => parseSourceText(' ')).toThrow(ArgumentError);
39
+ expect(() => parseSourceText('x'.repeat(10 * 1024 * 1024 + 1))).toThrow(ArgumentError);
40
+ });
41
+
42
+ it('parseSourceTitle trims and falls back to the default label', () => {
43
+ expect(parseSourceTitle('Custom title', 'Text Source')).toBe('Custom title');
44
+ expect(parseSourceTitle(' ', 'Text Source')).toBe('Text Source');
45
+ expect(parseSourceTitle(undefined, 'Text Source')).toBe('Text Source');
46
+ });
47
+
48
+ it('buildAddSourceFromUrlArgs wraps the url in the expected nested shape', () => {
49
+ const args = buildAddSourceFromUrlArgs('nb-123', 'https://example.com/a');
50
+ expect(args).toEqual([[[null, null, ['https://example.com/a']]], 'nb-123']);
51
+ });
52
+
53
+ it('buildAddSourceFromTextArgs uses the text inner tuple with source-type 2', () => {
54
+ const args = buildAddSourceFromTextArgs('nb-123', 'My Title', 'paragraph one');
55
+ expect(args).toEqual([[[null, ['My Title', 'paragraph one'], null, 2]], 'nb-123']);
56
+ });
57
+
58
+ it('parseAddSourceResult finds the source-id UUID anywhere in the wrapped result', () => {
59
+ const id = 'af732fa4-01c2-4de4-9d0a-933f2c29ee1e';
60
+ expect(parseAddSourceResult([[[ [id] ]]])).toBe(id);
61
+ expect(parseAddSourceResult([ 'project', null, [[id, 'title']] ])).toBe(id);
62
+ expect(parseAddSourceResult({ project: { sources: [{ sourceId: { sourceId: id } }] } })).toBe(id);
63
+ });
64
+
65
+ it('parseAddSourceResult returns the first UUID when several appear', () => {
66
+ const a = '11111111-1111-4111-8111-111111111111';
67
+ const b = '22222222-2222-4222-8222-222222222222';
68
+ expect(parseAddSourceResult([[[ [a], [b] ]]])).toBe(a);
69
+ });
70
+
71
+ it('parseAddSourceResult skips known input ids before selecting the new source id', () => {
72
+ const notebookId = '17e2b882-6a01-4c6c-9262-0738dfa2abee';
73
+ const sourceId = 'af732fa4-01c2-4de4-9d0a-933f2c29ee1e';
74
+ expect(parseAddSourceResult([notebookId, [[sourceId, 'title']]], [notebookId])).toBe(sourceId);
75
+ });
76
+
77
+ it('parseAddSourceResult ignores non-UUID strings', () => {
78
+ expect(parseAddSourceResult([ 'project-id', 'not-a-uuid', 'still-not' ])).toBe('');
79
+ });
80
+
81
+ it('parseAddSourceResult returns empty string for unparseable shapes', () => {
82
+ expect(parseAddSourceResult(null)).toBe('');
83
+ expect(parseAddSourceResult({})).toBe('');
84
+ expect(parseAddSourceResult([])).toBe('');
85
+ expect(parseAddSourceResult([[]])).toBe('');
86
+ });
87
+
88
+ it('refuses to add a remote source without --execute', async () => {
89
+ const command = getRegistry().get('notebooklm/add-source');
90
+ const page = { goto: vi.fn() };
91
+ await expect(command.func(page, {
92
+ notebook: '17e2b882-6a01-4c6c-9262-0738dfa2abee',
93
+ content: 'source body',
94
+ })).rejects.toThrow(ArgumentError);
95
+ expect(page.goto).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -0,0 +1,76 @@
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, requireNotebooklmExecute, requireNotebooklmSession, verifyNotebooklmNotebookExists } from './utils.js';
6
+
7
+ const NOTEBOOKLM_CREATE_PROJECT_RPC_ID = 'CCqFvf';
8
+ const DEFAULT_EMOJI = '📒';
9
+ const MAX_TITLE_LEN = 200;
10
+ const NOTEBOOK_UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
11
+
12
+ export function parseCreateTitle(value) {
13
+ const title = String(value ?? '').trim();
14
+ if (!title) throw new ArgumentError('<title> is required');
15
+ if (title.length > MAX_TITLE_LEN) {
16
+ throw new ArgumentError(`Title must be at most ${MAX_TITLE_LEN} characters (got ${title.length})`);
17
+ }
18
+ return title;
19
+ }
20
+
21
+ export function parseCreateEmoji(value) {
22
+ const emoji = String(value ?? '').trim();
23
+ if (!emoji) return DEFAULT_EMOJI;
24
+ return emoji;
25
+ }
26
+
27
+ export function parseCreateProjectResult(result) {
28
+ let current = result;
29
+ while (Array.isArray(current) && current.length === 1 && Array.isArray(current[0])) {
30
+ current = current[0];
31
+ }
32
+ const id = Array.isArray(current)
33
+ ? (typeof current[2] === 'string' && current[2])
34
+ || (typeof current[0] === 'string' && current[0])
35
+ || ''
36
+ : '';
37
+ return typeof id === 'string' && NOTEBOOK_UUID_RE.test(id) ? id : '';
38
+ }
39
+
40
+ cli({
41
+ site: NOTEBOOKLM_SITE,
42
+ name: 'create',
43
+ access: 'write',
44
+ description: 'Create a new NotebookLM notebook with the given title',
45
+ domain: NOTEBOOKLM_DOMAIN,
46
+ strategy: Strategy.COOKIE,
47
+ browser: true,
48
+ navigateBefore: false,
49
+ args: [
50
+ { name: 'title', positional: true, required: true, help: 'Notebook title (1-200 chars)' },
51
+ { name: 'emoji', help: `Notebook emoji icon (default ${DEFAULT_EMOJI})` },
52
+ { name: 'execute', type: 'boolean', help: 'Actually create the remote NotebookLM notebook' },
53
+ ],
54
+ columns: ['id', 'title', 'emoji', 'url'],
55
+ func: async (page, kwargs) => {
56
+ const title = parseCreateTitle(kwargs.title);
57
+ const emoji = parseCreateEmoji(kwargs.emoji);
58
+ requireNotebooklmExecute(kwargs.execute, 'create a NotebookLM notebook');
59
+ await ensureNotebooklmHome(page);
60
+ await requireNotebooklmSession(page);
61
+ const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_CREATE_PROJECT_RPC_ID, [title, emoji]);
62
+ const notebookId = parseCreateProjectResult(rpc.result);
63
+ if (!notebookId) {
64
+ throw new CommandExecutionError('NotebookLM CreateProject RPC returned no notebook id');
65
+ }
66
+ await verifyNotebooklmNotebookExists(page, notebookId, 'create');
67
+ return [{
68
+ id: notebookId,
69
+ title,
70
+ emoji,
71
+ url: buildNotebooklmNotebookUrl(notebookId),
72
+ }];
73
+ },
74
+ });
75
+
76
+ export const __test__ = { parseCreateTitle, parseCreateEmoji, parseCreateProjectResult };
@@ -0,0 +1,58 @@
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 './create.js';
5
+
6
+ const { parseCreateTitle, parseCreateEmoji, parseCreateProjectResult } = __test__;
7
+
8
+ describe('notebooklm create', () => {
9
+ it('parseCreateTitle trims and accepts 1-200 char titles', () => {
10
+ expect(parseCreateTitle('hello')).toBe('hello');
11
+ expect(parseCreateTitle(' spaced ')).toBe('spaced');
12
+ expect(parseCreateTitle('x'.repeat(200))).toHaveLength(200);
13
+ });
14
+
15
+ it('parseCreateTitle rejects empty / too-long titles', () => {
16
+ expect(() => parseCreateTitle('')).toThrow(ArgumentError);
17
+ expect(() => parseCreateTitle(' ')).toThrow(ArgumentError);
18
+ expect(() => parseCreateTitle(undefined)).toThrow(ArgumentError);
19
+ expect(() => parseCreateTitle('x'.repeat(201))).toThrow(ArgumentError);
20
+ });
21
+
22
+ it('parseCreateEmoji defaults to the notebook icon when empty', () => {
23
+ expect(parseCreateEmoji(undefined)).toBe('📒');
24
+ expect(parseCreateEmoji('')).toBe('📒');
25
+ expect(parseCreateEmoji(' ')).toBe('📒');
26
+ });
27
+
28
+ it('parseCreateEmoji passes through user-provided emoji', () => {
29
+ expect(parseCreateEmoji('🧪')).toBe('🧪');
30
+ expect(parseCreateEmoji(' 🎓 ')).toBe('🎓');
31
+ });
32
+
33
+ it('parseCreateProjectResult extracts the notebook id from the singleton-wrapped RPC result', () => {
34
+ const result = [[ ['notebook-payload-prefix', null, 'ec806f5b-fe74-4588-8f77-f073b91e9b1e', 'opencli-test', '🧪'] ]];
35
+ expect(parseCreateProjectResult(result)).toBe('ec806f5b-fe74-4588-8f77-f073b91e9b1e');
36
+ });
37
+
38
+ it('parseCreateProjectResult falls back to a UUID-shaped index 0 when index 2 is missing', () => {
39
+ const id = 'd0b14aa7-fc0f-44bc-a749-928e27e5fa3b';
40
+ const result = [id, null];
41
+ expect(parseCreateProjectResult(result)).toBe(id);
42
+ });
43
+
44
+ it('parseCreateProjectResult returns empty string on malformed or unparseable shapes', () => {
45
+ expect(parseCreateProjectResult(null)).toBe('');
46
+ expect(parseCreateProjectResult({})).toBe('');
47
+ expect(parseCreateProjectResult([])).toBe('');
48
+ expect(parseCreateProjectResult([null, null, null])).toBe('');
49
+ expect(parseCreateProjectResult(['some-id', null])).toBe('');
50
+ });
51
+
52
+ it('refuses to create a remote notebook without --execute', async () => {
53
+ const command = getRegistry().get('notebooklm/create');
54
+ const page = { goto: vi.fn() };
55
+ await expect(command.func(page, { title: 'Draft Notebook' })).rejects.toThrow(ArgumentError);
56
+ expect(page.goto).not.toHaveBeenCalled();
57
+ });
58
+ });
@@ -0,0 +1,91 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { NOTEBOOKLM_DOMAIN, NOTEBOOKLM_SITE } from './shared.js';
4
+ import { callNotebooklmRpc } from './rpc.js';
5
+ import { buildNotebooklmNotebookUrl, listNotebooklmSourcesViaRpc, parseNotebooklmNotebookTarget, requireNotebooklmExecute, requireNotebooklmSession } from './utils.js';
6
+
7
+ const NOTEBOOKLM_CREATE_AUDIO_RPC_ID = 'R7cb6c';
8
+ const AUDIO_OVERVIEW_CONFIG_BLOCK = [2, null, null, [1, null, null, null, null, null, null, null, null, null, [1]], [[1, 4, 2, 3, 6]]];
9
+ const AUDIO_UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
10
+
11
+ function toExcludedUuidSet(excludedIds) {
12
+ return new Set(excludedIds.map((id) => String(id ?? '').toLowerCase()).filter(Boolean));
13
+ }
14
+
15
+ export function buildCreateAudioArgs(projectId, sourceIds) {
16
+ const sourceTuples = sourceIds.map((id) => [[id]]);
17
+ const sourceTuplesTail = sourceIds.map((id) => [id]);
18
+ return [
19
+ AUDIO_OVERVIEW_CONFIG_BLOCK,
20
+ projectId,
21
+ [
22
+ null,
23
+ null,
24
+ 1,
25
+ sourceTuples,
26
+ null,
27
+ null,
28
+ [null, [null, null, null, sourceTuplesTail]],
29
+ ],
30
+ ];
31
+ }
32
+
33
+ export function parseAudioIdFromResult(result, excludedIds = []) {
34
+ const excluded = toExcludedUuidSet(excludedIds);
35
+ if (typeof result === 'string' && AUDIO_UUID_RE.test(result) && !excluded.has(result.toLowerCase())) return result;
36
+ const stack = [result];
37
+ while (stack.length) {
38
+ const node = stack.shift();
39
+ if (typeof node === 'string' && AUDIO_UUID_RE.test(node) && !excluded.has(node.toLowerCase())) return node;
40
+ if (Array.isArray(node)) for (const child of node) stack.push(child);
41
+ else if (node && typeof node === 'object') for (const v of Object.values(node)) stack.push(v);
42
+ }
43
+ return '';
44
+ }
45
+
46
+ cli({
47
+ site: NOTEBOOKLM_SITE,
48
+ name: 'generate-audio',
49
+ access: 'write',
50
+ description: 'Trigger an Audio Overview (Deep Dive podcast) generation for a NotebookLM notebook, using all of its sources',
51
+ domain: NOTEBOOKLM_DOMAIN,
52
+ strategy: Strategy.COOKIE,
53
+ browser: true,
54
+ navigateBefore: false,
55
+ args: [
56
+ { name: 'notebook', positional: true, required: true, help: 'Notebook id from `notebooklm list` or full notebook URL' },
57
+ { name: 'execute', type: 'boolean', help: 'Actually trigger remote NotebookLM audio generation' },
58
+ ],
59
+ columns: ['notebook_id', 'audio_id', 'source_count', 'status', 'notebook_url'],
60
+ func: async (page, kwargs) => {
61
+ const notebookId = parseNotebooklmNotebookTarget(String(kwargs.notebook ?? ''));
62
+ requireNotebooklmExecute(kwargs.execute, 'generate NotebookLM audio');
63
+ try {
64
+ await page.goto(buildNotebooklmNotebookUrl(notebookId));
65
+ await page.wait(2);
66
+ }
67
+ catch (error) {
68
+ throw new CommandExecutionError(`Failed to open NotebookLM notebook ${notebookId}: ${error?.message || error}`);
69
+ }
70
+ await requireNotebooklmSession(page);
71
+ const sources = await listNotebooklmSourcesViaRpc(page);
72
+ const sourceIds = sources.map((s) => s.id).filter((id) => typeof id === 'string' && id);
73
+ if (sourceIds.length === 0) {
74
+ throw new EmptyResultError('notebooklm generate-audio', 'The notebook has no sources; add a source before generating an audio overview.');
75
+ }
76
+ const rpc = await callNotebooklmRpc(page, NOTEBOOKLM_CREATE_AUDIO_RPC_ID, buildCreateAudioArgs(notebookId, sourceIds));
77
+ const audioId = parseAudioIdFromResult(rpc.result, [notebookId, ...sourceIds]);
78
+ if (!audioId) {
79
+ throw new CommandExecutionError('NotebookLM CreateAudioOverview RPC returned no audio id; server may have rejected the request.');
80
+ }
81
+ return [{
82
+ notebook_id: notebookId,
83
+ audio_id: audioId,
84
+ source_count: sourceIds.length,
85
+ status: 'pending',
86
+ notebook_url: buildNotebooklmNotebookUrl(notebookId),
87
+ }];
88
+ },
89
+ });
90
+
91
+ export const __test__ = { AUDIO_OVERVIEW_CONFIG_BLOCK, buildCreateAudioArgs, parseAudioIdFromResult };
@@ -0,0 +1,63 @@
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 './generate-audio.js';
5
+
6
+ const { AUDIO_OVERVIEW_CONFIG_BLOCK, buildCreateAudioArgs, parseAudioIdFromResult } = __test__;
7
+
8
+ describe('notebooklm generate-audio', () => {
9
+ it('AUDIO_OVERVIEW_CONFIG_BLOCK matches the live-captured wire shape', () => {
10
+ expect(AUDIO_OVERVIEW_CONFIG_BLOCK).toEqual([
11
+ 2, null, null,
12
+ [1, null, null, null, null, null, null, null, null, null, [1]],
13
+ [[1, 4, 2, 3, 6]],
14
+ ]);
15
+ });
16
+
17
+ it('buildCreateAudioArgs matches the byte-perfect wire format captured from the UI', () => {
18
+ const projectId = '42ad744e-477d-4198-97b6-a9ae6a663165';
19
+ const sourceId = '7c7666bd-59e1-42ab-879d-bacfe33325eb';
20
+ expect(buildCreateAudioArgs(projectId, [sourceId])).toEqual([
21
+ [2, null, null, [1, null, null, null, null, null, null, null, null, null, [1]], [[1, 4, 2, 3, 6]]],
22
+ projectId,
23
+ [null, null, 1, [[[sourceId]]], null, null, [null, [null, null, null, [[sourceId]]]]],
24
+ ]);
25
+ });
26
+
27
+ it('buildCreateAudioArgs threads multiple sources into both the nested and tail blocks', () => {
28
+ const a = '11111111-1111-4111-8111-111111111111';
29
+ const b = '22222222-2222-4222-8222-222222222222';
30
+ const args = buildCreateAudioArgs('pid', [a, b]);
31
+ expect(args[2][3]).toEqual([[[a]], [[b]]]);
32
+ expect(args[2][6][1][3]).toEqual([[a], [b]]);
33
+ });
34
+
35
+ it('parseAudioIdFromResult walks the tree for a UUID-shaped audio id', () => {
36
+ const id = '38da0e55-2360-4d3e-8573-61b5a6c0c219';
37
+ expect(parseAudioIdFromResult([[id, 'opencli-audio-benjaminliu', 1]])).toBe(id);
38
+ expect(parseAudioIdFromResult({ result: { audioId: id } })).toBe(id);
39
+ });
40
+
41
+ it('parseAudioIdFromResult ignores non-UUID strings', () => {
42
+ expect(parseAudioIdFromResult([[null, 'opencli-audio-benjaminliu', 1]])).toBe('');
43
+ expect(parseAudioIdFromResult({})).toBe('');
44
+ expect(parseAudioIdFromResult([])).toBe('');
45
+ expect(parseAudioIdFromResult(null)).toBe('');
46
+ });
47
+
48
+ it('parseAudioIdFromResult skips notebook/source ids before selecting the generated audio id', () => {
49
+ const notebookId = '17e2b882-6a01-4c6c-9262-0738dfa2abee';
50
+ const sourceId = '7c7666bd-59e1-42ab-879d-bacfe33325eb';
51
+ const audioId = '38da0e55-2360-4d3e-8573-61b5a6c0c219';
52
+ expect(parseAudioIdFromResult([notebookId, [[sourceId]], [audioId]], [notebookId, sourceId])).toBe(audioId);
53
+ });
54
+
55
+ it('refuses to trigger remote audio generation without --execute', async () => {
56
+ const command = getRegistry().get('notebooklm/generate-audio');
57
+ const page = { goto: vi.fn() };
58
+ await expect(command.func(page, {
59
+ notebook: '17e2b882-6a01-4c6c-9262-0738dfa2abee',
60
+ })).rejects.toThrow(ArgumentError);
61
+ expect(page.goto).not.toHaveBeenCalled();
62
+ });
63
+ });