@jackwener/opencli 1.7.22 → 1.8.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 (222) hide show
  1. package/README.md +30 -148
  2. package/README.zh-CN.md +37 -211
  3. package/cli-manifest.json +6423 -4260
  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/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/chatgpt/envelope.test.js +108 -0
  24. package/clis/chatgpt/image.js +2 -2
  25. package/clis/chatgpt/image.test.js +6 -0
  26. package/clis/chatgpt/utils.js +148 -41
  27. package/clis/chatgpt/utils.test.js +92 -2
  28. package/clis/douyin/_shared/browser-fetch.js +44 -20
  29. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  30. package/clis/douyin/_shared/evaluate-result.js +16 -0
  31. package/clis/douyin/_shared/tos-upload.js +105 -69
  32. package/clis/douyin/_shared/vod-upload.js +212 -0
  33. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  34. package/clis/douyin/delete.js +137 -4
  35. package/clis/douyin/delete.test.js +90 -1
  36. package/clis/douyin/publish-upload-id.test.js +170 -0
  37. package/clis/douyin/publish.js +88 -42
  38. package/clis/douyin/user-videos.js +9 -2
  39. package/clis/douyin/user-videos.test.js +43 -0
  40. package/clis/flomo/memos.js +228 -0
  41. package/clis/flomo/memos.test.js +144 -0
  42. package/clis/gitee/search.js +2 -2
  43. package/clis/gitee/search.test.js +65 -0
  44. package/clis/jike/post.js +27 -17
  45. package/clis/jike/read.test.js +86 -0
  46. package/clis/jike/topic.js +32 -19
  47. package/clis/jike/user.js +33 -20
  48. package/clis/lesswrong/comments.js +1 -1
  49. package/clis/lesswrong/curated.js +1 -1
  50. package/clis/lesswrong/frontpage.js +1 -1
  51. package/clis/lesswrong/frontpage.test.js +37 -0
  52. package/clis/lesswrong/new.js +1 -1
  53. package/clis/lesswrong/read.js +1 -1
  54. package/clis/lesswrong/sequences.js +1 -1
  55. package/clis/lesswrong/shortform.js +1 -1
  56. package/clis/lesswrong/tag.js +1 -1
  57. package/clis/lesswrong/top-month.js +1 -1
  58. package/clis/lesswrong/top-week.js +1 -1
  59. package/clis/lesswrong/top-year.js +1 -1
  60. package/clis/lesswrong/top.js +1 -1
  61. package/clis/linkedin/connect.js +401 -0
  62. package/clis/linkedin/connect.test.js +213 -0
  63. package/clis/linkedin/inbox.js +234 -0
  64. package/clis/linkedin/inbox.test.js +152 -0
  65. package/clis/linkedin/people-search.js +262 -0
  66. package/clis/linkedin/people-search.test.js +216 -0
  67. package/clis/linkedin/safe-send.js +357 -0
  68. package/clis/linkedin/safe-send.test.js +204 -0
  69. package/clis/linkedin/salesnav-inbox.js +210 -0
  70. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  71. package/clis/linkedin/salesnav-message.js +360 -0
  72. package/clis/linkedin/salesnav-message.test.js +172 -0
  73. package/clis/linkedin/salesnav-search.js +186 -0
  74. package/clis/linkedin/salesnav-search.test.js +76 -0
  75. package/clis/linkedin/salesnav-thread.js +212 -0
  76. package/clis/linkedin/salesnav-thread.test.js +79 -0
  77. package/clis/linkedin/sent-invitations.js +92 -0
  78. package/clis/linkedin/sent-invitations.test.js +62 -0
  79. package/clis/linkedin/thread-snapshot.js +214 -0
  80. package/clis/linkedin/thread-snapshot.test.js +89 -0
  81. package/clis/linkedin-learning/course.js +138 -0
  82. package/clis/linkedin-learning/course.test.js +114 -0
  83. package/clis/linkedin-learning/search.js +155 -0
  84. package/clis/linkedin-learning/search.test.js +144 -0
  85. package/clis/linkedin-learning/trending.js +133 -0
  86. package/clis/linkedin-learning/trending.test.js +123 -0
  87. package/clis/powerchina/search.js +3 -3
  88. package/clis/powerchina/search.test.js +27 -1
  89. package/clis/reddit/extract-media.test.js +149 -0
  90. package/clis/reddit/frontpage.js +47 -9
  91. package/clis/reddit/frontpage.test.js +34 -0
  92. package/clis/reddit/home.js +31 -1
  93. package/clis/reddit/home.test.js +46 -3
  94. package/clis/reddit/hot.js +32 -1
  95. package/clis/reddit/hot.test.js +15 -1
  96. package/clis/reddit/popular.js +39 -1
  97. package/clis/reddit/popular.test.js +26 -0
  98. package/clis/reddit/saved.js +1 -1
  99. package/clis/reddit/search.js +38 -1
  100. package/clis/reddit/search.test.js +26 -0
  101. package/clis/reddit/subreddit.js +52 -7
  102. package/clis/reddit/subreddit.test.js +31 -0
  103. package/clis/reddit/subscribed.js +165 -0
  104. package/clis/reddit/subscribed.test.js +168 -0
  105. package/clis/reddit/upvoted.js +1 -1
  106. package/clis/suno/commands.test.js +188 -0
  107. package/clis/suno/download.js +140 -0
  108. package/clis/suno/download.test.js +151 -0
  109. package/clis/suno/generate.js +226 -0
  110. package/clis/suno/generate.test.js +243 -0
  111. package/clis/suno/list.js +79 -0
  112. package/clis/suno/status.js +62 -0
  113. package/clis/suno/utils.js +540 -0
  114. package/clis/suno/utils.test.js +223 -0
  115. package/clis/twitter/device-follow.js +193 -0
  116. package/clis/twitter/device-follow.test.js +287 -0
  117. package/clis/twitter/download.js +443 -73
  118. package/clis/twitter/download.test.js +457 -0
  119. package/clis/twitter/list-create.js +155 -0
  120. package/clis/twitter/list-create.test.js +169 -0
  121. package/clis/twitter/list-remove.js +12 -5
  122. package/clis/twitter/list-remove.test.js +74 -0
  123. package/clis/twitter/list-tweets.js +6 -2
  124. package/clis/twitter/list-tweets.test.js +41 -1
  125. package/clis/twitter/lists.js +31 -4
  126. package/clis/twitter/lists.test.js +152 -16
  127. package/clis/twitter/search.js +6 -2
  128. package/clis/twitter/search.test.js +6 -0
  129. package/clis/twitter/shared.js +144 -0
  130. package/clis/twitter/shared.test.js +429 -1
  131. package/clis/twitter/thread.js +10 -2
  132. package/clis/twitter/thread.test.js +58 -0
  133. package/clis/twitter/timeline.js +6 -2
  134. package/clis/twitter/timeline.test.js +2 -0
  135. package/clis/twitter/tweets.js +3 -2
  136. package/clis/twitter/tweets.test.js +1 -1
  137. package/clis/weibo/delete.js +172 -0
  138. package/clis/weibo/delete.test.js +94 -0
  139. package/clis/weibo/publish.js +37 -14
  140. package/clis/weibo/publish.test.js +14 -5
  141. package/clis/weibo/user-posts.js +234 -0
  142. package/clis/weibo/user-posts.test.js +92 -0
  143. package/clis/weread/search-regression.test.js +18 -11
  144. package/clis/weread/search.js +15 -7
  145. package/clis/weread-official/book.js +135 -0
  146. package/clis/weread-official/commands.test.js +385 -0
  147. package/clis/weread-official/discover.js +107 -0
  148. package/clis/weread-official/list-apis.js +95 -0
  149. package/clis/weread-official/notes.js +171 -0
  150. package/clis/weread-official/readdata.js +158 -0
  151. package/clis/weread-official/review.js +93 -0
  152. package/clis/weread-official/search.js +106 -0
  153. package/clis/weread-official/shelf.js +97 -0
  154. package/clis/weread-official/utils.js +293 -0
  155. package/clis/weread-official/utils.test.js +242 -0
  156. package/clis/wikipedia/trending.js +7 -3
  157. package/clis/wikipedia/trending.test.js +57 -0
  158. package/clis/xianyu/chat.js +24 -109
  159. package/clis/xianyu/chat.test.js +5 -0
  160. package/clis/xianyu/im.js +322 -0
  161. package/clis/xianyu/im.test.js +253 -0
  162. package/clis/xianyu/inbox.js +96 -0
  163. package/clis/xianyu/messages.js +91 -0
  164. package/clis/xianyu/reply.js +82 -0
  165. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  166. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  167. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  168. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  169. package/clis/xiaohongshu/creator-notes.js +2 -1
  170. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  171. package/clis/xiaohongshu/creator-stats.js +2 -1
  172. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  173. package/clis/xiaohongshu/delete-note.js +260 -0
  174. package/clis/xiaohongshu/delete-note.test.js +172 -0
  175. package/clis/xiaohongshu/publish.js +48 -8
  176. package/clis/xiaohongshu/publish.test.js +65 -10
  177. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  178. package/clis/xiaohongshu/user.js +27 -4
  179. package/clis/xiaoyuzhou/download.js +1 -1
  180. package/clis/xiaoyuzhou/transcript.js +1 -1
  181. package/clis/youdao/note.js +258 -0
  182. package/clis/youdao/note.test.js +99 -0
  183. package/clis/youtube/transcript.js +397 -24
  184. package/clis/youtube/transcript.test.js +196 -6
  185. package/clis/zhihu/answer-comments.js +299 -0
  186. package/clis/zhihu/answer-comments.test.js +287 -0
  187. package/clis/zhihu/answer-detail.js +12 -0
  188. package/clis/zhihu/answer-detail.test.js +8 -0
  189. package/clis/zhihu/collection.js +15 -2
  190. package/clis/zhihu/collection.test.js +46 -0
  191. package/clis/zhihu/download.js +1 -1
  192. package/clis/zhihu/question.js +42 -9
  193. package/clis/zhihu/question.test.js +111 -9
  194. package/clis/zhihu/search.js +206 -43
  195. package/clis/zhihu/search.test.js +198 -0
  196. package/dist/src/browser/errors.js +4 -2
  197. package/dist/src/browser/errors.test.js +6 -0
  198. package/dist/src/browser/page.js +30 -4
  199. package/dist/src/browser/page.test.js +42 -0
  200. package/dist/src/browser/utils.d.ts +1 -1
  201. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  202. package/dist/src/cli-argv-preprocess.js +138 -0
  203. package/dist/src/cli-argv-preprocess.test.js +79 -0
  204. package/dist/src/convention-audit.js +15 -8
  205. package/dist/src/convention-audit.test.js +21 -0
  206. package/dist/src/download/media-download.js +15 -2
  207. package/dist/src/download/media-download.test.d.ts +1 -0
  208. package/dist/src/download/media-download.test.js +110 -0
  209. package/dist/src/electron-apps.js +1 -1
  210. package/dist/src/electron-apps.test.js +7 -2
  211. package/dist/src/errors.d.ts +17 -0
  212. package/dist/src/errors.js +22 -0
  213. package/dist/src/external-clis.yaml +8 -0
  214. package/dist/src/main.js +14 -2
  215. package/dist/src/utils.d.ts +43 -0
  216. package/dist/src/utils.js +97 -0
  217. package/dist/src/utils.test.d.ts +1 -0
  218. package/dist/src/utils.test.js +155 -0
  219. package/package.json +8 -2
  220. package/scripts/silent-column-drop-baseline.json +0 -52
  221. package/scripts/typed-error-lint-baseline.json +28 -380
  222. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './creator-stats.js';
5
+
6
+ describe('xiaohongshu creator-stats', () => {
7
+ it('throws EmptyResultError when the requested stats period has no data', async () => {
8
+ const cmd = getRegistry().get('xiaohongshu/creator-stats');
9
+ const page = {
10
+ goto: vi.fn().mockResolvedValue(undefined),
11
+ evaluate: vi.fn().mockResolvedValue({
12
+ data: {
13
+ seven: null,
14
+ thirty: {
15
+ view_count: 1,
16
+ view_list: [{ count: 1 }],
17
+ },
18
+ },
19
+ }),
20
+ };
21
+
22
+ await expect(cmd.func(page, { period: 'seven' })).rejects.toBeInstanceOf(EmptyResultError);
23
+ });
24
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Xiaohongshu delete-note: remove a published note via creator center UI.
3
+ *
4
+ * Flow:
5
+ * 1. Navigate to creator note-manager
6
+ * 2. Switch to "已发布" tab (delete is only available on published notes;
7
+ * "审核中" and "未通过" rows do not expose a web delete entry, only mobile)
8
+ * 3. Locate the row whose `data-impression` JSON contains the target noteId
9
+ * 4. Click the inline `<span class="control data-del">` action
10
+ * 5. Click "确定" in the `.d-modal-footer` confirmation modal
11
+ * 6. Poll for the row disappearing from the list
12
+ *
13
+ * Requires: logged into creator.xiaohongshu.com in Chrome.
14
+ */
15
+ import { cli, Strategy } from '@jackwener/opencli/registry';
16
+ import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
17
+ const NOTE_MANAGER_URL = 'https://creator.xiaohongshu.com/new/note-manager';
18
+ const ROW_SETTLE_MS = 3000;
19
+ const MODAL_SETTLE_MS = 2000;
20
+ const VERIFY_TIMEOUT_MS = 10_000;
21
+ const VERIFY_POLL_MS = 1000;
22
+ const NOTE_ID_RE = /^[0-9a-f]{24}$/i;
23
+ function unwrapEvaluateResult(payload) {
24
+ if (payload && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
25
+ return payload.data;
26
+ }
27
+ return payload;
28
+ }
29
+ function requireEvaluateString(payload, context) {
30
+ if (typeof payload !== 'string') {
31
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
32
+ }
33
+ return payload;
34
+ }
35
+ function requireEvaluateBoolean(payload, context) {
36
+ if (typeof payload !== 'boolean') {
37
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
38
+ }
39
+ return payload;
40
+ }
41
+ function requireEvaluateObject(payload, context) {
42
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
43
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
44
+ }
45
+ return payload;
46
+ }
47
+ function requireActionResult(payload, context) {
48
+ const result = requireEvaluateObject(payload, context);
49
+ if (typeof result.ok !== 'boolean') {
50
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
51
+ }
52
+ return result;
53
+ }
54
+ function isXiaohongshuHost(hostname) {
55
+ const host = String(hostname || '').toLowerCase();
56
+ return host === 'xiaohongshu.com' || host.endsWith('.xiaohongshu.com');
57
+ }
58
+ function isSupportedQueryNoteUrl(url) {
59
+ return url.hostname.toLowerCase() === 'creator.xiaohongshu.com'
60
+ && url.pathname.replace(/\/+$/, '') === '/statistics/note-detail';
61
+ }
62
+ function normalizeNoteId(input) {
63
+ const raw = String(input ?? '').trim();
64
+ if (!raw) {
65
+ throw new ArgumentError('xiaohongshu/delete-note: note-id cannot be empty');
66
+ }
67
+ if (NOTE_ID_RE.test(raw))
68
+ return raw.toLowerCase();
69
+ if (!/^https:\/\//i.test(raw)) {
70
+ throw new ArgumentError('xiaohongshu/delete-note: note-id must be a 24-character Xiaohongshu note ID or an exact Xiaohongshu note URL');
71
+ }
72
+ let url;
73
+ try {
74
+ url = new URL(raw);
75
+ }
76
+ catch {
77
+ throw new ArgumentError('xiaohongshu/delete-note: invalid note URL');
78
+ }
79
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isXiaohongshuHost(url.hostname)) {
80
+ throw new ArgumentError('xiaohongshu/delete-note: note URL must be an exact https://*.xiaohongshu.com URL');
81
+ }
82
+ const queryId = url.searchParams.get('noteId') || url.searchParams.get('note_id');
83
+ if (queryId && NOTE_ID_RE.test(queryId) && isSupportedQueryNoteUrl(url))
84
+ return queryId.toLowerCase();
85
+ const pathMatch = url.pathname.match(/^\/(?:explore|note|search_result|discovery\/item)\/([0-9a-f]{24})\/?$/i)
86
+ || url.pathname.match(/^\/user\/profile\/[^/?#]+\/([0-9a-f]{24})\/?$/i);
87
+ if (pathMatch)
88
+ return pathMatch[1].toLowerCase();
89
+ throw new ArgumentError('xiaohongshu/delete-note: note URL must contain a 24-character note ID');
90
+ }
91
+ function buildLocateAndMaybeDeleteScript(noteId, shouldClick) {
92
+ return `
93
+ (cfg => {
94
+ const { targetId, shouldClick } = cfg;
95
+ const isVisible = (el) => !!el && el.offsetParent !== null;
96
+ const matchesNoteId = (impressionRaw) => {
97
+ if (!impressionRaw) return false;
98
+ try {
99
+ const parsed = JSON.parse(impressionRaw);
100
+ const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
101
+ return typeof id === 'string' && id === targetId;
102
+ } catch {
103
+ return false;
104
+ }
105
+ };
106
+ const notes = Array.from(document.querySelectorAll('.note')).filter(isVisible);
107
+ for (const note of notes) {
108
+ if (matchesNoteId(note.getAttribute('data-impression'))) {
109
+ const del = note.querySelector('span.control.data-del');
110
+ if (!del || !isVisible(del)) {
111
+ return { ok: false, kind: 'no_delete_action', visibleRows: notes.length };
112
+ }
113
+ if (!shouldClick) {
114
+ return { ok: true, clicked: false };
115
+ }
116
+ del.click();
117
+ return { ok: true, clicked: true };
118
+ }
119
+ }
120
+ return { ok: false, kind: 'not_found', visibleRows: notes.length };
121
+ })(${JSON.stringify({ targetId: noteId, shouldClick })})
122
+ `;
123
+ }
124
+ function buildVerifyGoneScript(noteId) {
125
+ return `
126
+ (targetId => {
127
+ const matchesNoteId = (impressionRaw) => {
128
+ if (!impressionRaw) return false;
129
+ try {
130
+ const parsed = JSON.parse(impressionRaw);
131
+ const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
132
+ return typeof id === 'string' && id === targetId;
133
+ } catch {
134
+ return false;
135
+ }
136
+ };
137
+ const notes = Array.from(document.querySelectorAll('.note'));
138
+ return notes.some((n) => matchesNoteId(n.getAttribute('data-impression')));
139
+ })(${JSON.stringify(noteId)})
140
+ `;
141
+ }
142
+ cli({
143
+ site: 'xiaohongshu',
144
+ name: 'delete-note',
145
+ access: 'write',
146
+ description: '删除小红书已发布笔记 (creator center UI automation)',
147
+ domain: 'creator.xiaohongshu.com',
148
+ strategy: Strategy.COOKIE,
149
+ navigateBefore: false,
150
+ browser: true,
151
+ args: [
152
+ {
153
+ name: 'note-id',
154
+ required: true,
155
+ positional: true,
156
+ help: 'Note ID (e.g. 6a08ba0b000000000702a893 from xiaohongshu creator-notes / URL)',
157
+ },
158
+ {
159
+ name: 'execute',
160
+ type: 'boolean',
161
+ default: false,
162
+ help: 'Actually click delete + confirm. Default is dry-run target verification only.',
163
+ },
164
+ ],
165
+ columns: ['status', 'note_id', 'message'],
166
+ func: async (page, kwargs) => {
167
+ try {
168
+ const noteId = normalizeNoteId(kwargs['note-id']);
169
+ const execute = kwargs.execute === true;
170
+ await page.goto(NOTE_MANAGER_URL);
171
+ await page.wait({ time: ROW_SETTLE_MS / 1000 });
172
+ // Detect login redirect (creator.xiaohongshu.com bounces to /login on auth failure)
173
+ const currentUrl = requireEvaluateString(unwrapEvaluateResult(await page.evaluate('() => location.href')), 'current-url');
174
+ if (typeof currentUrl === 'string' && /\/login(?:[/?#]|$)/i.test(new URL(currentUrl).pathname + new URL(currentUrl).search)) {
175
+ throw new AuthRequiredError('creator.xiaohongshu.com');
176
+ }
177
+ // Step 1: ensure 已发布 tab is active (delete only exposed there).
178
+ const tabClicked = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(`
179
+ () => {
180
+ const isVisible = (el) => !!el && el.offsetParent !== null;
181
+ for (const el of document.querySelectorAll('a, button, [role="tab"], div')) {
182
+ const text = (el.innerText || el.textContent || '').trim();
183
+ if (text === '已发布' && isVisible(el)) {
184
+ el.click();
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ }
190
+ `)), 'published-tab');
191
+ if (!tabClicked) {
192
+ throw new CommandExecutionError('xiaohongshu/delete-note: 已发布 tab not found on note-manager; xhs creator UI may have changed.');
193
+ }
194
+ await page.wait({ time: ROW_SETTLE_MS / 1000 });
195
+ // Step 2: locate the .note row whose data-impression JSON carries the
196
+ // exact `noteId` field. Dry-run stops here; execute clicks delete.
197
+ // Substring matching on the raw attribute would risk matching unrelated
198
+ // fields whose values happen to share the noteId prefix, so parse the JSON
199
+ // and compare `noteTarget.value.noteId` explicitly.
200
+ const initResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(buildLocateAndMaybeDeleteScript(noteId, execute))), 'locate-note');
201
+ if (!initResult?.ok) {
202
+ if (initResult?.kind === 'not_found') {
203
+ throw new EmptyResultError('xiaohongshu/delete-note', `Note ${noteId} not visible in the 已发布 tab. Verify the note belongs to the logged-in account and has cleared review (审核中 / 未通过 rows have no web delete entry).`);
204
+ }
205
+ if (initResult?.kind === 'no_delete_action') {
206
+ throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} row found but no delete action visible; xhs creator UI may have changed.`);
207
+ }
208
+ throw new CommandExecutionError('xiaohongshu/delete-note: failed to locate note row');
209
+ }
210
+ if (!execute) {
211
+ return [{ status: 'dry-run', note_id: noteId, message: 'Target note row and delete action verified. Re-run with --execute to delete.' }];
212
+ }
213
+ await page.wait({ time: MODAL_SETTLE_MS / 1000 });
214
+ // Step 3: click "确定" in the `.d-modal-footer` confirmation modal.
215
+ const confirmResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(`
216
+ () => {
217
+ const isVisible = (el) => !!el && el.offsetParent !== null;
218
+ const footer = Array.from(document.querySelectorAll('.d-modal-footer')).find(isVisible);
219
+ if (!footer) return { ok: false, kind: 'no_modal' };
220
+ const buttons = Array.from(footer.querySelectorAll('button, [role="button"]')).filter(isVisible);
221
+ const confirmBtn = buttons.find((b) => (b.innerText || b.textContent || '').trim() === '确定');
222
+ if (!confirmBtn) return { ok: false, kind: 'no_confirm', labels: buttons.map(b => (b.innerText || '').trim()) };
223
+ confirmBtn.click();
224
+ return { ok: true };
225
+ }
226
+ `)), 'confirm-modal');
227
+ if (!confirmResult?.ok) {
228
+ throw new CommandExecutionError(`xiaohongshu/delete-note: confirmation modal step failed (${confirmResult?.kind ?? 'unknown'})`);
229
+ }
230
+ // Step 4: poll for row removal (proves the delete actually committed,
231
+ // not just the modal was clicked). Iteration-bounded rather than
232
+ // wall-clock so tests with a mocked `page.wait` exhaust the loop
233
+ // quickly instead of stalling on real time.
234
+ const VERIFY_ITERATIONS = Math.ceil(VERIFY_TIMEOUT_MS / VERIFY_POLL_MS);
235
+ let stillPresent = true;
236
+ for (let i = 0; i < VERIFY_ITERATIONS; i++) {
237
+ await page.wait({ time: VERIFY_POLL_MS / 1000 });
238
+ const probe = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(buildVerifyGoneScript(noteId))), 'verify-gone');
239
+ if (probe === false) {
240
+ stillPresent = false;
241
+ break;
242
+ }
243
+ }
244
+ if (stillPresent) {
245
+ throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} still visible after confirm click; deletion may not have committed.`);
246
+ }
247
+ return [{ status: 'deleted', note_id: noteId, message: 'Delete confirmed and note row disappeared.' }];
248
+ }
249
+ catch (err) {
250
+ if (err instanceof CliError)
251
+ throw err;
252
+ throw new CommandExecutionError(`xiaohongshu/delete-note failed: ${err?.message ?? String(err)}`);
253
+ }
254
+ },
255
+ });
256
+ export const __test__ = {
257
+ normalizeNoteId,
258
+ buildLocateAndMaybeDeleteScript,
259
+ buildVerifyGoneScript,
260
+ };
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
5
+
6
+ import { __test__ } from './delete-note.js';
7
+
8
+ function makePage(evaluateResults = []) {
9
+ const evaluate = vi.fn();
10
+ for (const r of evaluateResults) evaluate.mockResolvedValueOnce(r);
11
+ evaluate.mockResolvedValue(undefined);
12
+ return {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate,
16
+ };
17
+ }
18
+
19
+ describe('xiaohongshu delete-note command', () => {
20
+ const getCommand = () => getRegistry().get('xiaohongshu/delete-note');
21
+ const validId = '6a08ba0b000000000702a893';
22
+
23
+ it('returns deleted status when delete + confirm + verify all succeed', async () => {
24
+ const page = makePage([
25
+ 'https://creator.xiaohongshu.com/new/note-manager', // currentUrl
26
+ true, // 已发布 tab click
27
+ { ok: true, clicked: true }, // initResult: row found + delete clicked
28
+ { ok: true }, // confirmResult
29
+ false, // verify probe: row gone
30
+ ]);
31
+ const result = await getCommand().func(page, { 'note-id': validId, execute: true });
32
+ expect(result).toEqual([
33
+ { status: 'deleted', note_id: validId, message: 'Delete confirmed and note row disappeared.' },
34
+ ]);
35
+ expect(page.goto).toHaveBeenCalledWith('https://creator.xiaohongshu.com/new/note-manager');
36
+ });
37
+
38
+ it('dry-runs by default after verifying the exact target and delete affordance', async () => {
39
+ const page = makePage([
40
+ 'https://creator.xiaohongshu.com/new/note-manager',
41
+ true,
42
+ { ok: true, clicked: false },
43
+ ]);
44
+ const result = await getCommand().func(page, { 'note-id': validId });
45
+ expect(result).toEqual([
46
+ { status: 'dry-run', note_id: validId, message: 'Target note row and delete action verified. Re-run with --execute to delete.' },
47
+ ]);
48
+ expect(page.evaluate).toHaveBeenCalledTimes(3);
49
+ });
50
+
51
+ it('unwraps browser bridge envelopes at every evaluate boundary', async () => {
52
+ const page = makePage([
53
+ { session: 's', data: 'https://creator.xiaohongshu.com/new/note-manager' },
54
+ { session: 's', data: true },
55
+ { session: 's', data: { ok: true, clicked: true } },
56
+ { session: 's', data: { ok: true } },
57
+ { session: 's', data: false },
58
+ ]);
59
+ const result = await getCommand().func(page, { 'note-id': validId, execute: true });
60
+ expect(result[0]).toMatchObject({ status: 'deleted', note_id: validId });
61
+ });
62
+
63
+ it('normalizes exact Xiaohongshu note IDs from supported URL forms', () => {
64
+ expect(__test__.normalizeNoteId(validId.toUpperCase())).toBe(validId);
65
+ expect(__test__.normalizeNoteId(`https://www.xiaohongshu.com/explore/${validId}?xsec_token=t`)).toBe(validId);
66
+ expect(__test__.normalizeNoteId(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${validId}`)).toBe(validId);
67
+ });
68
+
69
+ it('throws ArgumentError for missing or ambiguous note identity before navigation', async () => {
70
+ const page = makePage();
71
+ await expect(getCommand().func(page, { 'note-id': '' })).rejects.toBeInstanceOf(ArgumentError);
72
+ await expect(getCommand().func(page, { 'note-id': ' ' })).rejects.toBeInstanceOf(ArgumentError);
73
+ await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toBeInstanceOf(ArgumentError);
74
+ await expect(getCommand().func(page, { 'note-id': 'https://evil.com/explore/6a08ba0b000000000702a893' })).rejects.toBeInstanceOf(ArgumentError);
75
+ await expect(getCommand().func(page, { 'note-id': 'https://xhslink.com/abc' })).rejects.toBeInstanceOf(ArgumentError);
76
+ await expect(getCommand().func(page, { 'note-id': `https://www.xiaohongshu.com/anything?noteId=${validId}` })).rejects.toBeInstanceOf(ArgumentError);
77
+ expect(page.goto).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('throws CommandExecutionError for malformed evaluate payloads instead of trusting truthy objects', async () => {
81
+ await expect(getCommand().func(makePage([
82
+ { session: 's', data: { href: 'https://creator.xiaohongshu.com/new/note-manager' } },
83
+ ]), { 'note-id': validId })).rejects.toThrowError(/malformed current-url payload/);
84
+
85
+ await expect(getCommand().func(makePage([
86
+ 'https://creator.xiaohongshu.com/new/note-manager',
87
+ { session: 's', data: { ok: true } },
88
+ ]), { 'note-id': validId })).rejects.toThrowError(/malformed published-tab payload/);
89
+
90
+ await expect(getCommand().func(makePage([
91
+ 'https://creator.xiaohongshu.com/new/note-manager',
92
+ true,
93
+ { session: 's', data: { ok: 'yes', clicked: false } },
94
+ ]), { 'note-id': validId })).rejects.toThrowError(/malformed locate-note payload/);
95
+ });
96
+
97
+ it('throws AuthRequiredError when redirected to login', async () => {
98
+ const page = makePage([
99
+ 'https://creator.xiaohongshu.com/login?redirectReason=401',
100
+ ]);
101
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toBeInstanceOf(AuthRequiredError);
102
+ });
103
+
104
+ it('throws CommandExecutionError when 已发布 tab cannot be clicked (UI drift)', async () => {
105
+ const page = makePage([
106
+ 'https://creator.xiaohongshu.com/new/note-manager',
107
+ false, // tab click returns false
108
+ ]);
109
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toThrowError(/已发布 tab not found/);
110
+ });
111
+
112
+ it('throws EmptyResultError when the note row is not in the 已发布 tab', async () => {
113
+ const page = makePage([
114
+ 'https://creator.xiaohongshu.com/new/note-manager',
115
+ true,
116
+ { ok: false, kind: 'not_found', visibleRows: 0 },
117
+ ]);
118
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toBeInstanceOf(EmptyResultError);
119
+ });
120
+
121
+ it('throws CommandExecutionError when the row has no visible delete action', async () => {
122
+ const page = makePage([
123
+ 'https://creator.xiaohongshu.com/new/note-manager',
124
+ true,
125
+ { ok: false, kind: 'no_delete_action', visibleRows: 1 },
126
+ ]);
127
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toThrowError(/no delete action/i);
128
+ });
129
+
130
+ it('throws CommandExecutionError when the confirmation modal does not appear', async () => {
131
+ const page = makePage([
132
+ 'https://creator.xiaohongshu.com/new/note-manager',
133
+ true,
134
+ { ok: true },
135
+ { ok: false, kind: 'no_modal' },
136
+ ]);
137
+ await expect(getCommand().func(page, { 'note-id': validId, execute: true })).rejects.toThrowError(/no_modal/);
138
+ });
139
+
140
+ it('throws CommandExecutionError when row stays visible after confirm (delete did not commit)', async () => {
141
+ // verify probes return true (note still present) for the entire poll window.
142
+ const probes = Array(15).fill(true);
143
+ const page = makePage([
144
+ 'https://creator.xiaohongshu.com/new/note-manager',
145
+ true,
146
+ { ok: true },
147
+ { ok: true },
148
+ ...probes,
149
+ ]);
150
+ await expect(getCommand().func(page, { 'note-id': validId, execute: true })).rejects.toThrowError(/still visible after confirm/i);
151
+ });
152
+
153
+ it('executes the generated row locator without substring-matching other impression fields', () => {
154
+ const otherId = '6a08ba0b000000000702a894';
155
+ const dom = new JSDOM(`
156
+ <div class="note" data-impression='{"noteTarget":{"value":{"noteId":"${otherId}"}},"title":"${validId}"}'>
157
+ <span class="control data-del">删除</span>
158
+ </div>
159
+ <div class="note" data-impression='{"noteTarget":{"value":{"noteId":"${validId}"}}}'>
160
+ <span class="control data-del">删除</span>
161
+ </div>
162
+ `, { runScripts: 'outside-only' });
163
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetParent', {
164
+ configurable: true,
165
+ get() {
166
+ return this.ownerDocument.body;
167
+ },
168
+ });
169
+ const result = dom.window.eval(__test__.buildLocateAndMaybeDeleteScript(validId, false));
170
+ expect(result).toEqual({ ok: true, clicked: false });
171
+ });
172
+ });
@@ -22,6 +22,14 @@ const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_l
22
22
  const MAX_IMAGES = 9;
23
23
  const MAX_TITLE_LEN = 20;
24
24
  const UPLOAD_SETTLE_MS = 3000;
25
+ /**
26
+ * XHS creator center wraps the publish/save button in an `<xhs-publish-btn>`
27
+ * web component backed by a CLOSED shadow root. Host-level `.click()` does
28
+ * not dispatch into the internal handler. Invoke these instance methods on
29
+ * the host element to trigger publish / save-draft directly (#1606).
30
+ */
31
+ const PUBLISH_METHOD_NAMES = ['_onPublish', 'onPublish', '_onSubmit', '_handlePublish'];
32
+ const DRAFT_METHOD_NAMES = ['_onSave', '_onSaveDraft', '_onDraft'];
25
33
  /** Selectors for the title field, ordered by priority across current UI variants. */
26
34
  const TITLE_SELECTORS = [
27
35
  // Some creator-center variants expose the title as contenteditable,
@@ -610,26 +618,58 @@ cli({
610
618
  }
611
619
  // ── Step 7: Publish or save draft ─────────────────────────────────────────
612
620
  const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
613
- const btnClicked = await page.evaluate(`
614
- (labels => {
621
+ const invokeResult = await page.evaluate(`
622
+ (cfg => {
623
+ const { isDraftMode, publishNames, draftNames, labels } = cfg;
624
+ const isVisible = (el) => {
625
+ if (!el || el.offsetParent === null) return false;
626
+ const rect = el.getBoundingClientRect();
627
+ return rect.width > 0 && rect.height > 0;
628
+ };
629
+ // Path 1: web component method invoke on <xhs-publish-btn>.
630
+ const hosts = Array.from(document.querySelectorAll('xhs-publish-btn')).filter(isVisible);
631
+ const wanted = isDraftMode ? draftNames : publishNames;
632
+ // Try every host + every candidate; do NOT bail on the first throw
633
+ // (multiple hosts can exist, and a later name may succeed).
634
+ let lastMethodError = null;
635
+ for (const host of hosts) {
636
+ for (const name of wanted) {
637
+ if (typeof host[name] !== 'function') continue;
638
+ try {
639
+ host[name]();
640
+ return { ok: true, via: 'method', name };
641
+ } catch (err) {
642
+ lastMethodError = String(err && err.message || err);
643
+ }
644
+ }
645
+ }
646
+ // Path 2: legacy <button>/[role=button] text-match click fallback.
615
647
  const buttons = document.querySelectorAll('button, [role="button"]');
616
648
  for (const btn of buttons) {
617
649
  const text = (btn.innerText || btn.textContent || '').trim();
618
650
  if (
619
651
  labels.some(l => text === l || text.includes(l)) &&
620
- btn.offsetParent !== null &&
652
+ isVisible(btn) &&
621
653
  !btn.disabled
622
654
  ) {
623
655
  btn.click();
624
- return true;
656
+ return { ok: true, via: 'click', text };
625
657
  }
626
658
  }
627
- return false;
628
- })(${JSON.stringify(actionLabels)})
659
+ return { ok: false, via: 'none', hosts: hosts.length, lastMethodError };
660
+ })(${JSON.stringify({
661
+ isDraftMode: isDraft,
662
+ publishNames: PUBLISH_METHOD_NAMES,
663
+ draftNames: DRAFT_METHOD_NAMES,
664
+ labels: actionLabels,
665
+ })})
629
666
  `);
630
- if (!btnClicked) {
667
+ if (!invokeResult?.ok) {
631
668
  await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
632
- throw new Error(`Could not find "${actionLabels[0]}" button. ` +
669
+ const viaClause = invokeResult?.via ? ` (via=${invokeResult.via})` : '';
670
+ const errorClause = invokeResult?.error ? `, error=${invokeResult.error}` : '';
671
+ const lastMethodClause = invokeResult?.lastMethodError ? `, lastMethodError=${invokeResult.lastMethodError}` : '';
672
+ throw new Error(`Could not trigger "${actionLabels[0]}" action${viaClause}${errorClause}${lastMethodClause}. ` +
633
673
  'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
634
674
  }
635
675
  // ── Step 8: Verify success ─────────────────────────────────────────────────