Skip to content

Commit ce559da

Browse files
Astro-Hanjackwener
andauthored
feat(zhihu): add interaction commands (#868)
* feat(zhihu): add interaction commands * fix(zhihu): tighten interaction target anchoring * fix(zhihu): scope comment authorship proof --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 56d15d1 commit ce559da

18 files changed

Lines changed: 2299 additions & 10 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
132132
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
133133
| **twitter** | `trending` `search` `timeline` `bookmarks` `post` `download` `profile` `article` `like` `likes` `notifications` `reply` `reply-dm` `thread` `follow` `unfollow` `followers` `following` `block` `unblock` `bookmark` `unbookmark` `delete` `hide-reply` `accept` |
134134
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
135+
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
135136
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |
136137
| **1688** | `search` `item` `assets` `download` `store` |
137138
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
149149
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
150150
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
151151
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
152-
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
152+
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
153153
| **weixin** | `download` | 浏览器 |
154154
| **youtube** | `search` `video` `transcript` | 浏览器 |
155155
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |

clis/zhihu/answer.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import './answer.js';
4+
5+
describe('zhihu answer', () => {
6+
it('rejects create mode when the current user already answered the question', async () => {
7+
const cmd = getRegistry().get('zhihu/answer');
8+
expect(cmd?.func).toBeTypeOf('function');
9+
10+
const page = {
11+
goto: vi.fn().mockResolvedValue(undefined),
12+
evaluate: vi.fn()
13+
.mockResolvedValueOnce({ slug: 'alice' })
14+
.mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: true }),
15+
} as any;
16+
17+
await expect(
18+
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
19+
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
20+
});
21+
22+
it('rejects anonymous mode instead of toggling it', async () => {
23+
const cmd = getRegistry().get('zhihu/answer');
24+
const page = {
25+
goto: vi.fn().mockResolvedValue(undefined),
26+
evaluate: vi.fn()
27+
.mockResolvedValueOnce({ slug: 'alice' })
28+
.mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
29+
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'on' }),
30+
} as any;
31+
32+
await expect(
33+
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
34+
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
35+
});
36+
37+
it('rejects when a unique safe answer composer cannot be proven', 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: false, hasExistingAnswerByCurrentUser: false }),
44+
} as any;
45+
46+
await expect(
47+
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
48+
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
49+
});
50+
51+
it('rejects when anonymous mode cannot be proven off', async () => {
52+
const cmd = getRegistry().get('zhihu/answer');
53+
const page = {
54+
goto: vi.fn().mockResolvedValue(undefined),
55+
evaluate: vi.fn()
56+
.mockResolvedValueOnce({ slug: 'alice' })
57+
.mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
58+
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'unknown' }),
59+
} as any;
60+
61+
await expect(
62+
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
63+
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
64+
});
65+
66+
it('requires a side-effect-free entry path and exact editor content before publish', async () => {
67+
const cmd = getRegistry().get('zhihu/answer');
68+
const page = {
69+
goto: vi.fn().mockResolvedValue(undefined),
70+
evaluate: vi.fn()
71+
.mockResolvedValueOnce({ slug: 'alice' })
72+
.mockResolvedValueOnce({ entryPathSafe: true })
73+
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'off' })
74+
.mockResolvedValueOnce({ editorContent: 'hello', bodyMatches: true })
75+
.mockResolvedValueOnce({
76+
createdTarget: 'answer:1:2',
77+
createdUrl: 'https://www.zhihu.com/question/1/answer/2',
78+
authorIdentity: 'alice',
79+
bodyMatches: true,
80+
}),
81+
} as any;
82+
83+
await expect(
84+
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
85+
).resolves.toEqual([
86+
expect.objectContaining({
87+
outcome: 'created',
88+
created_target: 'answer:1:2',
89+
created_url: 'https://www.zhihu.com/question/1/answer/2',
90+
author_identity: 'alice',
91+
}),
92+
]);
93+
94+
expect(page.evaluate.mock.calls[1][0]).toContain('composerCandidates.length === 1');
95+
expect(page.evaluate.mock.calls[1][0]).not.toContain('writeAnswerButton');
96+
expect(page.evaluate.mock.calls[1][0]).toContain('const readAnswerAuthorSlug = (node) =>');
97+
expect(page.evaluate.mock.calls[1][0]).toContain('const answerAuthorScopeSelector = ".AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop=\\"author\\"]"');
98+
expect(page.evaluate.mock.calls[1][0]).not.toContain("node.querySelector('a[href^=\"/people/\"]')");
99+
expect(page.evaluate.mock.calls[3][0]).toContain('composerCandidates.length !== 1');
100+
expect(page.evaluate.mock.calls[4][0]).toContain('const readAnswerAuthorSlug = (node) =>');
101+
expect(page.evaluate.mock.calls[4][0]).not.toContain("answerContainer?.querySelector('a[href^=\"/people/\"]')");
102+
});
103+
});

clis/zhihu/answer.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
import type { IPage } from '@jackwener/opencli/types';
4+
import { assertAllowedKinds, parseTarget, type ZhihuTarget } from './target.js';
5+
import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
6+
7+
const ANSWER_AUTHOR_SCOPE_SELECTOR = '.AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop="author"]';
8+
9+
cli({
10+
site: 'zhihu',
11+
name: 'answer',
12+
description: 'Answer a Zhihu question',
13+
domain: 'www.zhihu.com',
14+
strategy: Strategy.UI,
15+
browser: true,
16+
args: [
17+
{ name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
18+
{ name: 'text', positional: true, help: 'Answer text' },
19+
{ name: 'file', help: 'Answer text file path' },
20+
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
21+
],
22+
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'created_target', 'created_url', 'author_identity'],
23+
func: async (page: IPage | null, kwargs: Record<string, unknown>) => {
24+
if (!page) throw new CommandExecutionError('Browser session required for zhihu answer');
25+
26+
requireExecute(kwargs);
27+
const rawTarget = String(kwargs.target);
28+
const target = assertAllowedKinds('answer', parseTarget(rawTarget));
29+
const questionTarget = target as Extract<ZhihuTarget, { kind: 'question' }>;
30+
const payload = await resolvePayload(kwargs);
31+
32+
await page.goto(target.url);
33+
const authorIdentity = await resolveCurrentUserIdentity(page);
34+
35+
const entryPath = await page.evaluate(`(() => {
36+
const currentUserSlug = ${JSON.stringify(authorIdentity)};
37+
const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
38+
const readAnswerAuthorSlug = (node) => {
39+
const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
40+
const slugs = Array.from(new Set(authorScopes
41+
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
42+
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
43+
.filter(Boolean)));
44+
return slugs.length === 1 ? slugs[0] : null;
45+
};
46+
const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
47+
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
48+
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
49+
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
50+
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
51+
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
52+
return { editor, container, text, submitButton, nestedComment };
53+
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
54+
const hasExistingAnswerByCurrentUser = Array.from(document.querySelectorAll('[data-zop-question-answer], article')).some((node) => {
55+
return readAnswerAuthorSlug(node) === currentUserSlug;
56+
});
57+
return {
58+
entryPathSafe: composerCandidates.length === 1
59+
&& !String(composerCandidates[0].text || '').trim()
60+
&& !restoredDraft
61+
&& !hasExistingAnswerByCurrentUser,
62+
hasExistingAnswerByCurrentUser,
63+
};
64+
})()`) as { entryPathSafe?: boolean; hasExistingAnswerByCurrentUser?: boolean };
65+
66+
if (entryPath.hasExistingAnswerByCurrentUser) {
67+
throw new CliError('ACTION_NOT_AVAILABLE', 'zhihu answer only supports creating a new answer when the current user has not already answered this question');
68+
}
69+
if (!entryPath.entryPathSafe) {
70+
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor entry path was not proven side-effect free');
71+
}
72+
73+
const editorState = await page.evaluate(`(async () => {
74+
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
75+
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
76+
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
77+
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
78+
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
79+
return { editor, container, text, submitButton, nestedComment };
80+
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
81+
if (composerCandidates.length !== 1) return { editorState: 'unsafe', anonymousMode: 'unknown' };
82+
const { editor, text } = composerCandidates[0];
83+
const anonymousLabeledControl =
84+
(composerCandidates[0].container && composerCandidates[0].container.querySelector('[aria-label*="匿名"], [title*="匿名"]'))
85+
|| Array.from((composerCandidates[0].container || document).querySelectorAll('label, button, [role="switch"], [role="checkbox"]')).find((node) => /匿名/.test(node.textContent || ''))
86+
|| null;
87+
const anonymousToggle =
88+
anonymousLabeledControl?.matches?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
89+
? anonymousLabeledControl
90+
: anonymousLabeledControl?.querySelector?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
91+
|| null;
92+
let anonymousMode = 'unknown';
93+
if (anonymousToggle) {
94+
const ariaChecked = anonymousToggle.getAttribute && anonymousToggle.getAttribute('aria-checked');
95+
const checked = 'checked' in anonymousToggle ? anonymousToggle.checked === true : false;
96+
if (ariaChecked === 'true' || checked) anonymousMode = 'on';
97+
else if (ariaChecked === 'false' || ('checked' in anonymousToggle && anonymousToggle.checked === false)) anonymousMode = 'off';
98+
}
99+
return {
100+
editorState: editor && !text.trim() ? 'fresh_empty' : 'unsafe',
101+
anonymousMode,
102+
};
103+
})()`) as { editorState?: string; anonymousMode?: 'on' | 'off' | 'unknown' };
104+
105+
if (editorState.editorState !== 'fresh_empty') {
106+
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor was not fresh and empty');
107+
}
108+
if (editorState.anonymousMode !== 'off') {
109+
throw new CliError('ACTION_NOT_AVAILABLE', 'Anonymous answer mode could not be proven off for zhihu answer');
110+
}
111+
112+
const editorCheck = await page.evaluate(`(async () => {
113+
const textToInsert = ${JSON.stringify(payload)};
114+
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
115+
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
116+
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
117+
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
118+
return { editor, container, submitButton, nestedComment };
119+
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
120+
if (composerCandidates.length !== 1) return { editorContent: '', bodyMatches: false };
121+
const { editor } = composerCandidates[0];
122+
editor.focus();
123+
if ('value' in editor) {
124+
editor.value = '';
125+
editor.dispatchEvent(new Event('input', { bubbles: true }));
126+
editor.value = textToInsert;
127+
editor.dispatchEvent(new Event('input', { bubbles: true }));
128+
} else {
129+
editor.textContent = '';
130+
document.execCommand('insertText', false, textToInsert);
131+
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
132+
}
133+
await new Promise((resolve) => setTimeout(resolve, 200));
134+
const content = 'value' in editor ? editor.value : (editor.textContent || '');
135+
return { editorContent: content, bodyMatches: content === textToInsert };
136+
})()`) as { editorContent?: string; bodyMatches?: boolean };
137+
138+
if (editorCheck.editorContent !== payload || !editorCheck.bodyMatches) {
139+
throw new CliError('OUTCOME_UNKNOWN', 'Answer editor content did not exactly match the requested payload before publish');
140+
}
141+
142+
const proof = await page.evaluate(`(async () => {
143+
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
144+
const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
145+
const readAnswerAuthorSlug = (node) => {
146+
const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
147+
const slugs = Array.from(new Set(authorScopes
148+
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
149+
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
150+
.filter(Boolean)));
151+
return slugs.length === 1 ? slugs[0] : null;
152+
};
153+
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
154+
const container = editor.closest('form, [role="dialog"], .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
155+
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
156+
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
157+
return { editor, container, submitButton, nestedComment };
158+
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
159+
if (composerCandidates.length !== 1) return { createdTarget: null, createdUrl: null, authorIdentity: null, bodyMatches: false };
160+
const submitScope = composerCandidates[0].container || document;
161+
const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
162+
submit && submit.click();
163+
await new Promise((resolve) => setTimeout(resolve, 1500));
164+
const href = location.href;
165+
const match = href.match(/question\\/(\\d+)\\/answer\\/(\\d+)/);
166+
const targetHref = match ? '/question/' + match[1] + '/answer/' + match[2] : null;
167+
const answerContainer = targetHref
168+
? Array.from(document.querySelectorAll('[data-zop-question-answer], article')).find((node) => {
169+
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
170+
if (dataAnswerId && dataAnswerId.includes(match[2])) return true;
171+
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
172+
const hrefValue = link.getAttribute('href') || '';
173+
return hrefValue.includes(targetHref);
174+
});
175+
})
176+
: null;
177+
const authorSlug = answerContainer ? readAnswerAuthorSlug(answerContainer) : null;
178+
const bodyNode =
179+
answerContainer?.querySelector('[itemprop="text"]')
180+
|| answerContainer?.querySelector('.RichContent-inner')
181+
|| answerContainer?.querySelector('.RichText')
182+
|| answerContainer;
183+
const bodyText = normalize(bodyNode?.textContent || '');
184+
return match
185+
? {
186+
createdTarget: 'answer:' + match[1] + ':' + match[2],
187+
createdUrl: href,
188+
authorIdentity: authorSlug,
189+
bodyMatches: bodyText === normalize(${JSON.stringify(payload)}),
190+
}
191+
: { createdTarget: null, createdUrl: null, authorIdentity: authorSlug, bodyMatches: false };
192+
})()`) as {
193+
createdTarget?: string | null;
194+
createdUrl?: string | null;
195+
authorIdentity?: string | null;
196+
bodyMatches?: boolean;
197+
};
198+
199+
if (proof.authorIdentity !== authorIdentity) {
200+
throw new CliError('OUTCOME_UNKNOWN', 'Answer was created but authorship could not be proven for the frozen current user');
201+
}
202+
if (!proof.createdTarget || !proof.bodyMatches || proof.createdTarget.split(':')[1] !== questionTarget.id) {
203+
throw new CliError('OUTCOME_UNKNOWN', 'Created answer proof did not match the requested question or payload');
204+
}
205+
206+
return buildResultRow(`Answered question ${questionTarget.id}`, target.kind, rawTarget, 'created', {
207+
created_target: proof.createdTarget,
208+
created_url: proof.createdUrl,
209+
author_identity: authorIdentity,
210+
});
211+
},
212+
});

0 commit comments

Comments
 (0)