@jackwener/opencli 1.6.7 → 1.6.9

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 (122) hide show
  1. package/README.md +5 -1
  2. package/README.zh-CN.md +8 -3
  3. package/dist/clis/1688/assets.d.ts +42 -0
  4. package/dist/clis/1688/assets.js +204 -0
  5. package/dist/clis/1688/assets.test.d.ts +1 -0
  6. package/dist/clis/1688/assets.test.js +39 -0
  7. package/dist/clis/1688/download.d.ts +9 -0
  8. package/dist/clis/1688/download.js +76 -0
  9. package/dist/clis/1688/download.test.d.ts +1 -0
  10. package/dist/clis/1688/download.test.js +31 -0
  11. package/dist/clis/1688/shared.d.ts +10 -0
  12. package/dist/clis/1688/shared.js +43 -0
  13. package/dist/clis/jianyu/search.d.ts +14 -0
  14. package/dist/clis/jianyu/search.js +135 -0
  15. package/dist/clis/jianyu/search.test.d.ts +1 -0
  16. package/dist/clis/jianyu/search.test.js +23 -0
  17. package/dist/clis/linux-do/topic-content.d.ts +35 -0
  18. package/dist/clis/linux-do/topic-content.js +154 -0
  19. package/dist/clis/linux-do/topic-content.test.d.ts +1 -0
  20. package/dist/clis/linux-do/topic-content.test.js +59 -0
  21. package/dist/clis/linux-do/topic.yaml +1 -16
  22. package/dist/clis/quark/ls.d.ts +1 -0
  23. package/dist/clis/quark/ls.js +63 -0
  24. package/dist/clis/quark/mkdir.d.ts +1 -0
  25. package/dist/clis/quark/mkdir.js +36 -0
  26. package/dist/clis/quark/mv.d.ts +1 -0
  27. package/dist/clis/quark/mv.js +53 -0
  28. package/dist/clis/quark/rename.d.ts +1 -0
  29. package/dist/clis/quark/rename.js +26 -0
  30. package/dist/clis/quark/rm.d.ts +1 -0
  31. package/dist/clis/quark/rm.js +24 -0
  32. package/dist/clis/quark/save.d.ts +1 -0
  33. package/dist/clis/quark/save.js +80 -0
  34. package/dist/clis/quark/share-tree.d.ts +1 -0
  35. package/dist/clis/quark/share-tree.js +45 -0
  36. package/dist/clis/quark/utils.d.ts +50 -0
  37. package/dist/clis/quark/utils.js +146 -0
  38. package/dist/clis/quark/utils.test.d.ts +1 -0
  39. package/dist/clis/quark/utils.test.js +58 -0
  40. package/dist/clis/twitter/reply.js +3 -8
  41. package/dist/clis/twitter/reply.test.js +5 -5
  42. package/dist/clis/xiaohongshu/note.js +8 -3
  43. package/dist/clis/xiaohongshu/note.test.js +11 -0
  44. package/dist/clis/xueqiu/groups.yaml +23 -0
  45. package/dist/clis/xueqiu/kline.yaml +65 -0
  46. package/dist/clis/xueqiu/watchlist.yaml +9 -9
  47. package/dist/clis/zhihu/answer.d.ts +1 -0
  48. package/dist/clis/zhihu/answer.js +194 -0
  49. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  50. package/dist/clis/zhihu/answer.test.js +81 -0
  51. package/dist/clis/zhihu/comment.d.ts +1 -0
  52. package/dist/clis/zhihu/comment.js +335 -0
  53. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  54. package/dist/clis/zhihu/comment.test.js +54 -0
  55. package/dist/clis/zhihu/favorite.d.ts +1 -0
  56. package/dist/clis/zhihu/favorite.js +224 -0
  57. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  58. package/dist/clis/zhihu/favorite.test.js +196 -0
  59. package/dist/clis/zhihu/follow.d.ts +1 -0
  60. package/dist/clis/zhihu/follow.js +80 -0
  61. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  62. package/dist/clis/zhihu/follow.test.js +45 -0
  63. package/dist/clis/zhihu/like.d.ts +1 -0
  64. package/dist/clis/zhihu/like.js +91 -0
  65. package/dist/clis/zhihu/like.test.d.ts +1 -0
  66. package/dist/clis/zhihu/like.test.js +64 -0
  67. package/dist/clis/zhihu/target.d.ts +24 -0
  68. package/dist/clis/zhihu/target.js +91 -0
  69. package/dist/clis/zhihu/target.test.d.ts +1 -0
  70. package/dist/clis/zhihu/target.test.js +77 -0
  71. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  72. package/dist/clis/zhihu/write-shared.js +221 -0
  73. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  74. package/dist/clis/zhihu/write-shared.test.js +175 -0
  75. package/dist/src/analysis.d.ts +2 -0
  76. package/dist/src/analysis.js +6 -0
  77. package/dist/src/browser/bridge.d.ts +2 -0
  78. package/dist/src/browser/bridge.js +30 -24
  79. package/dist/src/browser/cdp.js +96 -0
  80. package/dist/src/browser/daemon-client.d.ts +17 -8
  81. package/dist/src/browser/daemon-client.js +12 -13
  82. package/dist/src/browser/daemon-client.test.js +32 -25
  83. package/dist/src/browser/index.d.ts +2 -1
  84. package/dist/src/browser/index.js +1 -1
  85. package/dist/src/browser.test.js +2 -3
  86. package/dist/src/build-manifest.d.ts +3 -1
  87. package/dist/src/build-manifest.js +10 -7
  88. package/dist/src/build-manifest.test.js +8 -4
  89. package/dist/src/cli.d.ts +2 -1
  90. package/dist/src/cli.js +48 -46
  91. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  92. package/dist/src/clis/binance/commands.test.js +54 -0
  93. package/dist/src/commanderAdapter.js +19 -6
  94. package/dist/src/commands/daemon.js +2 -10
  95. package/dist/src/diagnostic.d.ts +28 -2
  96. package/dist/src/diagnostic.js +263 -25
  97. package/dist/src/diagnostic.test.js +220 -1
  98. package/dist/src/discovery.js +7 -17
  99. package/dist/src/doctor.d.ts +2 -0
  100. package/dist/src/doctor.js +59 -31
  101. package/dist/src/doctor.test.js +89 -16
  102. package/dist/src/download/progress.js +7 -2
  103. package/dist/src/execution.js +1 -13
  104. package/dist/src/explore.d.ts +0 -2
  105. package/dist/src/explore.js +61 -38
  106. package/dist/src/extension-manifest-regression.test.js +0 -1
  107. package/dist/src/generate.d.ts +3 -6
  108. package/dist/src/generate.js +4 -8
  109. package/dist/src/package-paths.d.ts +8 -0
  110. package/dist/src/package-paths.js +41 -0
  111. package/dist/src/plugin-scaffold.js +1 -3
  112. package/dist/src/plugin.d.ts +2 -1
  113. package/dist/src/plugin.js +25 -8
  114. package/dist/src/plugin.test.js +16 -1
  115. package/dist/src/record.d.ts +1 -2
  116. package/dist/src/record.js +14 -52
  117. package/dist/src/synthesize.d.ts +0 -2
  118. package/dist/src/synthesize.js +8 -4
  119. package/package.json +3 -3
  120. package/dist/cli-manifest.json +0 -17250
  121. package/dist/src/browser/discover.d.ts +0 -15
  122. package/dist/src/browser/discover.js +0 -19
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './comment.js';
4
+ describe('zhihu comment', () => {
5
+ it('rejects composer paths that are not proven side-effect free', async () => {
6
+ const cmd = getRegistry().get('zhihu/comment');
7
+ expect(cmd?.func).toBeTypeOf('function');
8
+ const page = {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn()
11
+ .mockResolvedValueOnce({ slug: 'alice' })
12
+ .mockResolvedValueOnce({ entryPathSafe: false })
13
+ .mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] }),
14
+ };
15
+ await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
16
+ });
17
+ it('requires exact editor replacement before accepting fallback proof', async () => {
18
+ const cmd = getRegistry().get('zhihu/comment');
19
+ const page = {
20
+ goto: vi.fn().mockResolvedValue(undefined),
21
+ evaluate: vi.fn()
22
+ .mockResolvedValueOnce({ slug: 'alice' })
23
+ .mockResolvedValueOnce({ entryPathSafe: true })
24
+ .mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] })
25
+ .mockResolvedValueOnce({ composerState: 'fresh_top_level' })
26
+ .mockResolvedValueOnce({ editorContent: 'hello', mode: 'top_level' })
27
+ .mockResolvedValueOnce({
28
+ proofType: 'fallback',
29
+ createdProof: {
30
+ proof_type: 'comment_fallback',
31
+ author_scope: 'current_user',
32
+ target_scope: 'requested_target',
33
+ comment_scope: 'top_level_only',
34
+ content_match: 'exact_normalized',
35
+ observed_after_submit: true,
36
+ present_in_pre_submit_snapshot: false,
37
+ new_matching_entries: 1,
38
+ post_submit_matching_entries: 1,
39
+ snapshot_scope: 'stabilized_expanded_target_comment_list',
40
+ },
41
+ }),
42
+ };
43
+ await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).resolves.toEqual([
44
+ expect.objectContaining({ outcome: 'created', author_identity: 'alice', created_proof: expect.any(Object) }),
45
+ ]);
46
+ expect(page.evaluate.mock.calls[1][0]).toContain('topLevelCandidates.length === 1');
47
+ expect(page.evaluate.mock.calls[1][0]).not.toContain('commentTrigger');
48
+ expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-answerid')");
49
+ expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-zop-question-answer')");
50
+ expect(page.evaluate.mock.calls[5][0]).toContain('const readCommentAuthorSlug = (node) =>');
51
+ expect(page.evaluate.mock.calls[5][0]).toContain('const commentAuthorScopeSelector = ".CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop=\\"author\\"]"');
52
+ expect(page.evaluate.mock.calls[5][0]).not.toContain("card?.querySelector('a[href^=\"/people/\"]')");
53
+ });
54
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,224 @@
1
+ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { assertAllowedKinds, parseTarget } from './target.js';
4
+ import { buildResultRow, requireExecute } from './write-shared.js';
5
+ function rowKey(row) {
6
+ return row.id || `name:${normalizeCollectionName(row.name)}`;
7
+ }
8
+ function normalizeCollectionName(value) {
9
+ return value
10
+ .replace(/\s+/g, ' ')
11
+ .replace(/\s+\d+\s*(条内容|个内容|items?)$/i, '')
12
+ .replace(/\s+(公开|私密|默认)$/i, '')
13
+ .trim();
14
+ }
15
+ cli({
16
+ site: 'zhihu',
17
+ name: 'favorite',
18
+ description: 'Favorite a Zhihu answer or article into a specific collection',
19
+ domain: 'zhihu.com',
20
+ strategy: Strategy.UI,
21
+ browser: true,
22
+ args: [
23
+ { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
24
+ { name: 'collection', help: 'Collection name' },
25
+ { name: 'collection-id', help: 'Stable collection id' },
26
+ { name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
27
+ ],
28
+ columns: ['status', 'outcome', 'message', 'target_type', 'target', 'collection_name', 'collection_id'],
29
+ func: async (page, kwargs) => {
30
+ if (!page)
31
+ throw new CommandExecutionError('Browser session required for zhihu favorite');
32
+ requireExecute(kwargs);
33
+ const rawTarget = String(kwargs.target);
34
+ const target = assertAllowedKinds('favorite', parseTarget(rawTarget));
35
+ const collectionName = typeof kwargs.collection === 'string' ? kwargs.collection : undefined;
36
+ const collectionId = typeof kwargs['collection-id'] === 'string' ? kwargs['collection-id'] : undefined;
37
+ if ((collectionName ? 1 : 0) + (collectionId ? 1 : 0) !== 1) {
38
+ throw new CliError('INVALID_INPUT', 'Use exactly one of --collection or --collection-id');
39
+ }
40
+ await page.goto(target.url);
41
+ const preflight = await page.evaluate(`(async () => {
42
+ const targetKind = ${JSON.stringify(target.kind)};
43
+ const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
44
+ const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
45
+ const wantedName = ${JSON.stringify(collectionName ?? null)};
46
+ const wantedId = ${JSON.stringify(collectionId ?? null)};
47
+
48
+ let scope = document;
49
+ if (targetKind === 'answer') {
50
+ const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
51
+ const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
52
+ if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
53
+ return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
54
+ const href = link.getAttribute('href') || '';
55
+ return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
56
+ });
57
+ });
58
+ if (!block) return { wrongAnswer: true, chooserRows: [] };
59
+ scope = block;
60
+ } else {
61
+ scope =
62
+ document.querySelector('article')
63
+ || document.querySelector('.Post-Main')
64
+ || document.querySelector('[itemprop="articleBody"]')
65
+ || document;
66
+ }
67
+
68
+ const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
69
+ if (!favoriteButton) return { wrongAnswer: false, missingChooser: true, chooserRows: [] };
70
+ favoriteButton.click();
71
+ await new Promise((resolve) => setTimeout(resolve, 600));
72
+
73
+ const chooserRows = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button'))
74
+ .map((node) => {
75
+ const text = (node.textContent || '').trim();
76
+ const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
77
+ const selected = node.getAttribute('aria-checked') === 'true'
78
+ || node.getAttribute('aria-pressed') === 'true'
79
+ || /已选|已收藏/.test(text);
80
+ return text ? { id, name: text, selected } : null;
81
+ })
82
+ .filter(Boolean);
83
+
84
+ return {
85
+ wrongAnswer: false,
86
+ missingChooser: chooserRows.length === 0,
87
+ chooserRows,
88
+ targetRowId: wantedId,
89
+ targetRowName: wantedName,
90
+ };
91
+ })()`);
92
+ if (preflight.wrongAnswer) {
93
+ throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
94
+ }
95
+ if (preflight.missingChooser) {
96
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser did not open on the requested target');
97
+ }
98
+ const matchingRows = preflight.chooserRows.filter((row) => (collectionId
99
+ ? row.id === collectionId
100
+ : normalizeCollectionName(row.name) === normalizeCollectionName(collectionName || '')));
101
+ if (collectionId && !matchingRows.some((row) => row.id === collectionId)) {
102
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not confirm the requested stable collection id');
103
+ }
104
+ if (!collectionId && matchingRows.length !== 1) {
105
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not prove that the requested collection name is globally unique');
106
+ }
107
+ const targetRow = matchingRows[0];
108
+ const targetRowKey = rowKey(targetRow);
109
+ const selectedBefore = preflight.chooserRows.filter((row) => row.selected).map(rowKey);
110
+ const verify = await page.evaluate(`(async () => {
111
+ const targetKind = ${JSON.stringify(target.kind)};
112
+ const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
113
+ const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
114
+ const targetWasSelected = ${JSON.stringify(targetRow.selected)};
115
+ const wantedName = ${JSON.stringify(collectionName ?? null)};
116
+ const wantedId = ${JSON.stringify(collectionId ?? null)};
117
+ const normalizeCollectionName = (value) => String(value || '')
118
+ .replace(/\\s+/g, ' ')
119
+ .replace(/\\s+\\d+\\s*(条内容|个内容|items?)$/i, '')
120
+ .replace(/\\s+(公开|私密|默认)$/i, '')
121
+ .trim();
122
+ const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);
123
+
124
+ const chooserSelector = '[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button';
125
+ const readChooserRows = () => Array.from(document.querySelectorAll(chooserSelector))
126
+ .map((node) => {
127
+ const text = (node.textContent || '').trim();
128
+ const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
129
+ const selected = node.getAttribute('aria-checked') === 'true'
130
+ || node.getAttribute('aria-pressed') === 'true'
131
+ || /已选|已收藏/.test(text);
132
+ return text ? { id, name: text, selected } : null;
133
+ })
134
+ .filter(Boolean);
135
+ const waitForChooserRows = async (expectedPresent) => {
136
+ for (let attempt = 0; attempt < 10; attempt += 1) {
137
+ const rows = readChooserRows();
138
+ if (expectedPresent ? rows.length > 0 : rows.length === 0) return rows;
139
+ await new Promise((resolve) => setTimeout(resolve, 150));
140
+ }
141
+ return readChooserRows();
142
+ };
143
+ const closeChooser = async () => {
144
+ const closeButton = Array.from(document.querySelectorAll('[role="dialog"] button, [role="dialog"] [role="button"]')).find((node) => {
145
+ const text = (node.textContent || '').trim();
146
+ const aria = node.getAttribute('aria-label') || '';
147
+ return /关闭|取消|收起/.test(text) || /关闭|cancel|close/i.test(aria);
148
+ });
149
+ closeButton && closeButton.click();
150
+ return waitForChooserRows(false);
151
+ };
152
+ const reopenChooser = async () => {
153
+ let scope = document;
154
+ if (targetKind === 'answer') {
155
+ const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
156
+ const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
157
+ if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
158
+ return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
159
+ const href = link.getAttribute('href') || '';
160
+ return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
161
+ });
162
+ });
163
+ if (!block) return [];
164
+ scope = block;
165
+ } else {
166
+ scope =
167
+ document.querySelector('article')
168
+ || document.querySelector('.Post-Main')
169
+ || document.querySelector('[itemprop="articleBody"]')
170
+ || document;
171
+ }
172
+ const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
173
+ favoriteButton && favoriteButton.click();
174
+ return waitForChooserRows(true);
175
+ };
176
+
177
+ let chooserRows = readChooserRows();
178
+ let sawChooserClose = false;
179
+ if (!targetWasSelected) {
180
+ const row = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button')).find((node) => {
181
+ const text = (node.textContent || '').trim();
182
+ const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
183
+ return wantedId ? id === wantedId : normalizeCollectionName(text) === normalizeCollectionName(wantedName);
184
+ });
185
+ row && row.click();
186
+ await new Promise((resolve) => setTimeout(resolve, 300));
187
+ const submit = Array.from(document.querySelectorAll('[role="dialog"] button')).find((node) => /完成|确定|保存/.test(node.textContent || ''));
188
+ submit && submit.click();
189
+ chooserRows = await waitForChooserRows(false);
190
+ sawChooserClose = chooserRows.length === 0;
191
+ } else {
192
+ chooserRows = await closeChooser();
193
+ sawChooserClose = chooserRows.length === 0;
194
+ }
195
+ if (sawChooserClose) {
196
+ chooserRows = await reopenChooser();
197
+ }
198
+
199
+ return {
200
+ persisted: sawChooserClose && chooserRows.length > 0,
201
+ readbackSource: sawChooserClose && chooserRows.length > 0 ? 'reopened_chooser' : (chooserRows.length > 0 ? 'same_modal' : 'missing'),
202
+ selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey),
203
+ targetSelected: chooserRows.some((row) => rowKey(row) === ${JSON.stringify(targetRowKey)} && row.selected),
204
+ };
205
+ })()`);
206
+ if (!verify.persisted) {
207
+ throw new CliError('OUTCOME_UNKNOWN', 'Favorite action may have been applied, but persisted read-back was unavailable');
208
+ }
209
+ if (verify.readbackSource !== 'reopened_chooser') {
210
+ throw new CliError('OUTCOME_UNKNOWN', 'Favorite state was not re-read from a reopened chooser after submit');
211
+ }
212
+ if (!verify.targetSelected) {
213
+ throw new CliError('OUTCOME_UNKNOWN', 'Favorite chooser remained readable, but the requested collection was not confirmed as selected');
214
+ }
215
+ if (!selectedBefore.every((row) => verify.selectedAfter.includes(row))) {
216
+ throw new CliError('OUTCOME_UNKNOWN', `Favorite action changed unrelated collection membership: before=${JSON.stringify(selectedBefore)} after=${JSON.stringify(verify.selectedAfter)}`);
217
+ }
218
+ const outcome = targetRow.selected ? 'already_applied' : 'applied';
219
+ return buildResultRow(targetRow.selected ? `Already favorited ${target.kind}` : `Favorited ${target.kind}`, target.kind, rawTarget, outcome, {
220
+ collection_name: collectionName ?? targetRow.name,
221
+ ...(targetRow.id ? { collection_id: targetRow.id } : {}),
222
+ });
223
+ },
224
+ });
@@ -0,0 +1 @@
1
+ import './favorite.js';
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './favorite.js';
4
+ describe('zhihu favorite', () => {
5
+ it('rejects missing collection selectors before opening the chooser', async () => {
6
+ const cmd = getRegistry().get('zhihu/favorite');
7
+ expect(cmd?.func).toBeTypeOf('function');
8
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
9
+ await expect(cmd.func(page, { target: 'article:1', execute: true })).rejects.toMatchObject({
10
+ code: 'INVALID_INPUT',
11
+ });
12
+ expect(page.goto).not.toHaveBeenCalled();
13
+ expect(page.evaluate).not.toHaveBeenCalled();
14
+ });
15
+ it('requires persisted read-back and preserves previously selected collections', async () => {
16
+ const cmd = getRegistry().get('zhihu/favorite');
17
+ const page = {
18
+ goto: vi.fn().mockResolvedValue(undefined),
19
+ evaluate: vi.fn()
20
+ .mockResolvedValueOnce({
21
+ chooserRows: [
22
+ { id: 'fav-a', name: '已存在', selected: true },
23
+ { id: 'fav-b', name: '默认收藏夹', selected: false },
24
+ ],
25
+ targetRowId: 'fav-b',
26
+ targetRowName: '默认收藏夹',
27
+ })
28
+ .mockResolvedValueOnce({
29
+ persisted: true,
30
+ readbackSource: 'reopened_chooser',
31
+ selectedBefore: ['fav-a'],
32
+ selectedAfter: ['fav-a', 'fav-b'],
33
+ targetSelected: true,
34
+ }),
35
+ };
36
+ await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
37
+ expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹', target: 'article:1' }),
38
+ ]);
39
+ expect(page.evaluate.mock.calls[1][0]).toContain('waitForChooserRows(false)');
40
+ expect(page.evaluate.mock.calls[1][0]).toContain("readbackSource");
41
+ });
42
+ it('requires persisted read-back before returning already_applied', async () => {
43
+ const cmd = getRegistry().get('zhihu/favorite');
44
+ const page = {
45
+ goto: vi.fn().mockResolvedValue(undefined),
46
+ evaluate: vi.fn()
47
+ .mockResolvedValueOnce({
48
+ chooserRows: [{ id: 'fav-a', name: '默认收藏夹', selected: true }],
49
+ targetRowId: 'fav-a',
50
+ targetRowName: '默认收藏夹',
51
+ })
52
+ .mockResolvedValueOnce({
53
+ persisted: true,
54
+ readbackSource: 'reopened_chooser',
55
+ selectedAfter: ['fav-a'],
56
+ targetSelected: true,
57
+ }),
58
+ };
59
+ await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
60
+ expect.objectContaining({ outcome: 'already_applied', collection_name: '默认收藏夹' }),
61
+ ]);
62
+ });
63
+ it('accepts --collection-id as the stable selector path', async () => {
64
+ const cmd = getRegistry().get('zhihu/favorite');
65
+ const page = {
66
+ goto: vi.fn().mockResolvedValue(undefined),
67
+ evaluate: vi.fn()
68
+ .mockResolvedValueOnce({
69
+ chooserRows: [
70
+ { id: 'fav-a', name: '默认收藏夹', selected: false },
71
+ { id: 'fav-b', name: '同名收藏夹', selected: false },
72
+ ],
73
+ targetRowId: 'fav-b',
74
+ targetRowName: null,
75
+ })
76
+ .mockResolvedValueOnce({
77
+ persisted: true,
78
+ readbackSource: 'reopened_chooser',
79
+ selectedAfter: ['fav-b'],
80
+ targetSelected: true,
81
+ }),
82
+ };
83
+ await expect(cmd.func(page, { target: 'article:1', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([
84
+ expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b' }),
85
+ ]);
86
+ });
87
+ it('rejects duplicate collection names before selecting any row', async () => {
88
+ const cmd = getRegistry().get('zhihu/favorite');
89
+ const page = {
90
+ goto: vi.fn().mockResolvedValue(undefined),
91
+ evaluate: vi.fn().mockResolvedValue({
92
+ chooserRows: [
93
+ { id: 'fav-a', name: '默认收藏夹', selected: false },
94
+ { id: 'fav-b', name: '默认收藏夹', selected: false },
95
+ ],
96
+ }),
97
+ };
98
+ await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
99
+ });
100
+ it('rejects optimistic chooser state that was not re-read from a reopened chooser', async () => {
101
+ const cmd = getRegistry().get('zhihu/favorite');
102
+ const page = {
103
+ goto: vi.fn().mockResolvedValue(undefined),
104
+ evaluate: vi.fn()
105
+ .mockResolvedValueOnce({
106
+ chooserRows: [
107
+ { id: 'fav-a', name: '已存在', selected: true },
108
+ { id: 'fav-b', name: '默认收藏夹', selected: false },
109
+ ],
110
+ targetRowId: 'fav-b',
111
+ targetRowName: '默认收藏夹',
112
+ })
113
+ .mockResolvedValueOnce({
114
+ persisted: true,
115
+ readbackSource: 'same_modal',
116
+ selectedAfter: ['fav-a', 'fav-b'],
117
+ targetSelected: true,
118
+ }),
119
+ };
120
+ await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'OUTCOME_UNKNOWN' });
121
+ });
122
+ it('matches unique collection names even when chooser rows include extra UI text', async () => {
123
+ const cmd = getRegistry().get('zhihu/favorite');
124
+ const page = {
125
+ goto: vi.fn().mockResolvedValue(undefined),
126
+ evaluate: vi.fn()
127
+ .mockResolvedValueOnce({
128
+ chooserRows: [
129
+ { id: 'fav-b', name: '默认收藏夹 12 条内容', selected: false },
130
+ ],
131
+ targetRowId: null,
132
+ targetRowName: '默认收藏夹',
133
+ })
134
+ .mockResolvedValueOnce({
135
+ persisted: true,
136
+ readbackSource: 'reopened_chooser',
137
+ selectedAfter: ['fav-b'],
138
+ targetSelected: true,
139
+ }),
140
+ };
141
+ await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
142
+ expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }),
143
+ ]);
144
+ expect(page.evaluate.mock.calls[1][0]).toContain('normalizeCollectionName');
145
+ });
146
+ it('normalizes id-less row keys during reopened chooser verification', async () => {
147
+ const cmd = getRegistry().get('zhihu/favorite');
148
+ const page = {
149
+ goto: vi.fn().mockResolvedValue(undefined),
150
+ evaluate: vi.fn()
151
+ .mockResolvedValueOnce({
152
+ chooserRows: [
153
+ { id: '', name: '默认收藏夹 12 条内容', selected: false },
154
+ ],
155
+ targetRowId: null,
156
+ targetRowName: '默认收藏夹',
157
+ })
158
+ .mockResolvedValueOnce({
159
+ persisted: true,
160
+ readbackSource: 'reopened_chooser',
161
+ selectedAfter: ['name:默认收藏夹'],
162
+ targetSelected: true,
163
+ }),
164
+ };
165
+ await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
166
+ expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }),
167
+ ]);
168
+ expect(page.evaluate.mock.calls[1][0]).toContain("const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);");
169
+ expect(page.evaluate.mock.calls[1][0]).toContain('selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey)');
170
+ });
171
+ it('reuses data-attribute answer anchoring during reopened chooser verification', async () => {
172
+ const cmd = getRegistry().get('zhihu/favorite');
173
+ const page = {
174
+ goto: vi.fn().mockResolvedValue(undefined),
175
+ evaluate: vi.fn()
176
+ .mockResolvedValueOnce({
177
+ chooserRows: [
178
+ { id: 'fav-b', name: '默认收藏夹', selected: false },
179
+ ],
180
+ targetRowId: 'fav-b',
181
+ targetRowName: null,
182
+ })
183
+ .mockResolvedValueOnce({
184
+ persisted: true,
185
+ readbackSource: 'reopened_chooser',
186
+ selectedAfter: ['fav-b'],
187
+ targetSelected: true,
188
+ }),
189
+ };
190
+ await expect(cmd.func(page, { target: 'answer:1:2', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([
191
+ expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b', target: 'answer:1:2' }),
192
+ ]);
193
+ expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-answerid')");
194
+ expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-zop-question-answer')");
195
+ });
196
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { assertAllowedKinds, parseTarget } from './target.js';
4
+ import { buildResultRow, requireExecute } from './write-shared.js';
5
+ cli({
6
+ site: 'zhihu',
7
+ name: 'follow',
8
+ description: 'Follow a Zhihu user or question',
9
+ domain: 'www.zhihu.com',
10
+ strategy: Strategy.UI,
11
+ browser: true,
12
+ args: [
13
+ { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
14
+ { name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
15
+ ],
16
+ columns: ['status', 'outcome', 'message', 'target_type', 'target'],
17
+ func: async (page, kwargs) => {
18
+ if (!page)
19
+ throw new CommandExecutionError('Browser session required for zhihu follow');
20
+ requireExecute(kwargs);
21
+ const rawTarget = String(kwargs.target);
22
+ const target = assertAllowedKinds('follow', parseTarget(rawTarget));
23
+ await page.goto(target.url);
24
+ const result = await page.evaluate(`(async () => {
25
+ const targetKind = ${JSON.stringify(target.kind)};
26
+ const mainRoot = document.querySelector('main') || document;
27
+ let followBtn = null;
28
+
29
+ if (targetKind === 'question') {
30
+ const questionRoots = Array.from(mainRoot.querySelectorAll('.QuestionHeader, .Question-main, [data-zop-question-id], [class*="QuestionHeader"]'));
31
+ const scopedRoots = questionRoots.length ? questionRoots : [mainRoot];
32
+ const candidates = Array.from(new Set(scopedRoots.flatMap((root) => Array.from(root.querySelectorAll('button, a'))))).filter((node) => {
33
+ const text = (node.textContent || '').trim();
34
+ const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations'));
35
+ const inAnswerBlock = Boolean(node.closest('article, .AnswerItem, [data-zop-question-answer]'));
36
+ return /关注问题|已关注/.test(text) && !inAside && !inAnswerBlock;
37
+ });
38
+ if (candidates.length !== 1) return { state: 'ambiguous_question_follow' };
39
+ followBtn = candidates[0];
40
+ } else {
41
+ const candidates = Array.from(mainRoot.querySelectorAll('button, a')).filter((node) => {
42
+ const text = (node.textContent || '').trim();
43
+ const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations'));
44
+ return /关注|已关注/.test(text) && !/邀请|收藏|评论/.test(text) && !inAside;
45
+ });
46
+
47
+ if (candidates.length !== 1) return { state: 'ambiguous_user_follow' };
48
+ followBtn = candidates[0];
49
+ }
50
+
51
+ if (!followBtn) return { state: 'missing' };
52
+ if ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true') {
53
+ return { state: 'already_following' };
54
+ }
55
+
56
+ followBtn.click();
57
+ await new Promise((resolve) => setTimeout(resolve, 1000));
58
+
59
+ return ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true')
60
+ ? { state: 'followed' }
61
+ : { state: 'unknown' };
62
+ })()`);
63
+ if (result?.state === 'already_following') {
64
+ return buildResultRow(`Already followed ${target.kind}`, target.kind, rawTarget, 'already_applied');
65
+ }
66
+ if (result?.state === 'ambiguous_question_follow') {
67
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Question follow control was not uniquely anchored on the requested question page');
68
+ }
69
+ if (result?.state === 'ambiguous_user_follow') {
70
+ throw new CliError('ACTION_NOT_AVAILABLE', 'User follow control was not uniquely anchored on the requested profile page');
71
+ }
72
+ if (result?.state === 'missing') {
73
+ throw new CliError('ACTION_FAILED', 'Zhihu follow control was missing before any write was dispatched');
74
+ }
75
+ if (result?.state !== 'followed') {
76
+ throw new CliError('OUTCOME_UNKNOWN', 'Zhihu follow click was dispatched, but the final state could not be verified safely');
77
+ }
78
+ return buildResultRow(`Followed ${target.kind}`, target.kind, rawTarget, 'applied');
79
+ },
80
+ });
@@ -0,0 +1 @@
1
+ import './follow.js';
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './follow.js';
4
+ describe('zhihu follow', () => {
5
+ it('rejects missing --execute before any browser write path', async () => {
6
+ const cmd = getRegistry().get('zhihu/follow');
7
+ expect(cmd?.func).toBeTypeOf('function');
8
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
9
+ await expect(cmd.func(page, { target: 'question:123' })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
10
+ expect(page.goto).not.toHaveBeenCalled();
11
+ expect(page.evaluate).not.toHaveBeenCalled();
12
+ });
13
+ it('rejects user pages where the primary follow control is not uniquely anchored', async () => {
14
+ const cmd = getRegistry().get('zhihu/follow');
15
+ const page = {
16
+ goto: vi.fn().mockResolvedValue(undefined),
17
+ evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_user_follow' }),
18
+ };
19
+ await expect(cmd.func(page, { target: 'user:alice', execute: true })).rejects.toMatchObject({
20
+ code: 'ACTION_NOT_AVAILABLE',
21
+ });
22
+ });
23
+ it('returns already_applied when already following', async () => {
24
+ const cmd = getRegistry().get('zhihu/follow');
25
+ const page = {
26
+ goto: vi.fn().mockResolvedValue(undefined),
27
+ evaluate: vi.fn().mockResolvedValue({ state: 'already_following' }),
28
+ };
29
+ await expect(cmd.func(page, { target: 'question:123', execute: true })).resolves.toEqual([
30
+ expect.objectContaining({ outcome: 'already_applied', target_type: 'question', target: 'question:123' }),
31
+ ]);
32
+ });
33
+ it('rejects question pages where the question follow control is not uniquely anchored', async () => {
34
+ const cmd = getRegistry().get('zhihu/follow');
35
+ const page = {
36
+ goto: vi.fn().mockResolvedValue(undefined),
37
+ evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_question_follow' }),
38
+ };
39
+ await expect(cmd.func(page, { target: 'question:123', execute: true })).rejects.toMatchObject({
40
+ code: 'ACTION_NOT_AVAILABLE',
41
+ });
42
+ expect(page.evaluate.mock.calls[0][0]).toContain('QuestionHeader');
43
+ expect(page.evaluate.mock.calls[0][0]).toContain('new Set(');
44
+ });
45
+ });
@@ -0,0 +1 @@
1
+ export {};