@jackwener/opencli 1.6.8 → 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 (84) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -1
  3. package/dist/clis/jianyu/search.d.ts +14 -0
  4. package/dist/clis/jianyu/search.js +135 -0
  5. package/dist/clis/jianyu/search.test.d.ts +1 -0
  6. package/dist/clis/jianyu/search.test.js +23 -0
  7. package/dist/clis/quark/ls.d.ts +1 -0
  8. package/dist/clis/quark/ls.js +63 -0
  9. package/dist/clis/quark/mkdir.d.ts +1 -0
  10. package/dist/clis/quark/mkdir.js +36 -0
  11. package/dist/clis/quark/mv.d.ts +1 -0
  12. package/dist/clis/quark/mv.js +53 -0
  13. package/dist/clis/quark/rename.d.ts +1 -0
  14. package/dist/clis/quark/rename.js +26 -0
  15. package/dist/clis/quark/rm.d.ts +1 -0
  16. package/dist/clis/quark/rm.js +24 -0
  17. package/dist/clis/quark/save.d.ts +1 -0
  18. package/dist/clis/quark/save.js +80 -0
  19. package/dist/clis/quark/share-tree.d.ts +1 -0
  20. package/dist/clis/quark/share-tree.js +45 -0
  21. package/dist/clis/quark/utils.d.ts +50 -0
  22. package/dist/clis/quark/utils.js +146 -0
  23. package/dist/clis/quark/utils.test.d.ts +1 -0
  24. package/dist/clis/quark/utils.test.js +58 -0
  25. package/dist/clis/twitter/reply.js +3 -8
  26. package/dist/clis/twitter/reply.test.js +5 -5
  27. package/dist/clis/xiaohongshu/note.js +8 -3
  28. package/dist/clis/xiaohongshu/note.test.js +11 -0
  29. package/dist/clis/zhihu/answer.d.ts +1 -0
  30. package/dist/clis/zhihu/answer.js +194 -0
  31. package/dist/clis/zhihu/answer.test.d.ts +1 -0
  32. package/dist/clis/zhihu/answer.test.js +81 -0
  33. package/dist/clis/zhihu/comment.d.ts +1 -0
  34. package/dist/clis/zhihu/comment.js +335 -0
  35. package/dist/clis/zhihu/comment.test.d.ts +1 -0
  36. package/dist/clis/zhihu/comment.test.js +54 -0
  37. package/dist/clis/zhihu/favorite.d.ts +1 -0
  38. package/dist/clis/zhihu/favorite.js +224 -0
  39. package/dist/clis/zhihu/favorite.test.d.ts +1 -0
  40. package/dist/clis/zhihu/favorite.test.js +196 -0
  41. package/dist/clis/zhihu/follow.d.ts +1 -0
  42. package/dist/clis/zhihu/follow.js +80 -0
  43. package/dist/clis/zhihu/follow.test.d.ts +1 -0
  44. package/dist/clis/zhihu/follow.test.js +45 -0
  45. package/dist/clis/zhihu/like.d.ts +1 -0
  46. package/dist/clis/zhihu/like.js +91 -0
  47. package/dist/clis/zhihu/like.test.d.ts +1 -0
  48. package/dist/clis/zhihu/like.test.js +64 -0
  49. package/dist/clis/zhihu/target.d.ts +24 -0
  50. package/dist/clis/zhihu/target.js +91 -0
  51. package/dist/clis/zhihu/target.test.d.ts +1 -0
  52. package/dist/clis/zhihu/target.test.js +77 -0
  53. package/dist/clis/zhihu/write-shared.d.ts +32 -0
  54. package/dist/clis/zhihu/write-shared.js +221 -0
  55. package/dist/clis/zhihu/write-shared.test.d.ts +1 -0
  56. package/dist/clis/zhihu/write-shared.test.js +175 -0
  57. package/dist/src/browser/bridge.d.ts +2 -0
  58. package/dist/src/browser/bridge.js +30 -24
  59. package/dist/src/browser/daemon-client.d.ts +17 -8
  60. package/dist/src/browser/daemon-client.js +12 -13
  61. package/dist/src/browser/daemon-client.test.js +32 -25
  62. package/dist/src/browser/index.d.ts +2 -1
  63. package/dist/src/browser/index.js +1 -1
  64. package/dist/src/browser.test.js +2 -3
  65. package/dist/src/cli.js +3 -3
  66. package/dist/src/clis/binance/commands.test.d.ts +1 -0
  67. package/dist/src/clis/binance/commands.test.js +54 -0
  68. package/dist/src/commanderAdapter.js +19 -6
  69. package/dist/src/diagnostic.d.ts +1 -0
  70. package/dist/src/diagnostic.js +64 -2
  71. package/dist/src/diagnostic.test.js +91 -1
  72. package/dist/src/doctor.d.ts +2 -0
  73. package/dist/src/doctor.js +59 -31
  74. package/dist/src/doctor.test.js +89 -16
  75. package/dist/src/execution.js +1 -13
  76. package/dist/src/explore.js +1 -1
  77. package/dist/src/generate.d.ts +2 -5
  78. package/dist/src/generate.js +2 -5
  79. package/dist/src/plugin.d.ts +2 -1
  80. package/dist/src/plugin.js +25 -8
  81. package/dist/src/plugin.test.js +16 -1
  82. package/package.json +3 -3
  83. package/dist/src/browser/discover.d.ts +0 -15
  84. package/dist/src/browser/discover.js +0 -19
@@ -0,0 +1,146 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, getErrorMessage } from '@jackwener/opencli/errors';
2
+ export const SHARE_API = 'https://drive-h.quark.cn/1/clouddrive/share/sharepage';
3
+ export const DRIVE_API = 'https://drive-pc.quark.cn/1/clouddrive/file';
4
+ export const TASK_API = 'https://drive-pc.quark.cn/1/clouddrive/task';
5
+ const QUARK_DOMAIN = 'pan.quark.cn';
6
+ const AUTH_HINT = 'Quark Drive requires a logged-in browser session';
7
+ function isAuthFailure(message, status) {
8
+ if (status === 401 || status === 403)
9
+ return true;
10
+ return /not logged in|login required|please log in|authentication required|unauthorized|forbidden|未登录|请先登录|需要登录|登录/.test(message.toLowerCase());
11
+ }
12
+ function getErrorStatus(error) {
13
+ if (!error || typeof error !== 'object' || !('status' in error))
14
+ return undefined;
15
+ const status = error.status;
16
+ return typeof status === 'number' ? status : undefined;
17
+ }
18
+ function unwrapApiData(resp, action) {
19
+ if (resp.status === 200)
20
+ return resp.data;
21
+ if (isAuthFailure(resp.message, resp.status)) {
22
+ throw new AuthRequiredError(QUARK_DOMAIN, AUTH_HINT);
23
+ }
24
+ throw new CommandExecutionError(`quark: ${action}: ${resp.message}`);
25
+ }
26
+ export function extractPwdId(url) {
27
+ const m = url.match(/\/s\/([a-zA-Z0-9]+)/);
28
+ if (m)
29
+ return m[1];
30
+ if (/^[a-zA-Z0-9]+$/.test(url))
31
+ return url;
32
+ throw new ArgumentError(`Invalid Quark share URL: ${url}`);
33
+ }
34
+ export async function fetchJson(page, url, options) {
35
+ const method = options?.method || 'GET';
36
+ const body = options?.body ? JSON.stringify(options.body) : undefined;
37
+ const js = `fetch(${JSON.stringify(url)}, {
38
+ method: ${JSON.stringify(method)},
39
+ headers: { 'Content-Type': 'application/json' },
40
+ credentials: 'include',
41
+ ${body ? `body: ${JSON.stringify(body)},` : ''}
42
+ }).then(async r => {
43
+ const ct = r.headers.get('content-type') || '';
44
+ if (!ct.includes('json')) {
45
+ const text = await r.text().catch(() => '');
46
+ throw Object.assign(new Error('Non-JSON response: ' + text.slice(0, 200)), { status: r.status });
47
+ }
48
+ return r.json();
49
+ })`;
50
+ try {
51
+ return await page.evaluate(js);
52
+ }
53
+ catch (error) {
54
+ if (isAuthFailure(getErrorMessage(error), getErrorStatus(error))) {
55
+ throw new AuthRequiredError(QUARK_DOMAIN, AUTH_HINT);
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ export async function apiGet(page, url) {
61
+ const resp = await fetchJson(page, url);
62
+ return unwrapApiData(resp, 'API error');
63
+ }
64
+ export async function apiPost(page, url, body) {
65
+ const resp = await fetchJson(page, url, { method: 'POST', body });
66
+ return unwrapApiData(resp, 'API error');
67
+ }
68
+ export async function getToken(page, pwdId, passcode = '') {
69
+ const data = await fetchJson(page, `${SHARE_API}/token?pr=ucpro&fr=pc`, {
70
+ method: 'POST',
71
+ body: { pwd_id: pwdId, passcode, support_visit_limit_private_share: true },
72
+ });
73
+ return unwrapApiData(data, 'Failed to get token').stoken;
74
+ }
75
+ export async function getShareList(page, pwdId, stoken, pdirFid = '0', options) {
76
+ const allFiles = [];
77
+ let pageNum = 1;
78
+ let total = 0;
79
+ do {
80
+ const sortParam = options?.sort ? `&_sort=${options.sort}` : '';
81
+ const url = `${SHARE_API}/detail?pr=ucpro&fr=pc&ver=2&pwd_id=${pwdId}&stoken=${encodeURIComponent(stoken)}&pdir_fid=${pdirFid}&force=0&_page=${pageNum}&_size=200&_fetch_total=1${sortParam}`;
82
+ const data = await fetchJson(page, url);
83
+ const files = unwrapApiData(data, 'Failed to get share list')?.list || [];
84
+ allFiles.push(...files);
85
+ total = data.metadata?._total || 0;
86
+ pageNum++;
87
+ } while (allFiles.length < total);
88
+ return allFiles;
89
+ }
90
+ export async function listMyDrive(page, pdirFid) {
91
+ const allFiles = [];
92
+ let pageNum = 1;
93
+ let total = 0;
94
+ do {
95
+ const url = `${DRIVE_API}/sort?pr=ucpro&fr=pc&pdir_fid=${pdirFid}&_page=${pageNum}&_size=200&_fetch_total=1&_sort=file_type:asc,file_name:asc`;
96
+ const data = await fetchJson(page, url);
97
+ const files = unwrapApiData(data, 'Failed to list drive')?.list || [];
98
+ allFiles.push(...files);
99
+ total = data.metadata?._total || 0;
100
+ pageNum++;
101
+ } while (allFiles.length < total);
102
+ return allFiles;
103
+ }
104
+ export async function findFolder(page, path) {
105
+ const parts = path.split('/').filter(Boolean);
106
+ let currentFid = '0';
107
+ for (const part of parts) {
108
+ const files = await listMyDrive(page, currentFid);
109
+ const existing = files.find(f => f.dir && f.file_name === part);
110
+ if (existing) {
111
+ currentFid = existing.fid;
112
+ }
113
+ else {
114
+ throw new CommandExecutionError(`quark: Folder "${part}" not found in "${path}"`);
115
+ }
116
+ }
117
+ return currentFid;
118
+ }
119
+ export function formatDate(ts) {
120
+ if (!ts)
121
+ return '';
122
+ const d = new Date(ts);
123
+ return d.toISOString().replace('T', ' ').slice(0, 19);
124
+ }
125
+ export function formatSize(bytes) {
126
+ if (bytes <= 0)
127
+ return '0 B';
128
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
129
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
130
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
131
+ }
132
+ export async function getTaskStatus(page, taskId) {
133
+ const url = `${TASK_API}?pr=ucpro&fr=pc&task_id=${taskId}&retry_index=0`;
134
+ return apiGet(page, url);
135
+ }
136
+ export async function pollTask(page, taskId, onDone, maxAttempts = 30, intervalMs = 500) {
137
+ for (let i = 0; i < maxAttempts; i++) {
138
+ await new Promise(r => setTimeout(r, intervalMs));
139
+ const task = await getTaskStatus(page, taskId);
140
+ if (task?.status === 2) {
141
+ onDone?.(task);
142
+ return true;
143
+ }
144
+ }
145
+ return false;
146
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { apiGet, apiPost, extractPwdId, getShareList, getToken } from './utils.js';
4
+ function makePage(evaluateImpl) {
5
+ return {
6
+ evaluate: vi.fn(evaluateImpl),
7
+ };
8
+ }
9
+ describe('quark utils', () => {
10
+ it('extractPwdId accepts share URLs and raw ids', () => {
11
+ expect(extractPwdId('https://pan.quark.cn/s/abc123')).toBe('abc123');
12
+ expect(extractPwdId('abc123')).toBe('abc123');
13
+ });
14
+ it('maps JSON auth failures to AuthRequiredError', async () => {
15
+ const page = makePage(async () => ({
16
+ status: 401,
17
+ code: 401,
18
+ message: '未登录',
19
+ data: null,
20
+ }));
21
+ await expect(apiGet(page, 'https://drive-pc.quark.cn/test')).rejects.toBeInstanceOf(AuthRequiredError);
22
+ });
23
+ it('maps non-JSON auth pages to AuthRequiredError', async () => {
24
+ const page = makePage(async () => {
25
+ const error = Object.assign(new Error('Non-JSON response: <html><title>登录</title></html>'), { status: 401 });
26
+ throw error;
27
+ });
28
+ await expect(apiPost(page, 'https://drive-pc.quark.cn/test', {})).rejects.toBeInstanceOf(AuthRequiredError);
29
+ });
30
+ it('keeps generic API failures as CommandExecutionError', async () => {
31
+ const page = makePage(async () => ({
32
+ status: 500,
33
+ code: 500,
34
+ message: 'server busy',
35
+ data: null,
36
+ }));
37
+ await expect(apiGet(page, 'https://drive-pc.quark.cn/test')).rejects.toBeInstanceOf(CommandExecutionError);
38
+ });
39
+ it('unwraps successful token responses', async () => {
40
+ const page = makePage(async () => ({
41
+ status: 200,
42
+ code: 0,
43
+ message: 'ok',
44
+ data: { stoken: 'token123' },
45
+ }));
46
+ await expect(getToken(page, 'abc123')).resolves.toBe('token123');
47
+ });
48
+ it('maps share-tree detail auth failures to AuthRequiredError', async () => {
49
+ const page = makePage(async () => ({
50
+ status: 401,
51
+ code: 401,
52
+ message: '请先登录',
53
+ data: null,
54
+ metadata: { _total: 0 },
55
+ }));
56
+ await expect(getShareList(page, 'abc123', 'token123')).rejects.toBeInstanceOf(AuthRequiredError);
57
+ });
58
+ });
@@ -240,18 +240,13 @@ cli({
240
240
  localImagePath = await downloadRemoteImage(kwargs['image-url']);
241
241
  cleanupDir = path.dirname(localImagePath);
242
242
  }
243
- // Dedicated composer is more reliable for image replies because the media
244
- // toolbar and file input are consistently present there.
243
+ // Dedicated composer is more reliable than the inline tweet page reply box.
244
+ await page.goto(buildReplyComposerUrl(kwargs.url), { waitUntil: 'load', settleMs: 2500 });
245
+ await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
245
246
  if (localImagePath) {
246
- await page.goto(buildReplyComposerUrl(kwargs.url));
247
- await page.wait({ selector: '[data-testid="tweetTextarea_0"]' });
248
247
  await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
249
248
  await attachReplyImage(page, localImagePath);
250
249
  }
251
- else {
252
- await page.goto(kwargs.url);
253
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
254
- }
255
250
  const result = await submitReply(page, kwargs.text);
256
251
  if (result.ok) {
257
252
  await page.wait(3); // Wait for network submission to complete
@@ -34,7 +34,7 @@ function createPageMock(evaluateResults, overrides = {}) {
34
34
  };
35
35
  }
36
36
  describe('twitter reply command', () => {
37
- it('keeps the text-only reply flow working', async () => {
37
+ it('uses the dedicated reply composer for text-only replies too', async () => {
38
38
  const cmd = getRegistry().get('twitter/reply');
39
39
  expect(cmd?.func).toBeTypeOf('function');
40
40
  const page = createPageMock([
@@ -44,8 +44,8 @@ describe('twitter reply command', () => {
44
44
  url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
45
45
  text: 'text-only reply',
46
46
  });
47
- expect(page.goto).toHaveBeenCalledWith('https://x.com/_kop6/status/2040254679301718161?s=20');
48
- expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="primaryColumn"]' });
47
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
48
+ expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
49
49
  expect(result).toEqual([
50
50
  {
51
51
  status: 'success',
@@ -72,8 +72,8 @@ describe('twitter reply command', () => {
72
72
  text: 'reply with image',
73
73
  image: imagePath,
74
74
  });
75
- expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161');
76
- expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]' });
75
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
76
+ expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
77
77
  expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 });
78
78
  expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]');
79
79
  expect(result).toEqual([
@@ -37,9 +37,14 @@ cli({
37
37
  const title = clean(document.querySelector('#detail-title, .title'))
38
38
  const desc = clean(document.querySelector('#detail-desc, .desc, .note-text'))
39
39
  const author = clean(document.querySelector('.username, .author-wrapper .name'))
40
- const likes = clean(document.querySelector('.like-wrapper .count'))
41
- const collects = clean(document.querySelector('.collect-wrapper .count'))
42
- const comments = clean(document.querySelector('.chat-wrapper .count'))
40
+ // Scope to .interact-container — the post's main interaction bar.
41
+ // Without scoping, .like-wrapper / .chat-wrapper also match each
42
+ // comment's like/reply buttons in the comment section, and
43
+ // querySelector returns the FIRST match (a comment's count, not the
44
+ // post's). The post's true counts live inside .interact-container.
45
+ const likes = clean(document.querySelector('.interact-container .like-wrapper .count'))
46
+ const collects = clean(document.querySelector('.interact-container .collect-wrapper .count'))
47
+ const comments = clean(document.querySelector('.interact-container .chat-wrapper .count'))
43
48
 
44
49
  // Try to extract tags/topics
45
50
  const tags = []
@@ -170,6 +170,17 @@ describe('xiaohongshu note', () => {
170
170
  expect(result.find((r) => r.field === 'collects').value).toBe('0');
171
171
  expect(result.find((r) => r.field === 'comments').value).toBe('0');
172
172
  });
173
+ it('scopes metric selectors to .interact-container to avoid matching comment like buttons', async () => {
174
+ const page = createPageMock({
175
+ loginWall: false, notFound: false,
176
+ title: 'Test', desc: '', author: 'Author', likes: '10', collects: '5', comments: '3', tags: [],
177
+ });
178
+ await command.func(page, { 'note-id': 'abc123' });
179
+ const evaluateScript = page.evaluate.mock.calls[0][0];
180
+ expect(evaluateScript).toContain('.interact-container .like-wrapper .count');
181
+ expect(evaluateScript).toContain('.interact-container .collect-wrapper .count');
182
+ expect(evaluateScript).toContain('.interact-container .chat-wrapper .count');
183
+ });
173
184
  it('omits tags row when no tags present', async () => {
174
185
  const page = createPageMock({
175
186
  loginWall: false, notFound: false,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,194 @@
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, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
5
+ const ANSWER_AUTHOR_SCOPE_SELECTOR = '.AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop="author"]';
6
+ cli({
7
+ site: 'zhihu',
8
+ name: 'answer',
9
+ description: 'Answer a Zhihu question',
10
+ domain: 'www.zhihu.com',
11
+ strategy: Strategy.UI,
12
+ browser: true,
13
+ args: [
14
+ { name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
15
+ { name: 'text', positional: true, help: 'Answer text' },
16
+ { name: 'file', help: 'Answer text file path' },
17
+ { name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
18
+ ],
19
+ columns: ['status', 'outcome', 'message', 'target_type', 'target', 'created_target', 'created_url', 'author_identity'],
20
+ func: async (page, kwargs) => {
21
+ if (!page)
22
+ throw new CommandExecutionError('Browser session required for zhihu answer');
23
+ requireExecute(kwargs);
24
+ const rawTarget = String(kwargs.target);
25
+ const target = assertAllowedKinds('answer', parseTarget(rawTarget));
26
+ const questionTarget = target;
27
+ const payload = await resolvePayload(kwargs);
28
+ await page.goto(target.url);
29
+ const authorIdentity = await resolveCurrentUserIdentity(page);
30
+ const entryPath = await page.evaluate(`(() => {
31
+ const currentUserSlug = ${JSON.stringify(authorIdentity)};
32
+ const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
33
+ const readAnswerAuthorSlug = (node) => {
34
+ const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
35
+ const slugs = Array.from(new Set(authorScopes
36
+ .flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
37
+ .map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
38
+ .filter(Boolean)));
39
+ return slugs.length === 1 ? slugs[0] : null;
40
+ };
41
+ const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
42
+ const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
43
+ const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
44
+ const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
45
+ const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
46
+ const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
47
+ return { editor, container, text, submitButton, nestedComment };
48
+ }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
49
+ const hasExistingAnswerByCurrentUser = Array.from(document.querySelectorAll('[data-zop-question-answer], article')).some((node) => {
50
+ return readAnswerAuthorSlug(node) === currentUserSlug;
51
+ });
52
+ return {
53
+ entryPathSafe: composerCandidates.length === 1
54
+ && !String(composerCandidates[0].text || '').trim()
55
+ && !restoredDraft
56
+ && !hasExistingAnswerByCurrentUser,
57
+ hasExistingAnswerByCurrentUser,
58
+ };
59
+ })()`);
60
+ if (entryPath.hasExistingAnswerByCurrentUser) {
61
+ throw new CliError('ACTION_NOT_AVAILABLE', 'zhihu answer only supports creating a new answer when the current user has not already answered this question');
62
+ }
63
+ if (!entryPath.entryPathSafe) {
64
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor entry path was not proven side-effect free');
65
+ }
66
+ const editorState = await page.evaluate(`(async () => {
67
+ const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
68
+ const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
69
+ const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
70
+ const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
71
+ const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
72
+ return { editor, container, text, submitButton, nestedComment };
73
+ }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
74
+ if (composerCandidates.length !== 1) return { editorState: 'unsafe', anonymousMode: 'unknown' };
75
+ const { editor, text } = composerCandidates[0];
76
+ const anonymousLabeledControl =
77
+ (composerCandidates[0].container && composerCandidates[0].container.querySelector('[aria-label*="匿名"], [title*="匿名"]'))
78
+ || Array.from((composerCandidates[0].container || document).querySelectorAll('label, button, [role="switch"], [role="checkbox"]')).find((node) => /匿名/.test(node.textContent || ''))
79
+ || null;
80
+ const anonymousToggle =
81
+ anonymousLabeledControl?.matches?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
82
+ ? anonymousLabeledControl
83
+ : anonymousLabeledControl?.querySelector?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
84
+ || null;
85
+ let anonymousMode = 'unknown';
86
+ if (anonymousToggle) {
87
+ const ariaChecked = anonymousToggle.getAttribute && anonymousToggle.getAttribute('aria-checked');
88
+ const checked = 'checked' in anonymousToggle ? anonymousToggle.checked === true : false;
89
+ if (ariaChecked === 'true' || checked) anonymousMode = 'on';
90
+ else if (ariaChecked === 'false' || ('checked' in anonymousToggle && anonymousToggle.checked === false)) anonymousMode = 'off';
91
+ }
92
+ return {
93
+ editorState: editor && !text.trim() ? 'fresh_empty' : 'unsafe',
94
+ anonymousMode,
95
+ };
96
+ })()`);
97
+ if (editorState.editorState !== 'fresh_empty') {
98
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor was not fresh and empty');
99
+ }
100
+ if (editorState.anonymousMode !== 'off') {
101
+ throw new CliError('ACTION_NOT_AVAILABLE', 'Anonymous answer mode could not be proven off for zhihu answer');
102
+ }
103
+ const editorCheck = await page.evaluate(`(async () => {
104
+ const textToInsert = ${JSON.stringify(payload)};
105
+ const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
106
+ const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
107
+ const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
108
+ const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
109
+ return { editor, container, submitButton, nestedComment };
110
+ }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
111
+ if (composerCandidates.length !== 1) return { editorContent: '', bodyMatches: false };
112
+ const { editor } = composerCandidates[0];
113
+ editor.focus();
114
+ if ('value' in editor) {
115
+ editor.value = '';
116
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
117
+ editor.value = textToInsert;
118
+ editor.dispatchEvent(new Event('input', { bubbles: true }));
119
+ } else {
120
+ editor.textContent = '';
121
+ document.execCommand('insertText', false, textToInsert);
122
+ editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
123
+ }
124
+ await new Promise((resolve) => setTimeout(resolve, 200));
125
+ const content = 'value' in editor ? editor.value : (editor.textContent || '');
126
+ return { editorContent: content, bodyMatches: content === textToInsert };
127
+ })()`);
128
+ if (editorCheck.editorContent !== payload || !editorCheck.bodyMatches) {
129
+ throw new CliError('OUTCOME_UNKNOWN', 'Answer editor content did not exactly match the requested payload before publish');
130
+ }
131
+ const proof = await page.evaluate(`(async () => {
132
+ const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
133
+ const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
134
+ const readAnswerAuthorSlug = (node) => {
135
+ const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
136
+ const slugs = Array.from(new Set(authorScopes
137
+ .flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
138
+ .map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
139
+ .filter(Boolean)));
140
+ return slugs.length === 1 ? slugs[0] : null;
141
+ };
142
+ const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
143
+ const container = editor.closest('form, [role="dialog"], .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
144
+ const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
145
+ const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
146
+ return { editor, container, submitButton, nestedComment };
147
+ }).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
148
+ if (composerCandidates.length !== 1) return { createdTarget: null, createdUrl: null, authorIdentity: null, bodyMatches: false };
149
+ const submitScope = composerCandidates[0].container || document;
150
+ const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
151
+ submit && submit.click();
152
+ await new Promise((resolve) => setTimeout(resolve, 1500));
153
+ const href = location.href;
154
+ const match = href.match(/question\\/(\\d+)\\/answer\\/(\\d+)/);
155
+ const targetHref = match ? '/question/' + match[1] + '/answer/' + match[2] : null;
156
+ const answerContainer = targetHref
157
+ ? Array.from(document.querySelectorAll('[data-zop-question-answer], article')).find((node) => {
158
+ const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
159
+ if (dataAnswerId && dataAnswerId.includes(match[2])) return true;
160
+ return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
161
+ const hrefValue = link.getAttribute('href') || '';
162
+ return hrefValue.includes(targetHref);
163
+ });
164
+ })
165
+ : null;
166
+ const authorSlug = answerContainer ? readAnswerAuthorSlug(answerContainer) : null;
167
+ const bodyNode =
168
+ answerContainer?.querySelector('[itemprop="text"]')
169
+ || answerContainer?.querySelector('.RichContent-inner')
170
+ || answerContainer?.querySelector('.RichText')
171
+ || answerContainer;
172
+ const bodyText = normalize(bodyNode?.textContent || '');
173
+ return match
174
+ ? {
175
+ createdTarget: 'answer:' + match[1] + ':' + match[2],
176
+ createdUrl: href,
177
+ authorIdentity: authorSlug,
178
+ bodyMatches: bodyText === normalize(${JSON.stringify(payload)}),
179
+ }
180
+ : { createdTarget: null, createdUrl: null, authorIdentity: authorSlug, bodyMatches: false };
181
+ })()`);
182
+ if (proof.authorIdentity !== authorIdentity) {
183
+ throw new CliError('OUTCOME_UNKNOWN', 'Answer was created but authorship could not be proven for the frozen current user');
184
+ }
185
+ if (!proof.createdTarget || !proof.bodyMatches || proof.createdTarget.split(':')[1] !== questionTarget.id) {
186
+ throw new CliError('OUTCOME_UNKNOWN', 'Created answer proof did not match the requested question or payload');
187
+ }
188
+ return buildResultRow(`Answered question ${questionTarget.id}`, target.kind, rawTarget, 'created', {
189
+ created_target: proof.createdTarget,
190
+ created_url: proof.createdUrl,
191
+ author_identity: authorIdentity,
192
+ });
193
+ },
194
+ });
@@ -0,0 +1 @@
1
+ import './answer.js';
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './answer.js';
4
+ describe('zhihu answer', () => {
5
+ it('rejects create mode when the current user already answered the question', async () => {
6
+ const cmd = getRegistry().get('zhihu/answer');
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, hasExistingAnswerByCurrentUser: true }),
13
+ };
14
+ await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
15
+ });
16
+ it('rejects anonymous mode instead of toggling it', async () => {
17
+ const cmd = getRegistry().get('zhihu/answer');
18
+ const page = {
19
+ goto: vi.fn().mockResolvedValue(undefined),
20
+ evaluate: vi.fn()
21
+ .mockResolvedValueOnce({ slug: 'alice' })
22
+ .mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
23
+ .mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'on' }),
24
+ };
25
+ await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
26
+ });
27
+ it('rejects when a unique safe answer composer cannot be proven', async () => {
28
+ const cmd = getRegistry().get('zhihu/answer');
29
+ const page = {
30
+ goto: vi.fn().mockResolvedValue(undefined),
31
+ evaluate: vi.fn()
32
+ .mockResolvedValueOnce({ slug: 'alice' })
33
+ .mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: false }),
34
+ };
35
+ await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
36
+ });
37
+ it('rejects when anonymous mode cannot be proven off', async () => {
38
+ const cmd = getRegistry().get('zhihu/answer');
39
+ const page = {
40
+ goto: vi.fn().mockResolvedValue(undefined),
41
+ evaluate: vi.fn()
42
+ .mockResolvedValueOnce({ slug: 'alice' })
43
+ .mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
44
+ .mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'unknown' }),
45
+ };
46
+ await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
47
+ });
48
+ it('requires a side-effect-free entry path and exact editor content before publish', async () => {
49
+ const cmd = getRegistry().get('zhihu/answer');
50
+ const page = {
51
+ goto: vi.fn().mockResolvedValue(undefined),
52
+ evaluate: vi.fn()
53
+ .mockResolvedValueOnce({ slug: 'alice' })
54
+ .mockResolvedValueOnce({ entryPathSafe: true })
55
+ .mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'off' })
56
+ .mockResolvedValueOnce({ editorContent: 'hello', bodyMatches: true })
57
+ .mockResolvedValueOnce({
58
+ createdTarget: 'answer:1:2',
59
+ createdUrl: 'https://www.zhihu.com/question/1/answer/2',
60
+ authorIdentity: 'alice',
61
+ bodyMatches: true,
62
+ }),
63
+ };
64
+ await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).resolves.toEqual([
65
+ expect.objectContaining({
66
+ outcome: 'created',
67
+ created_target: 'answer:1:2',
68
+ created_url: 'https://www.zhihu.com/question/1/answer/2',
69
+ author_identity: 'alice',
70
+ }),
71
+ ]);
72
+ expect(page.evaluate.mock.calls[1][0]).toContain('composerCandidates.length === 1');
73
+ expect(page.evaluate.mock.calls[1][0]).not.toContain('writeAnswerButton');
74
+ expect(page.evaluate.mock.calls[1][0]).toContain('const readAnswerAuthorSlug = (node) =>');
75
+ expect(page.evaluate.mock.calls[1][0]).toContain('const answerAuthorScopeSelector = ".AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop=\\"author\\"]"');
76
+ expect(page.evaluate.mock.calls[1][0]).not.toContain("node.querySelector('a[href^=\"/people/\"]')");
77
+ expect(page.evaluate.mock.calls[3][0]).toContain('composerCandidates.length !== 1');
78
+ expect(page.evaluate.mock.calls[4][0]).toContain('const readAnswerAuthorSlug = (node) =>');
79
+ expect(page.evaluate.mock.calls[4][0]).not.toContain("answerContainer?.querySelector('a[href^=\"/people/\"]')");
80
+ });
81
+ });
@@ -0,0 +1 @@
1
+ export {};