Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ git clone git@github.com:jackwener/opencli.git && cd opencli && npm install && n
| **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
| **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` |
| **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `upvoted` `save` `saved` `comment` `subscribe` |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` |
| **amazon** | `bestsellers` `search` `product` `offer` `discussion` `movers-shakers` `new-releases` |
| **1688** | `search` `item` `assets` `download` `store` |
| **gemini** | `new` `ask` `image` `deep-research` `deep-research-result` |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ npx skills add jackwener/opencli --skill opencli-oneshot # 快速命令参
| **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
| **apple-podcasts** | `search` `episodes` `top` | 公开 |
| **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 公开 |
| **zhihu** | `hot` `search` `question` `download` | 浏览器 |
| **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 浏览器 |
| **weixin** | `download` | 浏览器 |
| **youtube** | `search` `video` `transcript` | 浏览器 |
| **boss** | `search` `detail` `recommend` `joblist` `greet` `batchgreet` `send` `chatlist` `chatmsg` `invite` `mark` `exchange` `resume` `stats` | 浏览器 |
Expand Down
103 changes: 103 additions & 0 deletions clis/zhihu/answer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import './answer.js';

describe('zhihu answer', () => {
it('rejects create mode when the current user already answered the question', async () => {
const cmd = getRegistry().get('zhihu/answer');
expect(cmd?.func).toBeTypeOf('function');

const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({ slug: 'alice' })
.mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: true }),
} as any;

await expect(
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
});

it('rejects anonymous mode instead of toggling it', async () => {
const cmd = getRegistry().get('zhihu/answer');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({ slug: 'alice' })
.mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'on' }),
} as any;

await expect(
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
});

it('rejects when a unique safe answer composer cannot be proven', async () => {
const cmd = getRegistry().get('zhihu/answer');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({ slug: 'alice' })
.mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: false }),
} as any;

await expect(
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
});

it('rejects when anonymous mode cannot be proven off', async () => {
const cmd = getRegistry().get('zhihu/answer');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({ slug: 'alice' })
.mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'unknown' }),
} as any;

await expect(
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
});

it('requires a side-effect-free entry path and exact editor content before publish', async () => {
const cmd = getRegistry().get('zhihu/answer');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn()
.mockResolvedValueOnce({ slug: 'alice' })
.mockResolvedValueOnce({ entryPathSafe: true })
.mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'off' })
.mockResolvedValueOnce({ editorContent: 'hello', bodyMatches: true })
.mockResolvedValueOnce({
createdTarget: 'answer:1:2',
createdUrl: 'https://www.zhihu.com/question/1/answer/2',
authorIdentity: 'alice',
bodyMatches: true,
}),
} as any;

await expect(
cmd!.func!(page, { target: 'question:1', text: 'hello', execute: true }),
).resolves.toEqual([
expect.objectContaining({
outcome: 'created',
created_target: 'answer:1:2',
created_url: 'https://www.zhihu.com/question/1/answer/2',
author_identity: 'alice',
}),
]);

expect(page.evaluate.mock.calls[1][0]).toContain('composerCandidates.length === 1');
expect(page.evaluate.mock.calls[1][0]).not.toContain('writeAnswerButton');
expect(page.evaluate.mock.calls[1][0]).toContain('const readAnswerAuthorSlug = (node) =>');
expect(page.evaluate.mock.calls[1][0]).toContain('const answerAuthorScopeSelector = ".AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop=\\"author\\"]"');
expect(page.evaluate.mock.calls[1][0]).not.toContain("node.querySelector('a[href^=\"/people/\"]')");
expect(page.evaluate.mock.calls[3][0]).toContain('composerCandidates.length !== 1');
expect(page.evaluate.mock.calls[4][0]).toContain('const readAnswerAuthorSlug = (node) =>');
expect(page.evaluate.mock.calls[4][0]).not.toContain("answerContainer?.querySelector('a[href^=\"/people/\"]')");
});
});
212 changes: 212 additions & 0 deletions clis/zhihu/answer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import type { IPage } from '@jackwener/opencli/types';
import { assertAllowedKinds, parseTarget, type ZhihuTarget } from './target.js';
import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';

const ANSWER_AUTHOR_SCOPE_SELECTOR = '.AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop="author"]';

cli({
site: 'zhihu',
name: 'answer',
description: 'Answer a Zhihu question',
domain: 'www.zhihu.com',
strategy: Strategy.UI,
browser: true,
args: [
{ name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
{ name: 'text', positional: true, help: 'Answer text' },
{ name: 'file', help: 'Answer text file path' },
{ name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
],
columns: ['status', 'outcome', 'message', 'target_type', 'target', 'created_target', 'created_url', 'author_identity'],
func: async (page: IPage | null, kwargs: Record<string, unknown>) => {
if (!page) throw new CommandExecutionError('Browser session required for zhihu answer');

requireExecute(kwargs);
const rawTarget = String(kwargs.target);
const target = assertAllowedKinds('answer', parseTarget(rawTarget));
const questionTarget = target as Extract<ZhihuTarget, { kind: 'question' }>;
const payload = await resolvePayload(kwargs);

await page.goto(target.url);
const authorIdentity = await resolveCurrentUserIdentity(page);

const entryPath = await page.evaluate(`(() => {
const currentUserSlug = ${JSON.stringify(authorIdentity)};
const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
const readAnswerAuthorSlug = (node) => {
const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
const slugs = Array.from(new Set(authorScopes
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
.filter(Boolean)));
return slugs.length === 1 ? slugs[0] : null;
};
const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, text, submitButton, nestedComment };
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
const hasExistingAnswerByCurrentUser = Array.from(document.querySelectorAll('[data-zop-question-answer], article')).some((node) => {
return readAnswerAuthorSlug(node) === currentUserSlug;
});
return {
entryPathSafe: composerCandidates.length === 1
&& !String(composerCandidates[0].text || '').trim()
&& !restoredDraft
&& !hasExistingAnswerByCurrentUser,
hasExistingAnswerByCurrentUser,
};
})()`) as { entryPathSafe?: boolean; hasExistingAnswerByCurrentUser?: boolean };

if (entryPath.hasExistingAnswerByCurrentUser) {
throw new CliError('ACTION_NOT_AVAILABLE', 'zhihu answer only supports creating a new answer when the current user has not already answered this question');
}
if (!entryPath.entryPathSafe) {
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor entry path was not proven side-effect free');
}

const editorState = await page.evaluate(`(async () => {
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, text, submitButton, nestedComment };
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
if (composerCandidates.length !== 1) return { editorState: 'unsafe', anonymousMode: 'unknown' };
const { editor, text } = composerCandidates[0];
const anonymousLabeledControl =
(composerCandidates[0].container && composerCandidates[0].container.querySelector('[aria-label*="匿名"], [title*="匿名"]'))
|| Array.from((composerCandidates[0].container || document).querySelectorAll('label, button, [role="switch"], [role="checkbox"]')).find((node) => /匿名/.test(node.textContent || ''))
|| null;
const anonymousToggle =
anonymousLabeledControl?.matches?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
? anonymousLabeledControl
: anonymousLabeledControl?.querySelector?.('input[type="checkbox"], [role="switch"], [role="checkbox"], button')
|| null;
let anonymousMode = 'unknown';
if (anonymousToggle) {
const ariaChecked = anonymousToggle.getAttribute && anonymousToggle.getAttribute('aria-checked');
const checked = 'checked' in anonymousToggle ? anonymousToggle.checked === true : false;
if (ariaChecked === 'true' || checked) anonymousMode = 'on';
else if (ariaChecked === 'false' || ('checked' in anonymousToggle && anonymousToggle.checked === false)) anonymousMode = 'off';
}
return {
editorState: editor && !text.trim() ? 'fresh_empty' : 'unsafe',
anonymousMode,
};
})()`) as { editorState?: string; anonymousMode?: 'on' | 'off' | 'unknown' };

if (editorState.editorState !== 'fresh_empty') {
throw new CliError('ACTION_NOT_AVAILABLE', 'Answer editor was not fresh and empty');
}
if (editorState.anonymousMode !== 'off') {
throw new CliError('ACTION_NOT_AVAILABLE', 'Anonymous answer mode could not be proven off for zhihu answer');
}

const editorCheck = await page.evaluate(`(async () => {
const textToInsert = ${JSON.stringify(payload)};
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, submitButton, nestedComment };
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
if (composerCandidates.length !== 1) return { editorContent: '', bodyMatches: false };
const { editor } = composerCandidates[0];
editor.focus();
if ('value' in editor) {
editor.value = '';
editor.dispatchEvent(new Event('input', { bubbles: true }));
editor.value = textToInsert;
editor.dispatchEvent(new Event('input', { bubbles: true }));
} else {
editor.textContent = '';
document.execCommand('insertText', false, textToInsert);
editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
}
await new Promise((resolve) => setTimeout(resolve, 200));
const content = 'value' in editor ? editor.value : (editor.textContent || '');
return { editorContent: content, bodyMatches: content === textToInsert };
})()`) as { editorContent?: string; bodyMatches?: boolean };

if (editorCheck.editorContent !== payload || !editorCheck.bodyMatches) {
throw new CliError('OUTCOME_UNKNOWN', 'Answer editor content did not exactly match the requested payload before publish');
}

const proof = await page.evaluate(`(async () => {
const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
const answerAuthorScopeSelector = ${JSON.stringify(ANSWER_AUTHOR_SCOPE_SELECTOR)};
const readAnswerAuthorSlug = (node) => {
const authorScopes = Array.from(node.querySelectorAll(answerAuthorScopeSelector));
const slugs = Array.from(new Set(authorScopes
.flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
.map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
.filter(Boolean)));
return slugs.length === 1 ? slugs[0] : null;
};
const composerCandidates = Array.from(document.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
const container = editor.closest('form, [role="dialog"], .AnswerForm, .DraftEditor-root, [data-za-module*="Answer"]') || editor.parentElement;
const submitButton = Array.from((container || document).querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
const nestedComment = Boolean(container?.closest('[data-comment-id], .CommentItem'));
return { editor, container, submitButton, nestedComment };
}).filter((candidate) => candidate.container && candidate.submitButton && !candidate.nestedComment);
if (composerCandidates.length !== 1) return { createdTarget: null, createdUrl: null, authorIdentity: null, bodyMatches: false };
const submitScope = composerCandidates[0].container || document;
const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|提交/.test(node.textContent || ''));
submit && submit.click();
await new Promise((resolve) => setTimeout(resolve, 1500));
const href = location.href;
const match = href.match(/question\\/(\\d+)\\/answer\\/(\\d+)/);
const targetHref = match ? '/question/' + match[1] + '/answer/' + match[2] : null;
const answerContainer = targetHref
? Array.from(document.querySelectorAll('[data-zop-question-answer], article')).find((node) => {
const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
if (dataAnswerId && dataAnswerId.includes(match[2])) return true;
return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
const hrefValue = link.getAttribute('href') || '';
return hrefValue.includes(targetHref);
});
})
: null;
const authorSlug = answerContainer ? readAnswerAuthorSlug(answerContainer) : null;
const bodyNode =
answerContainer?.querySelector('[itemprop="text"]')
|| answerContainer?.querySelector('.RichContent-inner')
|| answerContainer?.querySelector('.RichText')
|| answerContainer;
const bodyText = normalize(bodyNode?.textContent || '');
return match
? {
createdTarget: 'answer:' + match[1] + ':' + match[2],
createdUrl: href,
authorIdentity: authorSlug,
bodyMatches: bodyText === normalize(${JSON.stringify(payload)}),
}
: { createdTarget: null, createdUrl: null, authorIdentity: authorSlug, bodyMatches: false };
})()`) as {
createdTarget?: string | null;
createdUrl?: string | null;
authorIdentity?: string | null;
bodyMatches?: boolean;
};

if (proof.authorIdentity !== authorIdentity) {
throw new CliError('OUTCOME_UNKNOWN', 'Answer was created but authorship could not be proven for the frozen current user');
}
if (!proof.createdTarget || !proof.bodyMatches || proof.createdTarget.split(':')[1] !== questionTarget.id) {
throw new CliError('OUTCOME_UNKNOWN', 'Created answer proof did not match the requested question or payload');
}

return buildResultRow(`Answered question ${questionTarget.id}`, target.kind, rawTarget, 'created', {
created_target: proof.createdTarget,
created_url: proof.createdUrl,
author_identity: authorIdentity,
});
},
});
Loading