diff --git a/cli-manifest.json b/cli-manifest.json index 6eaea6361..68bd21126 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -20319,7 +20319,7 @@ "name": "answer", "description": "Answer a Zhihu question", "domain": "www.zhihu.com", - "strategy": "ui", + "strategy": "cookie", "browser": true, "args": [ { @@ -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": [ { @@ -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", @@ -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": [ { @@ -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": [ { @@ -20535,7 +20534,7 @@ "type": "js", "modulePath": "zhihu/follow.js", "sourceFile": "zhihu/follow.js", - "navigateBefore": true + "navigateBefore": "https://www.zhihu.com" }, { "site": "zhihu", @@ -20569,7 +20568,7 @@ "name": "like", "description": "Like a Zhihu answer or article", "domain": "zhihu.com", - "strategy": "ui", + "strategy": "cookie", "browser": true, "args": [ { @@ -20596,7 +20595,7 @@ "type": "js", "modulePath": "zhihu/like.js", "sourceFile": "zhihu/like.js", - "navigateBefore": true + "navigateBefore": "https://zhihu.com" }, { "site": "zhihu", diff --git a/clis/zhihu/answer.js b/clis/zhihu/answer.js index 91c11a13f..c27413f2e 100644 --- a/clis/zhihu/answer.js +++ b/clis/zhihu/answer.js @@ -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,31 @@ 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' }; + if (!data || !data.id) return { ok: false, status: resp.status, message: 'Answer API response did not include a created answer id' }; + 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'); } - 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, }); }, diff --git a/clis/zhihu/answer.test.js b/clis/zhihu/answer.test.js index 941263ae1..ac2b195b3 100644 --- a/clis/zhihu/answer.test.js +++ b/clis/zhihu/answer.test.js @@ -2,80 +2,53 @@ 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 () => { + it('registers as a cookie browser command', () => { 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 }), - }; - 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' }), - }; - await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' }); + expect(cmd).toBeDefined(); + expect(cmd.strategy).toBe('cookie'); + expect(cmd.browser).toBe(true); }); - it('rejects when a unique safe answer composer cannot be proven', async () => { + it('creates an answer via API and returns result', async () => { const cmd = getRegistry().get('zhihu/answer'); const page = { goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ slug: 'alice' }) - .mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: false }), + .mockResolvedValueOnce({ ok: true, id: '42', url: 'https://www.zhihu.com/question/1/answer/42' }), }; - await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' }); + const rows = await cmd.func(page, { target: 'question:1', text: 'hello', execute: true }); + expect(rows).toEqual([ + expect.objectContaining({ + outcome: 'created', + created_target: 'answer:1:42', + author_identity: 'alice', + }), + ]); }); - it('rejects when anonymous mode cannot be proven off', async () => { + it('throws on API error', async () => { const cmd = getRegistry().get('zhihu/answer'); const page = { goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ slug: 'alice' }) - .mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false }) - .mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'unknown' }), + .mockResolvedValueOnce({ ok: false, status: 400, message: 'already answered' }), }; - await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' }); + await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); }); - it('requires a side-effect-free entry path and exact editor content before publish', async () => { + it('requires the answer API response to include the created id', async () => { const cmd = getRegistry().get('zhihu/answer'); const page = { goto: vi.fn().mockResolvedValue(undefined), + wait: 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, - }), + .mockResolvedValueOnce({ ok: false, status: 200, message: 'Answer API response did not include a created answer id' }), }; - 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/\"]')"); + await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); + expect(page.evaluate.mock.calls[1][0]).toContain('Answer API response did not include a created answer id'); }); }); diff --git a/clis/zhihu/comment.js b/clis/zhihu/comment.js index db723c78d..727287861 100644 --- a/clis/zhihu/comment.js +++ b/clis/zhihu/comment.js @@ -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 COMMENT_AUTHOR_SCOPE_SELECTOR = '.CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop="author"]'; cli({ site: 'zhihu', name: 'comment', description: 'Create a top-level comment on a Zhihu answer or article', domain: 'zhihu.com', - strategy: Strategy.UI, + strategy: Strategy.COOKIE, browser: true, args: [ { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' }, @@ -16,7 +15,7 @@ cli({ { name: 'file', help: 'Comment text file path' }, { name: 'execute', type: 'boolean', help: 'Actually perform the write action' }, ], - columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url', 'created_proof'], + columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url'], func: async (page, kwargs) => { if (!page) throw new CommandExecutionError('Browser session required for zhihu comment'); @@ -25,311 +24,31 @@ cli({ const target = assertAllowedKinds('comment', parseTarget(rawTarget)); 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 targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]'); - let scope = document; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); - }); - }); - if (!block) return { entryPathSafe: false, wrongAnswer: true }; - scope = block; - } else { - scope = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - } - const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { - const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; - const replyHint = editor.getAttribute('data-reply-to') || ''; - const text = 'value' in editor ? editor.value || '' : (editor.textContent || ''); - const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); - return { editor, container, replyHint, text, nestedReply }; - }).filter((candidate) => candidate.container && !candidate.nestedReply); - return { - entryPathSafe: topLevelCandidates.length === 1 - && !restoredDraft - && !topLevelCandidates[0].replyHint - && !String(topLevelCandidates[0].text || '').trim(), - wrongAnswer: false, - }; - })()`); - if (entryPath.wrongAnswer) { - throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer::'); - } - if (!entryPath.entryPathSafe) { - throw new CliError('ACTION_NOT_AVAILABLE', 'Comment entry path was not proven side-effect free'); - } - const beforeSubmitSnapshot = await page.evaluate(`(() => { - const normalize = (value) => value.replace(/\\s+/g, ' ').trim(); - const targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - let scope = document; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); - }); - }); - if (!block) return { wrongAnswer: true, rows: [], commentLinks: [] }; - scope = block; - } else { - scope = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - } - return { - wrongAnswer: false, - rows: Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => ({ - id: node.getAttribute('data-comment-id') || '', - text: normalize(node.textContent || ''), - })), - commentLinks: Array.from(scope.querySelectorAll('a[href*="/comment/"]')) - .map((node) => node.getAttribute('href') || '') - .filter(Boolean), - }; - })()`); - if (beforeSubmitSnapshot.wrongAnswer) { - throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer::'); - } - const composer = await page.evaluate(`(async () => { - const targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - let scope = document; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); - }); - }); - if (!block) return { composerState: 'wrong_answer' }; - scope = block; - } else { - scope = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - } - const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { - const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; - const replyHint = editor.getAttribute('data-reply-to') || ''; - const text = 'value' in editor ? editor.value || '' : (editor.textContent || ''); - const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); - return { editor, container, replyHint, text, nestedReply }; - }).filter((candidate) => candidate.container && !candidate.nestedReply); - if (topLevelCandidates.length !== 1) return { composerState: 'unsafe' }; - return { - composerState: !topLevelCandidates[0].replyHint && !topLevelCandidates[0].text.trim() ? 'fresh_top_level' : 'unsafe', - }; - })()`); - if (composer.composerState === 'wrong_answer') { - throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer::'); - } - if (composer.composerState !== 'fresh_top_level') { - throw new CliError('ACTION_NOT_AVAILABLE', 'Comment composer was not a fresh top-level composer'); - } - const editorCheck = await page.evaluate(`(async () => { - const textToInsert = ${JSON.stringify(payload)}; - const targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - let scope = document; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); - }); - }); - if (!block) return { editorContent: '', mode: 'wrong_answer' }; - scope = block; - } else { - scope = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - } - const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { - const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; - const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); - return { editor, container, nestedReply }; - }).filter((candidate) => candidate.container && !candidate.nestedReply); - if (topLevelCandidates.length !== 1) return { editorContent: '', mode: 'missing' }; - const { editor } = topLevelCandidates[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 || ''); - const replyHint = editor.getAttribute('data-reply-to') || ''; - return { editorContent: content, mode: replyHint ? 'reply' : 'top_level' }; - })()`); - if (editorCheck.mode === 'wrong_answer') { - throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer::'); - } - if (editorCheck.mode !== 'top_level' || editorCheck.editorContent !== payload) { - throw new CliError('OUTCOME_UNKNOWN', 'Comment editor content did not exactly match the requested payload before submit'); - } - const proof = await page.evaluate(`(async () => { - const normalize = (value) => value.replace(/\\s+/g, ' ').trim(); - const commentAuthorScopeSelector = ${JSON.stringify(COMMENT_AUTHOR_SCOPE_SELECTOR)}; - const readCommentAuthorSlug = (node) => { - const authorScopes = Array.from(node.querySelectorAll(commentAuthorScopeSelector)); - 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 targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - let scope = document; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); - }); - }); - if (!block) return { proofType: 'wrong_answer' }; - scope = block; - } else { - scope = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - } - - const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { - const container = editor.closest('form, [role="dialog"], .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; - const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); - return { editor, container, nestedReply }; - }).filter((candidate) => candidate.container && !candidate.nestedReply); - if (topLevelCandidates.length !== 1) return { proofType: 'unknown' }; - const submitScope = topLevelCandidates[0].container || scope; - const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|评论|发送/.test(node.textContent || '')); - submit && submit.click(); - await new Promise((resolve) => setTimeout(resolve, 1200)); - const createdLink = Array.from(scope.querySelectorAll('a[href*="/comment/"]')).find((node) => { - const href = node.getAttribute('href') || ''; - return href.includes('/comment/') && !${JSON.stringify(beforeSubmitSnapshot.commentLinks ?? [])}.includes(href); - }); - - if (createdLink) { - const card = createdLink.closest('[data-comment-id], .CommentItem, li'); - const authorSlug = card ? readCommentAuthorSlug(card) : null; - const contentNode = - card?.querySelector('[data-comment-content], .RichContent-inner, .CommentItemV2-content, .CommentContent') - || card; - const text = normalize(contentNode?.textContent || ''); - const nestedReply = Boolean(card?.closest('ul ul, ol ol, li li') || card?.parentElement?.closest('[data-comment-id], .CommentItem')); - return { - proofType: 'stable_url', - createdUrl: new URL(createdLink.getAttribute('href') || '', location.origin).href, - commentScope: nestedReply ? 'nested_reply' : 'top_level_only', - authorIdentity: authorSlug, - targetMatches: text === normalize(${JSON.stringify(payload)}), - }; - } - - const currentUserSlug = ${JSON.stringify(authorIdentity)}; - const beforeIds = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.id).filter(Boolean))}); - const beforeTexts = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.text).filter(Boolean))}); - const normalizedPayload = normalize(${JSON.stringify(payload)}); - const after = Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => { - return { - id: node.getAttribute('data-comment-id') || '', - text: normalize(node.textContent || ''), - authorSlug: readCommentAuthorSlug(node), - topLevel: !node.closest('ul ul, ol ol, li li') && !node.parentElement?.closest('[data-comment-id], .CommentItem'), - }; - }); - - const matching = after.filter((row) => - !beforeIds.has(row.id) - && row.authorSlug === currentUserSlug - && row.topLevel - && row.text === normalizedPayload - && !beforeTexts.has(row.text) - ); - - return matching.length === 1 - ? { - proofType: 'fallback', - createdProof: { - proof_type: 'comment_fallback', - author_scope: 'current_user', - target_scope: 'requested_target', - comment_scope: 'top_level_only', - content_match: 'exact_normalized', - observed_after_submit: true, - present_in_pre_submit_snapshot: false, - new_matching_entries: 1, - post_submit_matching_entries: after.filter((row) => - row.authorSlug === currentUserSlug && row.topLevel && row.text === normalizedPayload - ).length, - snapshot_scope: ${JSON.stringify(target.kind === 'answer' - ? 'stabilized_expanded_target_answer_comment_list' - : 'stabilized_expanded_target_article_comment_list')}, - }, - } - : { proofType: 'unknown' }; - })()`); - if (proof.proofType === 'wrong_answer') { - throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer::'); - } - if (proof.proofType === 'fallback') { - return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', { - author_identity: authorIdentity, - created_proof: proof.createdProof, + const apiResult = await page.evaluate(`(async () => { + var targetKind = ${JSON.stringify(target.kind)}; + var targetId = ${JSON.stringify(target.id)}; + var content = ${JSON.stringify(payload)}; + var resourceType = targetKind === 'answer' ? 'answers' : 'articles'; + var url = 'https://www.zhihu.com/api/v4/' + resourceType + '/' + targetId + '/comments'; + var resp = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content }), }); - } - if (proof.proofType !== 'stable_url') { - throw new CliError('OUTCOME_UNKNOWN', 'Comment submit was dispatched, but the created object could not be proven safely'); - } - if (proof.commentScope !== 'top_level_only' || proof.authorIdentity !== authorIdentity || !proof.targetMatches) { - throw new CliError('OUTCOME_UNKNOWN', 'Stable comment URL was found, but authorship or top-level scope could not be proven safely'); - } - return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', { + var data = await resp.json(); + if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' }; + if (!data || !data.id) return { ok: false, status: resp.status, message: 'Comment API response did not include a created comment id' }; + return { ok: true, id: data.id, url: data.url }; + })()`); + if (!apiResult?.ok) { + throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to create comment'); + } + return buildResultRow(`Commented on ${target.kind} ${target.id}`, target.kind, rawTarget, 'created', { author_identity: authorIdentity, - created_url: proof.createdUrl, + created_url: apiResult.url || '', }); }, }); diff --git a/clis/zhihu/comment.test.js b/clis/zhihu/comment.test.js index 6998eb7a4..5b5080c05 100644 --- a/clis/zhihu/comment.test.js +++ b/clis/zhihu/comment.test.js @@ -2,53 +2,49 @@ import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; import './comment.js'; describe('zhihu comment', () => { - it('rejects composer paths that are not proven side-effect free', async () => { + it('registers as a cookie browser command', () => { + const cmd = getRegistry().get('zhihu/comment'); + expect(cmd).toBeDefined(); + expect(cmd.strategy).toBe('cookie'); + expect(cmd.browser).toBe(true); + }); + it('creates a comment via API and returns result', async () => { const cmd = getRegistry().get('zhihu/comment'); - expect(cmd?.func).toBeTypeOf('function'); const page = { goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ slug: 'alice' }) - .mockResolvedValueOnce({ entryPathSafe: false }) - .mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] }), + .mockResolvedValueOnce({ ok: true, id: 99, url: 'https://www.zhihu.com/api/v4/comments/99' }), }; - await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' }); + const rows = await cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true }); + expect(rows).toEqual([ + expect.objectContaining({ outcome: 'created', author_identity: 'alice' }), + ]); }); - it('requires exact editor replacement before accepting fallback proof', async () => { + it('throws on API error', async () => { const cmd = getRegistry().get('zhihu/comment'); const page = { goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn() .mockResolvedValueOnce({ slug: 'alice' }) - .mockResolvedValueOnce({ entryPathSafe: true }) - .mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] }) - .mockResolvedValueOnce({ composerState: 'fresh_top_level' }) - .mockResolvedValueOnce({ editorContent: 'hello', mode: 'top_level' }) - .mockResolvedValueOnce({ - proofType: 'fallback', - createdProof: { - proof_type: 'comment_fallback', - author_scope: 'current_user', - target_scope: 'requested_target', - comment_scope: 'top_level_only', - content_match: 'exact_normalized', - observed_after_submit: true, - present_in_pre_submit_snapshot: false, - new_matching_entries: 1, - post_submit_matching_entries: 1, - snapshot_scope: 'stabilized_expanded_target_comment_list', - }, - }), + .mockResolvedValueOnce({ ok: false, status: 403, message: 'forbidden' }), }; - await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'created', author_identity: 'alice', created_proof: expect.any(Object) }), - ]); - expect(page.evaluate.mock.calls[1][0]).toContain('topLevelCandidates.length === 1'); - expect(page.evaluate.mock.calls[1][0]).not.toContain('commentTrigger'); - expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-answerid')"); - expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-zop-question-answer')"); - expect(page.evaluate.mock.calls[5][0]).toContain('const readCommentAuthorSlug = (node) =>'); - expect(page.evaluate.mock.calls[5][0]).toContain('const commentAuthorScopeSelector = ".CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop=\\"author\\"]"'); - expect(page.evaluate.mock.calls[5][0]).not.toContain("card?.querySelector('a[href^=\"/people/\"]')"); + await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); + }); + it('requires the comment API response to include the created id', async () => { + const cmd = getRegistry().get('zhihu/comment'); + const page = { + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn() + .mockResolvedValueOnce({ slug: 'alice' }) + .mockResolvedValueOnce({ ok: false, status: 200, message: 'Comment API response did not include a created comment id' }), + }; + await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); + expect(page.evaluate.mock.calls[1][0]).toContain('Comment API response did not include a created comment id'); }); }); diff --git a/clis/zhihu/favorite.js b/clis/zhihu/favorite.js index 21956f5da..3937f7f2b 100644 --- a/clis/zhihu/favorite.js +++ b/clis/zhihu/favorite.js @@ -2,9 +2,6 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors'; import { cli, Strategy } from '@jackwener/opencli/registry'; import { assertAllowedKinds, parseTarget } from './target.js'; import { buildResultRow, requireExecute } from './write-shared.js'; -function rowKey(row) { - return row.id || `name:${normalizeCollectionName(row.name)}`; -} function normalizeCollectionName(value) { return value .replace(/\s+/g, ' ') @@ -17,7 +14,7 @@ cli({ name: 'favorite', description: 'Favorite a Zhihu answer or article into a specific collection', domain: 'zhihu.com', - strategy: Strategy.UI, + strategy: Strategy.COOKIE, browser: true, args: [ { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' }, @@ -37,188 +34,53 @@ cli({ if ((collectionName ? 1 : 0) + (collectionId ? 1 : 0) !== 1) { throw new CliError('INVALID_INPUT', 'Use exactly one of --collection or --collection-id'); } - await page.goto(target.url); - const preflight = await page.evaluate(`(async () => { - const targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - const wantedName = ${JSON.stringify(collectionName ?? null)}; - const wantedId = ${JSON.stringify(collectionId ?? null)}; - - let scope = document; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); - }); - }); - if (!block) return { wrongAnswer: true, chooserRows: [] }; - scope = block; - } else { - scope = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - } - - const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || '')); - if (!favoriteButton) return { wrongAnswer: false, missingChooser: true, chooserRows: [] }; - favoriteButton.click(); - await new Promise((resolve) => setTimeout(resolve, 600)); - - const chooserRows = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button')) - .map((node) => { - const text = (node.textContent || '').trim(); - const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || ''; - const selected = node.getAttribute('aria-checked') === 'true' - || node.getAttribute('aria-pressed') === 'true' - || /已选|已收藏/.test(text); - return text ? { id, name: text, selected } : null; - }) - .filter(Boolean); + await page.goto('https://www.zhihu.com'); + await page.wait(2); + const apiResult = await page.evaluate(`(async () => { + var collectionId = ${JSON.stringify(collectionId || null)}; + var collectionName = ${JSON.stringify(collectionName || null)}; + var targetKind = ${JSON.stringify(target.kind)}; + var targetId = ${JSON.stringify(target.id)}; + var normalizeCollectionName = function(value) { + return String(value || '') + .replace(/\\s+/g, ' ') + .replace(/\\s+\\d+\\s*(条内容|个内容|items?)$/i, '') + .replace(/\\s+(公开|私密|默认)$/i, '') + .trim() + .toLowerCase(); + }; - return { - wrongAnswer: false, - missingChooser: chooserRows.length === 0, - chooserRows, - targetRowId: wantedId, - targetRowName: wantedName, - }; - })()`); - if (preflight.wrongAnswer) { - throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer::'); - } - if (preflight.missingChooser) { - throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser did not open on the requested target'); - } - const matchingRows = preflight.chooserRows.filter((row) => (collectionId - ? row.id === collectionId - : normalizeCollectionName(row.name) === normalizeCollectionName(collectionName || ''))); - if (collectionId && !matchingRows.some((row) => row.id === collectionId)) { - throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not confirm the requested stable collection id'); - } - if (!collectionId && matchingRows.length !== 1) { - throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not prove that the requested collection name is globally unique'); - } - const targetRow = matchingRows[0]; - const targetRowKey = rowKey(targetRow); - const selectedBefore = preflight.chooserRows.filter((row) => row.selected).map(rowKey); - const verify = await page.evaluate(`(async () => { - const targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - const targetWasSelected = ${JSON.stringify(targetRow.selected)}; - const wantedName = ${JSON.stringify(collectionName ?? null)}; - const wantedId = ${JSON.stringify(collectionId ?? null)}; - const normalizeCollectionName = (value) => String(value || '') - .replace(/\\s+/g, ' ') - .replace(/\\s+\\d+\\s*(条内容|个内容|items?)$/i, '') - .replace(/\\s+(公开|私密|默认)$/i, '') - .trim(); - const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name); + if (!collectionId && collectionName) { + var listResp = await fetch('https://www.zhihu.com/api/v4/people/self/collections?limit=50', { credentials: 'include' }); + if (!listResp.ok) return { ok: false, message: 'Failed to list collections: HTTP ' + listResp.status }; + var listData = {}; + try { listData = await listResp.json(); } catch(e) { + return { ok: false, message: 'Failed to parse collection list' }; + } + var needle = normalizeCollectionName(collectionName); + var matches = (listData.data || []).filter(function(c) { return normalizeCollectionName(c.title) === needle; }); + if (matches.length === 0) return { ok: false, message: 'Collection not found: ' + collectionName }; + if (matches.length > 1) return { ok: false, message: 'Collection name is ambiguous: ' + collectionName }; + collectionId = String(matches[0].id); + } - const chooserSelector = '[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button'; - const readChooserRows = () => Array.from(document.querySelectorAll(chooserSelector)) - .map((node) => { - const text = (node.textContent || '').trim(); - const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || ''; - const selected = node.getAttribute('aria-checked') === 'true' - || node.getAttribute('aria-pressed') === 'true' - || /已选|已收藏/.test(text); - return text ? { id, name: text, selected } : null; - }) - .filter(Boolean); - const waitForChooserRows = async (expectedPresent) => { - for (let attempt = 0; attempt < 10; attempt += 1) { - const rows = readChooserRows(); - if (expectedPresent ? rows.length > 0 : rows.length === 0) return rows; - await new Promise((resolve) => setTimeout(resolve, 150)); - } - return readChooserRows(); - }; - const closeChooser = async () => { - const closeButton = Array.from(document.querySelectorAll('[role="dialog"] button, [role="dialog"] [role="button"]')).find((node) => { - const text = (node.textContent || '').trim(); - const aria = node.getAttribute('aria-label') || ''; - return /关闭|取消|收起/.test(text) || /关闭|cancel|close/i.test(aria); - }); - closeButton && closeButton.click(); - return waitForChooserRows(false); - }; - const reopenChooser = async () => { - let scope = document; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); + var resp = await fetch('https://www.zhihu.com/api/v4/favlists/' + collectionId + '/items', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ item_id: targetId, item_type: targetKind }), }); - }); - if (!block) return []; - scope = block; - } else { - scope = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - } - const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || '')); - favoriteButton && favoriteButton.click(); - return waitForChooserRows(true); - }; - - let chooserRows = readChooserRows(); - let sawChooserClose = false; - if (!targetWasSelected) { - const row = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button')).find((node) => { - const text = (node.textContent || '').trim(); - const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || ''; - return wantedId ? id === wantedId : normalizeCollectionName(text) === normalizeCollectionName(wantedName); - }); - row && row.click(); - await new Promise((resolve) => setTimeout(resolve, 300)); - const submit = Array.from(document.querySelectorAll('[role="dialog"] button')).find((node) => /完成|确定|保存/.test(node.textContent || '')); - submit && submit.click(); - chooserRows = await waitForChooserRows(false); - sawChooserClose = chooserRows.length === 0; - } else { - chooserRows = await closeChooser(); - sawChooserClose = chooserRows.length === 0; - } - if (sawChooserClose) { - chooserRows = await reopenChooser(); - } - - return { - persisted: sawChooserClose && chooserRows.length > 0, - readbackSource: sawChooserClose && chooserRows.length > 0 ? 'reopened_chooser' : (chooserRows.length > 0 ? 'same_modal' : 'missing'), - selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey), - targetSelected: chooserRows.some((row) => rowKey(row) === ${JSON.stringify(targetRowKey)} && row.selected), - }; - })()`); - if (!verify.persisted) { - throw new CliError('OUTCOME_UNKNOWN', 'Favorite action may have been applied, but persisted read-back was unavailable'); - } - if (verify.readbackSource !== 'reopened_chooser') { - throw new CliError('OUTCOME_UNKNOWN', 'Favorite state was not re-read from a reopened chooser after submit'); - } - if (!verify.targetSelected) { - throw new CliError('OUTCOME_UNKNOWN', 'Favorite chooser remained readable, but the requested collection was not confirmed as selected'); - } - if (!selectedBefore.every((row) => verify.selectedAfter.includes(row))) { - throw new CliError('OUTCOME_UNKNOWN', `Favorite action changed unrelated collection membership: before=${JSON.stringify(selectedBefore)} after=${JSON.stringify(verify.selectedAfter)}`); + if (resp.ok || resp.status === 204) return { ok: true, collectionId: collectionId }; + var data = {}; + try { data = await resp.json(); } catch(e) {} + return { ok: false, message: data.error ? data.error.message : 'HTTP ' + resp.status }; + })()`); + if (!apiResult?.ok) { + throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to favorite'); } - const outcome = targetRow.selected ? 'already_applied' : 'applied'; - return buildResultRow(targetRow.selected ? `Already favorited ${target.kind}` : `Favorited ${target.kind}`, target.kind, rawTarget, outcome, { - collection_name: collectionName ?? targetRow.name, - ...(targetRow.id ? { collection_id: targetRow.id } : {}), + return buildResultRow(`Favorited ${target.kind} ${target.id}`, target.kind, rawTarget, 'applied', { + collection_name: collectionName || '', + collection_id: apiResult.collectionId || collectionId || '', }); }, }); diff --git a/clis/zhihu/favorite.test.js b/clis/zhihu/favorite.test.js index 6c23173aa..225702982 100644 --- a/clis/zhihu/favorite.test.js +++ b/clis/zhihu/favorite.test.js @@ -2,195 +2,58 @@ import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; import './favorite.js'; describe('zhihu favorite', () => { - it('rejects missing collection selectors before opening the chooser', async () => { + it('registers as a cookie browser command', () => { const cmd = getRegistry().get('zhihu/favorite'); - expect(cmd?.func).toBeTypeOf('function'); - const page = { goto: vi.fn(), evaluate: vi.fn() }; - await expect(cmd.func(page, { target: 'article:1', execute: true })).rejects.toMatchObject({ - code: 'INVALID_INPUT', - }); - expect(page.goto).not.toHaveBeenCalled(); - expect(page.evaluate).not.toHaveBeenCalled(); + expect(cmd).toBeDefined(); + expect(cmd.strategy).toBe('cookie'); }); - it('requires persisted read-back and preserves previously selected collections', async () => { + it('favorites via API with collection-id', async () => { const cmd = getRegistry().get('zhihu/favorite'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ - chooserRows: [ - { id: 'fav-a', name: '已存在', selected: true }, - { id: 'fav-b', name: '默认收藏夹', selected: false }, - ], - targetRowId: 'fav-b', - targetRowName: '默认收藏夹', - }) - .mockResolvedValueOnce({ - persisted: true, - readbackSource: 'reopened_chooser', - selectedBefore: ['fav-a'], - selectedAfter: ['fav-a', 'fav-b'], - targetSelected: true, - }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: true, collectionId: '123' }), }; - await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹', target: 'article:1' }), - ]); - expect(page.evaluate.mock.calls[1][0]).toContain('waitForChooserRows(false)'); - expect(page.evaluate.mock.calls[1][0]).toContain("readbackSource"); + const rows = await cmd.func(page, { target: 'answer:1:2', 'collection-id': '123', execute: true }); + expect(rows).toEqual([expect.objectContaining({ outcome: 'applied', collection_id: '123' })]); }); - it('requires persisted read-back before returning already_applied', async () => { + it('throws on API error', async () => { const cmd = getRegistry().get('zhihu/favorite'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ - chooserRows: [{ id: 'fav-a', name: '默认收藏夹', selected: true }], - targetRowId: 'fav-a', - targetRowName: '默认收藏夹', - }) - .mockResolvedValueOnce({ - persisted: true, - readbackSource: 'reopened_chooser', - selectedAfter: ['fav-a'], - targetSelected: true, - }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'collection not found' }), }; - await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'already_applied', collection_name: '默认收藏夹' }), - ]); + await expect(cmd.func(page, { target: 'answer:1:2', 'collection-id': '123', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); }); - it('accepts --collection-id as the stable selector path', async () => { + it('requires exact normalized collection-name matches', async () => { const cmd = getRegistry().get('zhihu/favorite'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ - chooserRows: [ - { id: 'fav-a', name: '默认收藏夹', selected: false }, - { id: 'fav-b', name: '同名收藏夹', selected: false }, - ], - targetRowId: 'fav-b', - targetRowName: null, - }) - .mockResolvedValueOnce({ - persisted: true, - readbackSource: 'reopened_chooser', - selectedAfter: ['fav-b'], - targetSelected: true, - }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'Collection not found: AI' }), }; - await expect(cmd.func(page, { target: 'article:1', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b' }), - ]); + await expect(cmd.func(page, { target: 'answer:1:2', collection: 'AI', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); + expect(page.evaluate.mock.calls[0][0]).toContain('normalizeCollectionName(c.title) === needle'); + expect(page.evaluate.mock.calls[0][0]).not.toContain('.includes(needle)'); }); - it('rejects duplicate collection names before selecting any row', async () => { + it('fails fast on ambiguous collection-name matches', async () => { const cmd = getRegistry().get('zhihu/favorite'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ - chooserRows: [ - { id: 'fav-a', name: '默认收藏夹', selected: false }, - { id: 'fav-b', name: '默认收藏夹', selected: false }, - ], - }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'Collection name is ambiguous: 默认收藏夹' }), }; - await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' }); + await expect(cmd.func(page, { target: 'answer:1:2', collection: '默认收藏夹', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); + expect(page.evaluate.mock.calls[0][0]).toContain('matches.length > 1'); }); - it('rejects optimistic chooser state that was not re-read from a reopened chooser', async () => { + it('requires exactly one of --collection or --collection-id', async () => { const cmd = getRegistry().get('zhihu/favorite'); - const page = { - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ - chooserRows: [ - { id: 'fav-a', name: '已存在', selected: true }, - { id: 'fav-b', name: '默认收藏夹', selected: false }, - ], - targetRowId: 'fav-b', - targetRowName: '默认收藏夹', - }) - .mockResolvedValueOnce({ - persisted: true, - readbackSource: 'same_modal', - selectedAfter: ['fav-a', 'fav-b'], - targetSelected: true, - }), - }; - await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'OUTCOME_UNKNOWN' }); - }); - it('matches unique collection names even when chooser rows include extra UI text', async () => { - const cmd = getRegistry().get('zhihu/favorite'); - const page = { - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ - chooserRows: [ - { id: 'fav-b', name: '默认收藏夹 12 条内容', selected: false }, - ], - targetRowId: null, - targetRowName: '默认收藏夹', - }) - .mockResolvedValueOnce({ - persisted: true, - readbackSource: 'reopened_chooser', - selectedAfter: ['fav-b'], - targetSelected: true, - }), - }; - await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }), - ]); - expect(page.evaluate.mock.calls[1][0]).toContain('normalizeCollectionName'); - }); - it('normalizes id-less row keys during reopened chooser verification', async () => { - const cmd = getRegistry().get('zhihu/favorite'); - const page = { - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ - chooserRows: [ - { id: '', name: '默认收藏夹 12 条内容', selected: false }, - ], - targetRowId: null, - targetRowName: '默认收藏夹', - }) - .mockResolvedValueOnce({ - persisted: true, - readbackSource: 'reopened_chooser', - selectedAfter: ['name:默认收藏夹'], - targetSelected: true, - }), - }; - await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }), - ]); - expect(page.evaluate.mock.calls[1][0]).toContain("const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);"); - expect(page.evaluate.mock.calls[1][0]).toContain('selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey)'); - }); - it('reuses data-attribute answer anchoring during reopened chooser verification', async () => { - const cmd = getRegistry().get('zhihu/favorite'); - const page = { - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn() - .mockResolvedValueOnce({ - chooserRows: [ - { id: 'fav-b', name: '默认收藏夹', selected: false }, - ], - targetRowId: 'fav-b', - targetRowName: null, - }) - .mockResolvedValueOnce({ - persisted: true, - readbackSource: 'reopened_chooser', - selectedAfter: ['fav-b'], - targetSelected: true, - }), - }; - await expect(cmd.func(page, { target: 'answer:1:2', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b', target: 'answer:1:2' }), - ]); - expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-answerid')"); - expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-zop-question-answer')"); + const page = { goto: vi.fn(), wait: vi.fn(), evaluate: vi.fn() }; + await expect(cmd.func(page, { target: 'answer:1:2', execute: true })) + .rejects.toMatchObject({ code: 'INVALID_INPUT' }); }); }); diff --git a/clis/zhihu/follow.js b/clis/zhihu/follow.js index 166c34eb6..01014fc0a 100644 --- a/clis/zhihu/follow.js +++ b/clis/zhihu/follow.js @@ -7,7 +7,7 @@ cli({ name: 'follow', description: 'Follow a Zhihu user or question', domain: 'www.zhihu.com', - strategy: Strategy.UI, + strategy: Strategy.COOKIE, browser: true, args: [ { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' }, @@ -20,61 +20,30 @@ cli({ requireExecute(kwargs); const rawTarget = String(kwargs.target); const target = assertAllowedKinds('follow', parseTarget(rawTarget)); - await page.goto(target.url); - const result = await page.evaluate(`(async () => { - const targetKind = ${JSON.stringify(target.kind)}; - const mainRoot = document.querySelector('main') || document; - let followBtn = null; - - if (targetKind === 'question') { - const questionRoots = Array.from(mainRoot.querySelectorAll('.QuestionHeader, .Question-main, [data-zop-question-id], [class*="QuestionHeader"]')); - const scopedRoots = questionRoots.length ? questionRoots : [mainRoot]; - const candidates = Array.from(new Set(scopedRoots.flatMap((root) => Array.from(root.querySelectorAll('button, a'))))).filter((node) => { - const text = (node.textContent || '').trim(); - const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations')); - const inAnswerBlock = Boolean(node.closest('article, .AnswerItem, [data-zop-question-answer]')); - return /关注问题|已关注/.test(text) && !inAside && !inAnswerBlock; - }); - if (candidates.length !== 1) return { state: 'ambiguous_question_follow' }; - followBtn = candidates[0]; - } else { - const candidates = Array.from(mainRoot.querySelectorAll('button, a')).filter((node) => { - const text = (node.textContent || '').trim(); - const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations')); - return /关注|已关注/.test(text) && !/邀请|收藏|评论/.test(text) && !inAside; - }); - - if (candidates.length !== 1) return { state: 'ambiguous_user_follow' }; - followBtn = candidates[0]; - } - - if (!followBtn) return { state: 'missing' }; - if ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true') { - return { state: 'already_following' }; - } - - followBtn.click(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - - return ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true') - ? { state: 'followed' } - : { state: 'unknown' }; - })()`); - if (result?.state === 'already_following') { - return buildResultRow(`Already followed ${target.kind}`, target.kind, rawTarget, 'already_applied'); + await page.goto('https://www.zhihu.com'); + await page.wait(2); + const apiResult = await page.evaluate(`(async () => { + var targetKind = ${JSON.stringify(target.kind)}; + var targetId = ${JSON.stringify(target.kind === 'user' ? target.slug : target.id)}; + var url; + if (targetKind === 'question') { + url = 'https://www.zhihu.com/api/v4/questions/' + targetId + '/followers'; + } else if (targetKind === 'user') { + url = 'https://www.zhihu.com/api/v4/members/' + targetId + '/followers'; + } else { + return { ok: false, message: 'unsupported target type: ' + targetKind }; + } + var resp = await fetch(url, { method: 'POST', credentials: 'include' }); + if (!resp.ok) { + var data = {}; + try { data = await resp.json(); } catch(e) {} + return { ok: false, message: data.error ? data.error.message : 'HTTP ' + resp.status }; + } + return { ok: true }; + })()`); + if (!apiResult?.ok) { + throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to follow'); } - if (result?.state === 'ambiguous_question_follow') { - throw new CliError('ACTION_NOT_AVAILABLE', 'Question follow control was not uniquely anchored on the requested question page'); - } - if (result?.state === 'ambiguous_user_follow') { - throw new CliError('ACTION_NOT_AVAILABLE', 'User follow control was not uniquely anchored on the requested profile page'); - } - if (result?.state === 'missing') { - throw new CliError('ACTION_FAILED', 'Zhihu follow control was missing before any write was dispatched'); - } - if (result?.state !== 'followed') { - throw new CliError('OUTCOME_UNKNOWN', 'Zhihu follow click was dispatched, but the final state could not be verified safely'); - } - return buildResultRow(`Followed ${target.kind}`, target.kind, rawTarget, 'applied'); + return buildResultRow(`Followed ${target.kind} ${target.kind === 'user' ? target.slug : target.id}`, target.kind, rawTarget, 'applied'); }, }); diff --git a/clis/zhihu/follow.test.js b/clis/zhihu/follow.test.js index a468d893c..00f92e2ed 100644 --- a/clis/zhihu/follow.test.js +++ b/clis/zhihu/follow.test.js @@ -2,44 +2,41 @@ import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; import './follow.js'; describe('zhihu follow', () => { - it('rejects missing --execute before any browser write path', async () => { + it('registers as a cookie browser command', () => { const cmd = getRegistry().get('zhihu/follow'); - expect(cmd?.func).toBeTypeOf('function'); - const page = { goto: vi.fn(), evaluate: vi.fn() }; - await expect(cmd.func(page, { target: 'question:123' })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); - expect(page.goto).not.toHaveBeenCalled(); - expect(page.evaluate).not.toHaveBeenCalled(); + expect(cmd).toBeDefined(); + expect(cmd.strategy).toBe('cookie'); }); - it('rejects user pages where the primary follow control is not uniquely anchored', async () => { + it('follows via API and returns result', async () => { const cmd = getRegistry().get('zhihu/follow'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_user_follow' }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: true }), }; - await expect(cmd.func(page, { target: 'user:alice', execute: true })).rejects.toMatchObject({ - code: 'ACTION_NOT_AVAILABLE', - }); + const rows = await cmd.func(page, { target: 'question:123', execute: true }); + expect(rows).toEqual([expect.objectContaining({ outcome: 'applied' })]); }); - it('returns already_applied when already following', async () => { + it('uses the parsed user slug for user follow API calls', async () => { const cmd = getRegistry().get('zhihu/follow'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'already_following' }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: true }), }; - await expect(cmd.func(page, { target: 'question:123', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'already_applied', target_type: 'question', target: 'question:123' }), - ]); + await cmd.func(page, { target: 'user:alice', execute: true }); + expect(page.evaluate.mock.calls[0][0]).toContain("'https://www.zhihu.com/api/v4/members/' + targetId + '/followers'"); + expect(page.evaluate.mock.calls[0][0]).toContain('var targetId = "alice"'); + expect(page.evaluate.mock.calls[0][0]).not.toContain('undefined'); }); - it('rejects question pages where the question follow control is not uniquely anchored', async () => { + it('throws on API error', async () => { const cmd = getRegistry().get('zhihu/follow'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_question_follow' }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'already following' }), }; - await expect(cmd.func(page, { target: 'question:123', execute: true })).rejects.toMatchObject({ - code: 'ACTION_NOT_AVAILABLE', - }); - expect(page.evaluate.mock.calls[0][0]).toContain('QuestionHeader'); - expect(page.evaluate.mock.calls[0][0]).toContain('new Set('); + await expect(cmd.func(page, { target: 'question:123', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); }); }); diff --git a/clis/zhihu/like.js b/clis/zhihu/like.js index 8afa6aa5b..4243f1aba 100644 --- a/clis/zhihu/like.js +++ b/clis/zhihu/like.js @@ -7,7 +7,7 @@ cli({ name: 'like', description: 'Like a Zhihu answer or article', domain: 'zhihu.com', - strategy: Strategy.UI, + strategy: Strategy.COOKIE, browser: true, args: [ { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' }, @@ -20,72 +20,27 @@ cli({ requireExecute(kwargs); const rawTarget = String(kwargs.target); const target = assertAllowedKinds('like', parseTarget(rawTarget)); - await page.goto(target.url); - const result = await page.evaluate(`(async () => { - const targetKind = ${JSON.stringify(target.kind)}; - const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; - const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; - - let btn = null; - if (targetKind === 'answer') { - const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { - const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; - if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; - return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { - const href = link.getAttribute('href') || ''; - return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); - }); - }); - if (!block) return { state: 'wrong_answer' }; - const candidates = Array.from(block?.querySelectorAll('button') || []).filter((node) => { - const text = (node.textContent || '').trim(); - const inCommentItem = Boolean(node.closest('[data-comment-id], .CommentItem')); - return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed') && !inCommentItem; - }); - if (candidates.length !== 1) return { state: 'ambiguous_answer_like' }; - btn = candidates[0]; - } else { - const articleRoot = - document.querySelector('article') - || document.querySelector('.Post-Main') - || document.querySelector('[itemprop="articleBody"]') - || document; - const candidates = Array.from(articleRoot.querySelectorAll('button')).filter((node) => { - const text = (node.textContent || '').trim(); - return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed'); - }); - if (candidates.length !== 1) return { state: 'ambiguous_article_like' }; - btn = candidates[0]; - } - - if (!btn) return { state: 'missing' }; - if (btn.getAttribute('aria-pressed') === 'true') return { state: 'already_liked' }; - - btn.click(); - await new Promise((resolve) => setTimeout(resolve, 1200)); - - return btn.getAttribute('aria-pressed') === 'true' - ? { state: 'liked' } - : { state: 'unknown' }; - })()`); - if (result?.state === 'wrong_answer') { - throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer::'); + await page.goto('https://www.zhihu.com'); + await page.wait(2); + const apiResult = await page.evaluate(`(async () => { + var targetKind = ${JSON.stringify(target.kind)}; + var targetId = ${JSON.stringify(target.id)}; + var resourceType = targetKind === 'answer' ? 'answers' : 'articles'; + var url = 'https://www.zhihu.com/api/v4/' + resourceType + '/' + targetId + '/voters'; + var resp = await fetch(url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'up' }), + }); + var data = await resp.json(); + if (!resp.ok) return { ok: false, message: data.error ? data.error.message : 'unknown error' }; + if (data && data.success === false) return { ok: false, message: 'Zhihu like API reported success=false' }; + return { ok: true, success: data.success }; + })()`); + if (!apiResult?.ok) { + throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to like'); } - if (result?.state === 'already_liked') { - return buildResultRow(`Already liked ${target.kind}`, target.kind, rawTarget, 'already_applied'); - } - if (result?.state === 'ambiguous_answer_like') { - throw new CliError('ACTION_NOT_AVAILABLE', 'Answer like control was not uniquely anchored on the requested answer'); - } - if (result?.state === 'ambiguous_article_like') { - throw new CliError('ACTION_NOT_AVAILABLE', 'Article like control was not uniquely anchored on the requested target'); - } - if (result?.state === 'missing') { - throw new CliError('ACTION_FAILED', 'Zhihu like control was missing before any write was dispatched'); - } - if (result?.state !== 'liked') { - throw new CliError('OUTCOME_UNKNOWN', 'Zhihu like click was dispatched, but the final state could not be verified safely'); - } - return buildResultRow(`Liked ${target.kind}`, target.kind, rawTarget, 'applied'); + return buildResultRow(`Liked ${target.kind} ${target.id}`, target.kind, rawTarget, 'applied'); }, }); diff --git a/clis/zhihu/like.test.js b/clis/zhihu/like.test.js index 7fc9ddcf4..a80a57f8e 100644 --- a/clis/zhihu/like.test.js +++ b/clis/zhihu/like.test.js @@ -2,63 +2,40 @@ import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '@jackwener/opencli/registry'; import './like.js'; describe('zhihu like', () => { - it('rejects article pages where the like control is not uniquely anchored', async () => { + it('registers as a cookie browser command', () => { const cmd = getRegistry().get('zhihu/like'); - expect(cmd?.func).toBeTypeOf('function'); - const page = { - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_article_like' }), - }; - await expect(cmd.func(page, { target: 'article:9', execute: true })).rejects.toMatchObject({ - code: 'ACTION_NOT_AVAILABLE', - }); - }); - it('returns already_applied for an already-liked article target', async () => { - const cmd = getRegistry().get('zhihu/like'); - const page = { - goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'already_liked' }), - }; - await expect(cmd.func(page, { target: 'article:9', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'already_applied', target_type: 'article', target: 'article:9' }), - ]); + expect(cmd).toBeDefined(); + expect(cmd.strategy).toBe('cookie'); }); - it('anchors to the requested answer block before clicking like', async () => { + it('likes via API and returns result', async () => { const cmd = getRegistry().get('zhihu/like'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'liked' }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: true, success: true }), }; - await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).resolves.toEqual([ - expect.objectContaining({ outcome: 'applied', target_type: 'answer', target: 'answer:123:456' }), - ]); - expect(page.goto).toHaveBeenCalledWith('https://www.zhihu.com/question/123/answer/456'); - expect(page.evaluate).toHaveBeenCalledTimes(1); - expect(page.evaluate.mock.calls[0][0]).toContain('targetQuestionId'); - expect(page.evaluate.mock.calls[0][0]).toContain('"123"'); - expect(page.evaluate.mock.calls[0][0]).toContain('"456"'); - expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-answerid')"); - expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-zop-question-answer')"); + const rows = await cmd.func(page, { target: 'answer:1:2', execute: true }); + expect(rows).toEqual([expect.objectContaining({ outcome: 'applied' })]); }); - it('rejects answer targets when the answer-level like control is not unique', async () => { + it('throws on API error', async () => { const cmd = getRegistry().get('zhihu/like'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_answer_like' }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'rate limited' }), }; - await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({ - code: 'ACTION_NOT_AVAILABLE', - }); + await expect(cmd.func(page, { target: 'answer:1:2', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); }); - it('maps missing answer blocks to TARGET_NOT_FOUND', async () => { + it('does not treat success=false API responses as a successful like', async () => { const cmd = getRegistry().get('zhihu/like'); const page = { goto: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue({ state: 'wrong_answer' }), + wait: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'Zhihu like API reported success=false' }), }; - await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({ - code: 'TARGET_NOT_FOUND', - }); - expect(page.evaluate.mock.calls[0][0]).toContain("if (!block) return { state: 'wrong_answer' }"); + await expect(cmd.func(page, { target: 'answer:1:2', execute: true })) + .rejects.toMatchObject({ code: 'COMMAND_EXEC' }); + expect(page.evaluate.mock.calls[0][0]).toContain('data.success === false'); }); }); diff --git a/clis/zhihu/search.js b/clis/zhihu/search.js index 16bfd04e2..5908644e3 100644 --- a/clis/zhihu/search.js +++ b/clis/zhihu/search.js @@ -15,12 +15,13 @@ cli({ const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(//g, '').replace(/<\\/em>/g, '').trim(); const keyword = \${{ args.query | json }}; const limit = \${{ args.limit }}; - const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, { + var fetchLimit = Math.max(limit * 3, 30); + const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + fetchLimit, { credentials: 'include' }); const d = await res.json(); return (d?.data || []) - .filter(item => item.type === 'search_result') + .filter(item => item.object && (item.object.type === 'answer' || item.object.type === 'article' || item.object.type === 'question')) .map(item => { const obj = item.object || {}; const q = obj.question || {}; diff --git a/clis/zhihu/write-shared.js b/clis/zhihu/write-shared.js index 9b453bf5f..6dbfba83c 100644 --- a/clis/zhihu/write-shared.js +++ b/clis/zhihu/write-shared.js @@ -194,7 +194,14 @@ function buildResolveCurrentUserIdentityJs() { const navScopes = Array.from(document.querySelectorAll(navScopeSelector)); const slug = findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([document], false); - return slug ? { slug } : null; + if (slug) return { slug }; + + var avatarImgs = document.querySelectorAll('header img[alt*="\\u4e3b\\u9875"]'); + for (var ai = 0; ai < avatarImgs.length; ai++) { + var altMatch = (avatarImgs[ai].alt || '').match(/\\u70b9\\u51fb\\u6253\\u5f00(.+?)\\u7684\\u4e3b\\u9875/); + if (altMatch) return { slug: altMatch[1] }; + } + return null; })()`; } export async function resolveCurrentUserIdentity(page) { diff --git a/clis/zhihu/write-shared.test.js b/clis/zhihu/write-shared.test.js index f03b18676..c27c540ba 100644 --- a/clis/zhihu/write-shared.test.js +++ b/clis/zhihu/write-shared.test.js @@ -163,6 +163,7 @@ describe('zhihu write shared helpers', () => { const documentRoot = new FakeRoot({ 'header, nav, [role="banner"], [role="navigation"]': [], 'a[href^="/people/"]': [], + 'header img[alt*="\u4e3b\u9875"]': [], }); const page = createPageForDom(documentRoot); await expect(__test__.resolveCurrentUserIdentity(page)).rejects.toMatchObject({