-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(zhihu): fix identity detection, comment, answer, and search #1207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' }, | ||
|
|
@@ -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' }; | ||
| 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
|
||
| 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, | ||
| }); | ||
| }, | ||
|
|
||
There was a problem hiding this comment.
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 insidepage.evaluateand surface as an unhandled browser-eval error rather than a structured CLI error. Consider readingresp.text()and JSON-parsing defensively (or try/catch aroundresp.json()), returning a structured{ ok:false, status, message }even when the body is not JSON.