Skip to content
Open
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
23 changes: 11 additions & 12 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20319,7 +20319,7 @@
"name": "answer",
"description": "Answer a Zhihu question",
"domain": "www.zhihu.com",
"strategy": "ui",
"strategy": "cookie",
"browser": true,
"args": [
{
Expand Down Expand Up @@ -20362,14 +20362,14 @@
"type": "js",
"modulePath": "zhihu/answer.js",
"sourceFile": "zhihu/answer.js",
"navigateBefore": true
"navigateBefore": "https://www.zhihu.com"
},
{
"site": "zhihu",
"name": "comment",
"description": "Create a top-level comment on a Zhihu answer or article",
"domain": "zhihu.com",
"strategy": "ui",
"strategy": "cookie",
"browser": true,
"args": [
{
Expand Down Expand Up @@ -20406,13 +20406,12 @@
"target_type",
"target",
"author_identity",
"created_url",
"created_proof"
"created_url"
],
"type": "js",
"modulePath": "zhihu/comment.js",
"sourceFile": "zhihu/comment.js",
"navigateBefore": true
"navigateBefore": "https://zhihu.com"
},
{
"site": "zhihu",
Expand Down Expand Up @@ -20460,7 +20459,7 @@
"name": "favorite",
"description": "Favorite a Zhihu answer or article into a specific collection",
"domain": "zhihu.com",
"strategy": "ui",
"strategy": "cookie",
"browser": true,
"args": [
{
Expand Down Expand Up @@ -20501,14 +20500,14 @@
"type": "js",
"modulePath": "zhihu/favorite.js",
"sourceFile": "zhihu/favorite.js",
"navigateBefore": true
"navigateBefore": "https://zhihu.com"
},
{
"site": "zhihu",
"name": "follow",
"description": "Follow a Zhihu user or question",
"domain": "www.zhihu.com",
"strategy": "ui",
"strategy": "cookie",
"browser": true,
"args": [
{
Expand All @@ -20535,7 +20534,7 @@
"type": "js",
"modulePath": "zhihu/follow.js",
"sourceFile": "zhihu/follow.js",
"navigateBefore": true
"navigateBefore": "https://www.zhihu.com"
},
{
"site": "zhihu",
Expand Down Expand Up @@ -20569,7 +20568,7 @@
"name": "like",
"description": "Like a Zhihu answer or article",
"domain": "zhihu.com",
"strategy": "ui",
"strategy": "cookie",
"browser": true,
"args": [
{
Expand All @@ -20596,7 +20595,7 @@
"type": "js",
"modulePath": "zhihu/like.js",
"sourceFile": "zhihu/like.js",
"navigateBefore": true
"navigateBefore": "https://zhihu.com"
},
{
"site": "zhihu",
Expand Down
182 changes: 20 additions & 162 deletions clis/zhihu/answer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { assertAllowedKinds, parseTarget } 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,
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'target', positional: true, required: true, help: 'Zhihu question URL or typed target' },
Expand All @@ -23,171 +22,30 @@ cli({
requireExecute(kwargs);
const rawTarget = String(kwargs.target);
const target = assertAllowedKinds('answer', parseTarget(rawTarget));
const questionTarget = target;
const payload = await resolvePayload(kwargs);
await page.goto(target.url);
await page.wait(3);
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,
};
})()`);
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,
};
})()`);
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 };
})()`);
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);
const apiResult = await page.evaluate(`(async () => {
var questionId = ${JSON.stringify(target.id)};
var content = ${JSON.stringify(payload)};
var url = 'https://www.zhihu.com/api/v4/questions/' + questionId + '/answers';
var resp = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content, reshipment_settings: 'disallowed' }),
});
})
: 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 };
})()`);
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');
var data = await resp.json();
if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' };
Comment on lines +39 to +40
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The in-page API call unconditionally does await resp.json(). If Zhihu returns non-JSON (e.g., HTML on a 403/5xx/robot check), this will throw inside page.evaluate and surface as an unhandled browser-eval error rather than a structured CLI error. Consider reading resp.text() and JSON-parsing defensively (or try/catch around resp.json()), returning a structured { ok:false, status, message } even when the body is not JSON.

Suggested change
var data = await resp.json();
if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' };
var bodyText = await resp.text();
var data = null;
try {
data = bodyText ? JSON.parse(bodyText) : null;
}
catch (e) {
data = null;
}
if (!resp.ok) {
var errorMessage = data && data.error && data.error.message
? data.error.message
: (bodyText ? bodyText.slice(0, 200) : ('Request failed with status ' + resp.status));
return { ok: false, status: resp.status, message: errorMessage };
}
if (!data || typeof data !== 'object' || data.id == null) {
return { ok: false, status: resp.status, message: bodyText ? bodyText.slice(0, 200) : 'Invalid API response' };
}

Copilot uses AI. Check for mistakes.
return { ok: true, id: String(data.id), url: data.url || ('https://www.zhihu.com/question/' + questionId + '/answer/' + data.id) };
})()`);
if (!apiResult?.ok) {
throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to create answer');
}
Comment on lines +43 to 45
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On API failure, this currently throws CliError('COMMAND_EXEC', ...) for all statuses. In the same adapter, zhihu/question maps 401/403 to AuthRequiredError (see clis/zhihu/question.js:39-44). It’d be more consistent/helpful to surface 401/403 from the answer-create API as AuthRequiredError (or another auth-specific error) so users get the correct remediation message when their session expires.

Copilot uses AI. Check for mistakes.
return buildResultRow(`Answered question ${questionTarget.id}`, target.kind, rawTarget, 'created', {
created_target: proof.createdTarget,
created_url: proof.createdUrl,
return buildResultRow(`Answered question ${target.id}`, target.kind, rawTarget, 'created', {
created_target: 'answer:' + target.id + ':' + apiResult.id,
created_url: apiResult.url,
author_identity: authorIdentity,
});
},
Expand Down
Loading