@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,549 @@
1
+ /**
2
+ * Suno web (suno.com) browser automation helpers — rewritten for the
3
+ * /api/generate/v2-web/ schema introduced 2026-05.
4
+ *
5
+ * Auth model: Bearer JWT from Clerk (`window.Clerk.session.getToken()`).
6
+ *
7
+ * The studio backend lives on `studio-api-prod.suno.com`; the page itself is
8
+ * on `suno.com`. The browser's normal cross-origin cookie-bearing fetch
9
+ * succeeds from a real Chrome tab, but the OpenCLI bridge's evaluate
10
+ * context isolates third-party cookies — `credentials: 'include'` drops the
11
+ * Clerk session cookie. Sending the JWT explicitly as `Authorization: Bearer`
12
+ * bypasses the isolation and matches the auth path the studio API expects.
13
+ *
14
+ * Required custom headers (in addition to Bearer):
15
+ * - browser-token: {"token":"<base64 of {timestamp: ms}>"} (anti-replay)
16
+ * - device-id: <uuid> (persistent per browser, found in `suno_device_id` cookie)
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError, TimeoutError } from '@jackwener/opencli/errors';
21
+
22
+ export const SUNO_DOMAIN = 'suno.com';
23
+ export const SUNO_URL = 'https://suno.com';
24
+ export const STUDIO_API = 'https://studio-api-prod.suno.com';
25
+ export const SUNO_CDN = 'https://cdn1.suno.ai';
26
+
27
+ // As of 2026-05, the UI exposes V5.5 (chirp-fenix) and V4.5+ (chirp-bluejay).
28
+ // Older versions are still routable via the API and remain valid `mv` values.
29
+ export const SUNO_MODELS = ['chirp-fenix', 'chirp-bluejay', 'chirp-v4', 'chirp-v3-5'];
30
+ export const DEFAULT_SUNO_MODEL = 'chirp-fenix';
31
+
32
+ export const SUPPORTED_FORMATS = ['mp3', 'm4a', 'wav', 'video', 'cover', 'metadata'];
33
+ export const DEFAULT_FORMATS = ['mp3', 'metadata'];
34
+
35
+ export function parseFormats(value) {
36
+ if (value === undefined || value === null || value === '') return DEFAULT_FORMATS.slice();
37
+ const raw = Array.isArray(value) ? value.join(',') : String(value);
38
+ const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
39
+ if (!parts.length) return DEFAULT_FORMATS.slice();
40
+ const unknown = parts.filter(p => !SUPPORTED_FORMATS.includes(p));
41
+ if (unknown.length) {
42
+ throw new ArgumentError(
43
+ `Unsupported --formats value(s): ${unknown.join(', ')}`,
44
+ `Supported: ${SUPPORTED_FORMATS.join(', ')}. (stems require multi-step extraction and are not yet wired.)`,
45
+ );
46
+ }
47
+ return Array.from(new Set(parts));
48
+ }
49
+
50
+ export function resolveSunoOutputDir(value) {
51
+ const raw = String(value || '').trim();
52
+ if (!raw) return path.join(process.env.HOME || '~', 'Music', 'suno');
53
+ if (raw === '~') return process.env.HOME || '~';
54
+ if (raw.startsWith('~/')) return path.join(process.env.HOME || '~', raw.slice(2));
55
+ return path.resolve(raw);
56
+ }
57
+
58
+ export function sanitizeTitleForFilename(title, fallback = 'untitled') {
59
+ const cleaned = String(title || fallback)
60
+ .replace(/[\\/:*?"<>|\x00-\x1f]+/g, '-')
61
+ .replace(/\s+/g, ' ')
62
+ .trim()
63
+ .slice(0, 60);
64
+ return cleaned || fallback;
65
+ }
66
+
67
+ export function ensureDir(dir) {
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ }
70
+
71
+ export function normalizeBooleanFlag(value, fallback = false) {
72
+ if (typeof value === 'boolean') return value;
73
+ if (value === null || value === undefined || value === '') return fallback;
74
+ const s = String(value).trim().toLowerCase();
75
+ return s === 'true' || s === '1' || s === 'yes' || s === 'on';
76
+ }
77
+
78
+ export function unwrapEvaluateResult(value) {
79
+ if (value && typeof value === 'object' && 'session' in value && 'data' in value) {
80
+ return value.data;
81
+ }
82
+ return value;
83
+ }
84
+
85
+ export function requirePositiveInt(value, label) {
86
+ const n = Number(value);
87
+ if (!Number.isInteger(n) || n < 1) {
88
+ throw new ArgumentError(`${label} must be a positive integer`);
89
+ }
90
+ return n;
91
+ }
92
+
93
+ export function requireNonNegativeInt(value, label) {
94
+ const n = Number(value);
95
+ if (!Number.isInteger(n) || n < 0) {
96
+ throw new ArgumentError(`${label} must be a non-negative integer`);
97
+ }
98
+ return n;
99
+ }
100
+
101
+ export function clampSlider(value, label, def) {
102
+ if (value === undefined || value === null || value === '') return def;
103
+ const n = Number(value);
104
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
105
+ throw new ArgumentError(`${label} must be a number between 0 and 1`);
106
+ }
107
+ return n;
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // In-page helper snippets. Each is inlined into a page.evaluate() call so
112
+ // the browser-token is generated fresh per request and the Clerk token is
113
+ // pulled live (avoiding 60s TTL expiry on long polls).
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+
116
+ const BROWSER_TOKEN_JS = `JSON.stringify({ token: btoa(JSON.stringify({ timestamp: Date.now() })) })`;
117
+ const CLERK_TOKEN_JS = `await window.Clerk.session.getToken()`;
118
+
119
+ /**
120
+ * Build the standard header set used by every studio-api-prod.suno.com call.
121
+ *
122
+ * deviceId is read once per command and embedded literally; browser-token
123
+ * and Authorization are computed inline (timestamp/JWT refresh per call).
124
+ */
125
+ function sunoHeadersJs(deviceId, extra = {}) {
126
+ const extraEntries = Object.entries(extra).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ');
127
+ return `{
128
+ 'Authorization': 'Bearer ' + ${CLERK_TOKEN_JS},
129
+ 'browser-token': ${BROWSER_TOKEN_JS},
130
+ 'device-id': ${JSON.stringify(deviceId)},
131
+ ${extraEntries}${extraEntries ? ',' : ''}
132
+ }`;
133
+ }
134
+
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+ // Session bootstrap.
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Parse studio-api-prod billing/info. Inlined into the page IIFE via
141
+ * toString() so the same parser runs in Node tests and the browser.
142
+ */
143
+ export function parseSunoBillingInfo(data) {
144
+ const packCredits = (data?.credit_packs || []).reduce((s, p) => s + (p?.amount ?? p?.credits ?? 0), 0);
145
+ const monthlyRemaining = Math.max(0, (data?.monthly_limit ?? 0) - (data?.monthly_usage ?? 0));
146
+ const totalCreditsAvailable = typeof data?.total_credits_left === 'number'
147
+ ? data.total_credits_left
148
+ : (data?.credits ?? 0) + packCredits + monthlyRemaining;
149
+ const plans = Array.isArray(data?.plans) ? data.plans : [];
150
+ const subscriptionKey = typeof data?.subscription_type === 'string' && data.subscription_type
151
+ ? data.subscription_type
152
+ : null;
153
+ const currentPlan = subscriptionKey
154
+ ? plans.find((p) => p?.plan_key === subscriptionKey)
155
+ : plans.find((p) => p?.plan_key === 'free');
156
+ return {
157
+ planId: currentPlan?.id || data?.plan?.id || null,
158
+ planKey: currentPlan?.plan_key || data?.plan?.plan_key || (subscriptionKey ?? 'free'),
159
+ totalCreditsAvailable,
160
+ breakdown: {
161
+ pack: data?.credits ?? 0,
162
+ purchasedPacks: packCredits,
163
+ monthlyRemaining,
164
+ monthlyLimit: data?.monthly_limit ?? 0,
165
+ monthlyUsed: data?.monthly_usage ?? 0,
166
+ },
167
+ };
168
+ }
169
+
170
+ export async function ensureSunoSession(page) {
171
+ await page.goto(`${SUNO_URL}/me`, { settleMs: 2000 });
172
+ // OneTrust consent banner can block the page; dismiss it if present.
173
+ await page.evaluate(`(() => {
174
+ const btn = Array.from(document.querySelectorAll('button')).find(b => /^reject all$|^accept all$|^confirm/i.test((b.innerText || '').trim()));
175
+ if (btn) btn.click();
176
+ })()`);
177
+
178
+ // Wait briefly for Clerk to mount before any API call.
179
+ for (let i = 0; i < 20; i += 1) {
180
+ const ready = unwrapEvaluateResult(await page.evaluate(`!!(window.Clerk && window.Clerk.session)`));
181
+ if (ready) break;
182
+ await page.wait(0.5);
183
+ }
184
+
185
+ const deviceId = await getSunoDeviceId(page);
186
+ const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
187
+ try {
188
+ if (!window.Clerk?.session) return { ok: false, auth: true, error: 'Clerk session unavailable' };
189
+ const res = await fetch('${STUDIO_API}/api/billing/info/', { headers: ${sunoHeadersJs(deviceId)} });
190
+ if (!res.ok) return { ok: false, status: res.status, body: (await res.text()).slice(0, 300) };
191
+ let data = null;
192
+ try {
193
+ data = await res.json();
194
+ } catch (e) {
195
+ return { ok: false, error: 'Malformed billing/info JSON: ' + String(e).slice(0, 200) };
196
+ }
197
+ const parse = ${parseSunoBillingInfo.toString()};
198
+ return { ok: true, ...parse(data) };
199
+ } catch (e) {
200
+ return { ok: false, error: String(e).slice(0, 200) };
201
+ }
202
+ })()`));
203
+
204
+ if (!result || !result.ok) {
205
+ const detail = result?.status || result?.error || 'unknown';
206
+ if (result?.auth || result?.status === 401 || result?.status === 403) {
207
+ throw new AuthRequiredError(SUNO_DOMAIN, `Suno session check failed (${detail}). Open https://suno.com in Chrome and sign in, then retry.`);
208
+ }
209
+ throw new CommandExecutionError(`Suno session check failed (${detail}).`);
210
+ }
211
+ return { ...result, deviceId };
212
+ }
213
+
214
+ /**
215
+ * Verify the captcha pre-flight. If `required:true`, the simple flow won't
216
+ * work without solving a CAPTCHA (out of scope for the headless adapter).
217
+ */
218
+ export async function checkSunoCaptcha(page, deviceId) {
219
+ const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
220
+ const res = await fetch('${STUDIO_API}/api/c/check', {
221
+ method: 'POST',
222
+ headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
223
+ body: JSON.stringify({ ctype: 'generation' }),
224
+ });
225
+ if (!res.ok) return { ok: false, status: res.status };
226
+ return { ok: true, ...(await res.json()) };
227
+ })()`));
228
+ return result; // { ok, required, captcha_version }
229
+ }
230
+
231
+ // ─────────────────────────────────────────────────────────────────────────────
232
+ // device-id discovery. Suno frontend writes `suno_device_id` as a cookie on
233
+ // first load; we re-use it so every command shares the same identity.
234
+ // ─────────────────────────────────────────────────────────────────────────────
235
+
236
+ export async function getSunoDeviceId(page) {
237
+ const id = unwrapEvaluateResult(await page.evaluate(`(() => {
238
+ try {
239
+ const fromCookie = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('suno_device_id='));
240
+ if (fromCookie) return decodeURIComponent(fromCookie.split('=')[1]);
241
+ for (const key of ['device_id', 'deviceId', 'suno_device_id']) {
242
+ const v = localStorage.getItem(key);
243
+ if (v) return v.replace(/^"|"$/g, '');
244
+ }
245
+ } catch {}
246
+ return null;
247
+ })()`));
248
+ if (id && typeof id === 'string') return id;
249
+ return unwrapEvaluateResult(await page.evaluate(`crypto.randomUUID()`));
250
+ }
251
+
252
+ // ─────────────────────────────────────────────────────────────────────────────
253
+ // Generate (Custom or Simple mode).
254
+ // ─────────────────────────────────────────────────────────────────────────────
255
+
256
+ /**
257
+ * Submit a generation request via /api/generate/v2-web/.
258
+ *
259
+ * payload fields:
260
+ * - mode: 'custom' | 'simple'
261
+ * - model: chirp-fenix etc.
262
+ * - title: song title (required by API even for Simple mode)
263
+ * - lyrics: Custom-mode lyrics (with [Verse] metatags). Used as API `prompt`.
264
+ * - tags: Custom-mode style string.
265
+ * - negativeTags: Custom-mode exclusion tags.
266
+ * - description: Simple-mode description string. Sent in `prompt` per the
267
+ * v2-web schema (the older `gpt_description_prompt` field is gone).
268
+ * - makeInstrumental: bool
269
+ * - weirdness, styleWeight: 0..1 sliders
270
+ * - userTier: UUID from billing/info plan.id
271
+ * - createSessionToken: UUID, generated per session
272
+ * - deviceId: UUID from local browser
273
+ */
274
+ export async function submitSunoGeneration(page, payload) {
275
+ const isCustom = payload.mode === 'custom';
276
+ const body = {
277
+ token: null,
278
+ generation_type: 'TEXT',
279
+ title: payload.title || '',
280
+ tags: isCustom ? (payload.tags || '') : '',
281
+ negative_tags: isCustom ? (payload.negativeTags || '') : '',
282
+ mv: payload.model || DEFAULT_SUNO_MODEL,
283
+ prompt: isCustom ? (payload.lyrics || '') : (payload.description || ''),
284
+ make_instrumental: !!payload.makeInstrumental,
285
+ user_uploaded_images_b64: null,
286
+ metadata: {
287
+ web_client_pathname: '/create',
288
+ is_max_mode: false,
289
+ is_mumble: false,
290
+ create_mode: isCustom ? 'custom' : 'simple',
291
+ user_tier: payload.userTier,
292
+ create_session_token: payload.createSessionToken,
293
+ disable_volume_normalization: false,
294
+ control_sliders: {
295
+ weirdness_constraint: payload.weirdness,
296
+ style_weight: payload.styleWeight,
297
+ },
298
+ },
299
+ override_fields: [],
300
+ cover_clip_id: null,
301
+ cover_start_s: null,
302
+ cover_end_s: null,
303
+ persona_id: null,
304
+ artist_clip_id: null,
305
+ artist_start_s: null,
306
+ artist_end_s: null,
307
+ continue_clip_id: null,
308
+ continued_aligned_prompt: null,
309
+ continue_at: null,
310
+ transaction_uuid: payload.transactionUuid,
311
+ token_provider: null,
312
+ };
313
+
314
+ const bodyJson = JSON.stringify(body);
315
+ const deviceId = payload.deviceId;
316
+ const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
317
+ const res = await fetch('${STUDIO_API}/api/generate/v2-web/', {
318
+ method: 'POST',
319
+ headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
320
+ body: ${JSON.stringify(bodyJson)},
321
+ });
322
+ const text = await res.text();
323
+ let parsed = null;
324
+ try { parsed = JSON.parse(text); } catch {}
325
+ return { status: res.status, ok: res.ok, body: parsed, raw: parsed ? null : text.slice(0, 600) };
326
+ })()`));
327
+
328
+ if (!result || !result.ok) {
329
+ const status = result?.status || 'unknown';
330
+ const detail = result?.body?.detail || result?.raw || JSON.stringify(result?.body || {}).slice(0, 500);
331
+ if (status === 401 || status === 403) {
332
+ throw new AuthRequiredError(SUNO_DOMAIN, `Suno API rejected request (HTTP ${status}). Re-login on suno.com.`);
333
+ }
334
+ if (status === 402) {
335
+ throw new CommandExecutionError(`Suno API: insufficient credits (HTTP 402). ${detail}`);
336
+ }
337
+ throw new CommandExecutionError(`Suno generate failed (HTTP ${status}): ${detail}`);
338
+ }
339
+
340
+ if (!result.body || typeof result.body !== 'object' || Array.isArray(result.body)) {
341
+ throw new CommandExecutionError('Suno generate returned malformed JSON payload.');
342
+ }
343
+ const clips = result.body?.clips || [];
344
+ if (!clips.length) {
345
+ throw new EmptyResultError('suno generate', `Submission accepted but Suno returned no clip ids. Raw: ${JSON.stringify(result.body).slice(0, 300)}`);
346
+ }
347
+ return result.body;
348
+ }
349
+
350
+ // ─────────────────────────────────────────────────────────────────────────────
351
+ // Poll /api/feed/v3 (cookie auth, no Bearer).
352
+ // ─────────────────────────────────────────────────────────────────────────────
353
+
354
+ export async function pollSunoClips(page, clipIds, timeoutSeconds, deviceId, pollSeconds = 5, onProgress = null) {
355
+ const deadline = Date.now() + timeoutSeconds * 1000;
356
+ const targetSet = new Set(clipIds);
357
+ const idsJson = JSON.stringify(clipIds);
358
+
359
+ while (Date.now() < deadline) {
360
+ const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
361
+ const res = await fetch('${STUDIO_API}/api/feed/v3', {
362
+ method: 'POST',
363
+ headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
364
+ body: JSON.stringify({ clip_ids: ${idsJson} }),
365
+ });
366
+ const body = await res.json().catch(() => null);
367
+ return { status: res.status, body };
368
+ })()`));
369
+
370
+ if (!result) {
371
+ await page.wait(pollSeconds);
372
+ continue;
373
+ }
374
+ if (result.status === 401 || result.status === 403) {
375
+ throw new AuthRequiredError(SUNO_DOMAIN, `Suno feed API rejected (HTTP ${result.status}). Re-login.`);
376
+ }
377
+ if (result.status < 200 || result.status >= 300) {
378
+ throw new CommandExecutionError(`Suno feed API failed while polling clips (HTTP ${result.status || '?'})`);
379
+ }
380
+ if (!result.body || typeof result.body !== 'object' || Array.isArray(result.body)) {
381
+ throw new CommandExecutionError('Suno feed API returned malformed JSON while polling clips');
382
+ }
383
+
384
+ const allClips = result.body.clips || [];
385
+ if (!Array.isArray(allClips)) {
386
+ throw new CommandExecutionError('Suno feed API returned malformed clips payload');
387
+ }
388
+ const ourClips = allClips.filter(c => targetSet.has(c.id));
389
+ const finished = ourClips.filter(c => c.status === 'complete' || c.status === 'error');
390
+
391
+ if (typeof onProgress === 'function') {
392
+ onProgress({ total: clipIds.length, done: finished.length, statuses: ourClips.map(c => `${c.id.slice(0,8)}:${c.status}`) });
393
+ }
394
+
395
+ if (finished.length === clipIds.length) return ourClips;
396
+ await page.wait(pollSeconds);
397
+ }
398
+
399
+ throw new TimeoutError(`Suno generation did not complete within ${timeoutSeconds}s. Try --timeout <higher>.`);
400
+ }
401
+
402
+ // ─────────────────────────────────────────────────────────────────────────────
403
+ // Asset download.
404
+ // ─────────────────────────────────────────────────────────────────────────────
405
+
406
+ function pickMediaUrl(clip, contentTypeFragment) {
407
+ const arr = Array.isArray(clip.media_urls) ? clip.media_urls : [];
408
+ const hit = arr.find(m => (m.content_type || '').toLowerCase().includes(contentTypeFragment));
409
+ return hit?.url || null;
410
+ }
411
+
412
+ /**
413
+ * Resolve the canonical MP3 URL for a clip. Suno's /api/download/clip/{id}
414
+ * returns a fresh signed URL; clip.audio_url is a backup.
415
+ */
416
+ async function resolveMp3Url(page, clip, deviceId) {
417
+ const fromApi = await page.evaluate(`(async () => {
418
+ try {
419
+ const res = await fetch('${STUDIO_API}/api/download/clip/${clip.id}?format=mp3', { headers: ${sunoHeadersJs(deviceId)} });
420
+ if (!res.ok) return null;
421
+ const data = await res.json();
422
+ return data?.download_url || null;
423
+ } catch { return null; }
424
+ })()`);
425
+ return fromApi || clip.audio_url || `${SUNO_CDN}/${clip.id}.mp3`;
426
+ }
427
+
428
+ /**
429
+ * Trigger WAV conversion (if not already converted), then poll for the file
430
+ * URL. Suno charges a download credit for WAV — the billing call is required.
431
+ */
432
+ async function ensureSunoWav(page, clipId, deviceId, timeoutSeconds = 120, pollSeconds = 3) {
433
+ // Charge the credit + queue conversion. Both calls fire idempotently if
434
+ // the wav is already cached server-side.
435
+ await page.evaluate(`(async () => {
436
+ try {
437
+ await fetch('${STUDIO_API}/api/billing/clips/${clipId}/download/', {
438
+ method: 'POST',
439
+ headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
440
+ body: JSON.stringify({}),
441
+ });
442
+ } catch {}
443
+ try {
444
+ await fetch('${STUDIO_API}/api/gen/${clipId}/convert_wav/', {
445
+ method: 'POST',
446
+ headers: ${sunoHeadersJs(deviceId, { 'Content-Type': 'application/json' })},
447
+ });
448
+ } catch {}
449
+ })()`);
450
+
451
+ const deadline = Date.now() + timeoutSeconds * 1000;
452
+ while (Date.now() < deadline) {
453
+ const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
454
+ const res = await fetch('${STUDIO_API}/api/gen/${clipId}/wav_file/', { headers: ${sunoHeadersJs(deviceId)} });
455
+ if (!res.ok) return { ok: false, status: res.status };
456
+ const data = await res.json().catch(() => null);
457
+ return { ok: true, url: data?.wav_file_url || null };
458
+ })()`));
459
+ if (result?.ok && result.url) return result.url;
460
+ await page.wait(pollSeconds);
461
+ }
462
+ return null;
463
+ }
464
+
465
+ async function downloadBinary(page, url) {
466
+ // CDN URLs (cdn1.suno.ai, cloudfront) are public and reject the CORS
467
+ // preflight triggered by `credentials: 'include'`. Plain fetch is what
468
+ // the Suno web UI uses for asset downloads too.
469
+ return unwrapEvaluateResult(await page.evaluate(`(async () => {
470
+ try {
471
+ const res = await fetch(${JSON.stringify(url)});
472
+ if (!res.ok) return { ok: false, status: res.status };
473
+ const blob = await res.blob();
474
+ const dataUrl = await new Promise((resolve, reject) => {
475
+ const reader = new FileReader();
476
+ reader.onloadend = () => resolve(String(reader.result || ''));
477
+ reader.onerror = () => reject(new Error('reader failed'));
478
+ reader.readAsDataURL(blob);
479
+ });
480
+ return { ok: true, mime: blob.type || '', dataUrl };
481
+ } catch (e) {
482
+ return { ok: false, error: String(e).slice(0, 200) };
483
+ }
484
+ })()`));
485
+ }
486
+
487
+ async function writeFromDataUrl(page, url, filePath) {
488
+ const result = await downloadBinary(page, url);
489
+ if (!result?.ok) {
490
+ return { ok: false, reason: result?.status ? `HTTP ${result.status}` : (result?.error || 'unknown') };
491
+ }
492
+ const base64 = String(result.dataUrl || '').replace(/^data:[^;]+;base64,/, '');
493
+ if (!base64) {
494
+ return { ok: false, reason: 'empty response body' };
495
+ }
496
+ fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
497
+ if (!fs.statSync(filePath).size) {
498
+ return { ok: false, reason: 'empty file written' };
499
+ }
500
+ return { ok: true };
501
+ }
502
+
503
+ export async function downloadSunoClip(page, clip, outputDir, formats, deviceId) {
504
+ ensureDir(outputDir);
505
+ const slug = `${sanitizeTitleForFilename(clip.title || clip.id)}_${clip.id.slice(0, 8)}`;
506
+ const written = [];
507
+
508
+ if (formats.includes('metadata')) {
509
+ const metaPath = path.join(outputDir, `${slug}.json`);
510
+ fs.writeFileSync(metaPath, JSON.stringify(clip, null, 2), 'utf8');
511
+ written.push({ format: 'metadata', file: metaPath, ok: true });
512
+ }
513
+
514
+ for (const fmt of formats) {
515
+ if (fmt === 'metadata') continue;
516
+ let url = null;
517
+ let ext = '';
518
+ try {
519
+ if (fmt === 'mp3') {
520
+ ext = '.mp3';
521
+ url = await resolveMp3Url(page, clip, deviceId);
522
+ } else if (fmt === 'm4a') {
523
+ ext = '.m4a';
524
+ url = pickMediaUrl(clip, 'm4a');
525
+ } else if (fmt === 'video') {
526
+ ext = '.mp4';
527
+ url = clip.video_url || null;
528
+ } else if (fmt === 'cover') {
529
+ ext = '.jpeg';
530
+ url = clip.image_large_url || clip.image_url || null;
531
+ } else if (fmt === 'wav') {
532
+ ext = '.wav';
533
+ url = await ensureSunoWav(page, clip.id, deviceId);
534
+ }
535
+ } catch (err) {
536
+ written.push({ format: fmt, file: null, ok: false, reason: `prep failed: ${String(err).slice(0, 100)}` });
537
+ continue;
538
+ }
539
+ if (!url) {
540
+ written.push({ format: fmt, file: null, ok: false, reason: 'not available' });
541
+ continue;
542
+ }
543
+ const filePath = path.join(outputDir, `${slug}${ext}`);
544
+ const result = await writeFromDataUrl(page, url, filePath);
545
+ written.push({ format: fmt, file: filePath, ok: result.ok, reason: result.ok ? null : result.reason });
546
+ }
547
+
548
+ return { slug, written };
549
+ }