comment.js
1 import { CliError, CommandExecutionError } from '@jackwener/opencli/errors'; 2 import { cli, Strategy } from '@jackwener/opencli/registry'; 3 import { assertAllowedKinds, parseTarget } from './target.js'; 4 import { buildResultRow, requireExecute, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js'; 5 const COMMENT_AUTHOR_SCOPE_SELECTOR = '.CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop="author"]'; 6 cli({ 7 site: 'zhihu', 8 name: 'comment', 9 description: 'Create a top-level comment on a Zhihu answer or article', 10 domain: 'zhihu.com', 11 strategy: Strategy.UI, 12 browser: true, 13 args: [ 14 { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' }, 15 { name: 'text', positional: true, help: 'Comment text' }, 16 { name: 'file', help: 'Comment text file path' }, 17 { name: 'execute', type: 'boolean', help: 'Actually perform the write action' }, 18 ], 19 columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url', 'created_proof'], 20 func: async (page, kwargs) => { 21 if (!page) 22 throw new CommandExecutionError('Browser session required for zhihu comment'); 23 requireExecute(kwargs); 24 const rawTarget = String(kwargs.target); 25 const target = assertAllowedKinds('comment', parseTarget(rawTarget)); 26 const payload = await resolvePayload(kwargs); 27 await page.goto(target.url); 28 const authorIdentity = await resolveCurrentUserIdentity(page); 29 const entryPath = await page.evaluate(`(() => { 30 const targetKind = ${JSON.stringify(target.kind)}; 31 const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; 32 const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; 33 const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]'); 34 let scope = document; 35 if (targetKind === 'answer') { 36 const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { 37 const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; 38 if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; 39 return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { 40 const href = link.getAttribute('href') || ''; 41 return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); 42 }); 43 }); 44 if (!block) return { entryPathSafe: false, wrongAnswer: true }; 45 scope = block; 46 } else { 47 scope = 48 document.querySelector('article') 49 || document.querySelector('.Post-Main') 50 || document.querySelector('[itemprop="articleBody"]') 51 || document; 52 } 53 const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { 54 const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; 55 const replyHint = editor.getAttribute('data-reply-to') || ''; 56 const text = 'value' in editor ? editor.value || '' : (editor.textContent || ''); 57 const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); 58 return { editor, container, replyHint, text, nestedReply }; 59 }).filter((candidate) => candidate.container && !candidate.nestedReply); 60 return { 61 entryPathSafe: topLevelCandidates.length === 1 62 && !restoredDraft 63 && !topLevelCandidates[0].replyHint 64 && !String(topLevelCandidates[0].text || '').trim(), 65 wrongAnswer: false, 66 }; 67 })()`); 68 if (entryPath.wrongAnswer) { 69 throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>'); 70 } 71 if (!entryPath.entryPathSafe) { 72 throw new CliError('ACTION_NOT_AVAILABLE', 'Comment entry path was not proven side-effect free'); 73 } 74 const beforeSubmitSnapshot = await page.evaluate(`(() => { 75 const normalize = (value) => value.replace(/\\s+/g, ' ').trim(); 76 const targetKind = ${JSON.stringify(target.kind)}; 77 const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; 78 const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; 79 let scope = document; 80 if (targetKind === 'answer') { 81 const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { 82 const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; 83 if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; 84 return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { 85 const href = link.getAttribute('href') || ''; 86 return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); 87 }); 88 }); 89 if (!block) return { wrongAnswer: true, rows: [], commentLinks: [] }; 90 scope = block; 91 } else { 92 scope = 93 document.querySelector('article') 94 || document.querySelector('.Post-Main') 95 || document.querySelector('[itemprop="articleBody"]') 96 || document; 97 } 98 return { 99 wrongAnswer: false, 100 rows: Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => ({ 101 id: node.getAttribute('data-comment-id') || '', 102 text: normalize(node.textContent || ''), 103 })), 104 commentLinks: Array.from(scope.querySelectorAll('a[href*="/comment/"]')) 105 .map((node) => node.getAttribute('href') || '') 106 .filter(Boolean), 107 }; 108 })()`); 109 if (beforeSubmitSnapshot.wrongAnswer) { 110 throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>'); 111 } 112 const composer = await page.evaluate(`(async () => { 113 const targetKind = ${JSON.stringify(target.kind)}; 114 const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; 115 const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; 116 let scope = document; 117 if (targetKind === 'answer') { 118 const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { 119 const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; 120 if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; 121 return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { 122 const href = link.getAttribute('href') || ''; 123 return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); 124 }); 125 }); 126 if (!block) return { composerState: 'wrong_answer' }; 127 scope = block; 128 } else { 129 scope = 130 document.querySelector('article') 131 || document.querySelector('.Post-Main') 132 || document.querySelector('[itemprop="articleBody"]') 133 || document; 134 } 135 const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { 136 const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; 137 const replyHint = editor.getAttribute('data-reply-to') || ''; 138 const text = 'value' in editor ? editor.value || '' : (editor.textContent || ''); 139 const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); 140 return { editor, container, replyHint, text, nestedReply }; 141 }).filter((candidate) => candidate.container && !candidate.nestedReply); 142 if (topLevelCandidates.length !== 1) return { composerState: 'unsafe' }; 143 return { 144 composerState: !topLevelCandidates[0].replyHint && !topLevelCandidates[0].text.trim() ? 'fresh_top_level' : 'unsafe', 145 }; 146 })()`); 147 if (composer.composerState === 'wrong_answer') { 148 throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>'); 149 } 150 if (composer.composerState !== 'fresh_top_level') { 151 throw new CliError('ACTION_NOT_AVAILABLE', 'Comment composer was not a fresh top-level composer'); 152 } 153 const editorCheck = await page.evaluate(`(async () => { 154 const textToInsert = ${JSON.stringify(payload)}; 155 const targetKind = ${JSON.stringify(target.kind)}; 156 const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; 157 const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; 158 let scope = document; 159 if (targetKind === 'answer') { 160 const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { 161 const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; 162 if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; 163 return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { 164 const href = link.getAttribute('href') || ''; 165 return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); 166 }); 167 }); 168 if (!block) return { editorContent: '', mode: 'wrong_answer' }; 169 scope = block; 170 } else { 171 scope = 172 document.querySelector('article') 173 || document.querySelector('.Post-Main') 174 || document.querySelector('[itemprop="articleBody"]') 175 || document; 176 } 177 const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { 178 const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; 179 const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); 180 return { editor, container, nestedReply }; 181 }).filter((candidate) => candidate.container && !candidate.nestedReply); 182 if (topLevelCandidates.length !== 1) return { editorContent: '', mode: 'missing' }; 183 const { editor } = topLevelCandidates[0]; 184 editor.focus(); 185 if ('value' in editor) { 186 editor.value = ''; 187 editor.dispatchEvent(new Event('input', { bubbles: true })); 188 editor.value = textToInsert; 189 editor.dispatchEvent(new Event('input', { bubbles: true })); 190 } else { 191 editor.textContent = ''; 192 document.execCommand('insertText', false, textToInsert); 193 editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' })); 194 } 195 await new Promise((resolve) => setTimeout(resolve, 200)); 196 const content = 'value' in editor ? editor.value : (editor.textContent || ''); 197 const replyHint = editor.getAttribute('data-reply-to') || ''; 198 return { editorContent: content, mode: replyHint ? 'reply' : 'top_level' }; 199 })()`); 200 if (editorCheck.mode === 'wrong_answer') { 201 throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>'); 202 } 203 if (editorCheck.mode !== 'top_level' || editorCheck.editorContent !== payload) { 204 throw new CliError('OUTCOME_UNKNOWN', 'Comment editor content did not exactly match the requested payload before submit'); 205 } 206 const proof = await page.evaluate(`(async () => { 207 const normalize = (value) => value.replace(/\\s+/g, ' ').trim(); 208 const commentAuthorScopeSelector = ${JSON.stringify(COMMENT_AUTHOR_SCOPE_SELECTOR)}; 209 const readCommentAuthorSlug = (node) => { 210 const authorScopes = Array.from(node.querySelectorAll(commentAuthorScopeSelector)); 211 const slugs = Array.from(new Set(authorScopes 212 .flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]'))) 213 .map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null) 214 .filter(Boolean))); 215 return slugs.length === 1 ? slugs[0] : null; 216 }; 217 const targetKind = ${JSON.stringify(target.kind)}; 218 const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)}; 219 const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)}; 220 let scope = document; 221 if (targetKind === 'answer') { 222 const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => { 223 const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || ''; 224 if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true; 225 return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => { 226 const href = link.getAttribute('href') || ''; 227 return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId); 228 }); 229 }); 230 if (!block) return { proofType: 'wrong_answer' }; 231 scope = block; 232 } else { 233 scope = 234 document.querySelector('article') 235 || document.querySelector('.Post-Main') 236 || document.querySelector('[itemprop="articleBody"]') 237 || document; 238 } 239 240 const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => { 241 const container = editor.closest('form, [role="dialog"], .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement; 242 const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem')); 243 return { editor, container, nestedReply }; 244 }).filter((candidate) => candidate.container && !candidate.nestedReply); 245 if (topLevelCandidates.length !== 1) return { proofType: 'unknown' }; 246 const submitScope = topLevelCandidates[0].container || scope; 247 const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|评论|发送/.test(node.textContent || '')); 248 submit && submit.click(); 249 await new Promise((resolve) => setTimeout(resolve, 1200)); 250 const createdLink = Array.from(scope.querySelectorAll('a[href*="/comment/"]')).find((node) => { 251 const href = node.getAttribute('href') || ''; 252 return href.includes('/comment/') && !${JSON.stringify(beforeSubmitSnapshot.commentLinks ?? [])}.includes(href); 253 }); 254 255 if (createdLink) { 256 const card = createdLink.closest('[data-comment-id], .CommentItem, li'); 257 const authorSlug = card ? readCommentAuthorSlug(card) : null; 258 const contentNode = 259 card?.querySelector('[data-comment-content], .RichContent-inner, .CommentItemV2-content, .CommentContent') 260 || card; 261 const text = normalize(contentNode?.textContent || ''); 262 const nestedReply = Boolean(card?.closest('ul ul, ol ol, li li') || card?.parentElement?.closest('[data-comment-id], .CommentItem')); 263 return { 264 proofType: 'stable_url', 265 createdUrl: new URL(createdLink.getAttribute('href') || '', location.origin).href, 266 commentScope: nestedReply ? 'nested_reply' : 'top_level_only', 267 authorIdentity: authorSlug, 268 targetMatches: text === normalize(${JSON.stringify(payload)}), 269 }; 270 } 271 272 const currentUserSlug = ${JSON.stringify(authorIdentity)}; 273 const beforeIds = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.id).filter(Boolean))}); 274 const beforeTexts = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.text).filter(Boolean))}); 275 const normalizedPayload = normalize(${JSON.stringify(payload)}); 276 const after = Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => { 277 return { 278 id: node.getAttribute('data-comment-id') || '', 279 text: normalize(node.textContent || ''), 280 authorSlug: readCommentAuthorSlug(node), 281 topLevel: !node.closest('ul ul, ol ol, li li') && !node.parentElement?.closest('[data-comment-id], .CommentItem'), 282 }; 283 }); 284 285 const matching = after.filter((row) => 286 !beforeIds.has(row.id) 287 && row.authorSlug === currentUserSlug 288 && row.topLevel 289 && row.text === normalizedPayload 290 && !beforeTexts.has(row.text) 291 ); 292 293 return matching.length === 1 294 ? { 295 proofType: 'fallback', 296 createdProof: { 297 proof_type: 'comment_fallback', 298 author_scope: 'current_user', 299 target_scope: 'requested_target', 300 comment_scope: 'top_level_only', 301 content_match: 'exact_normalized', 302 observed_after_submit: true, 303 present_in_pre_submit_snapshot: false, 304 new_matching_entries: 1, 305 post_submit_matching_entries: after.filter((row) => 306 row.authorSlug === currentUserSlug && row.topLevel && row.text === normalizedPayload 307 ).length, 308 snapshot_scope: ${JSON.stringify(target.kind === 'answer' 309 ? 'stabilized_expanded_target_answer_comment_list' 310 : 'stabilized_expanded_target_article_comment_list')}, 311 }, 312 } 313 : { proofType: 'unknown' }; 314 })()`); 315 if (proof.proofType === 'wrong_answer') { 316 throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>'); 317 } 318 if (proof.proofType === 'fallback') { 319 return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', { 320 author_identity: authorIdentity, 321 created_proof: proof.createdProof, 322 }); 323 } 324 if (proof.proofType !== 'stable_url') { 325 throw new CliError('OUTCOME_UNKNOWN', 'Comment submit was dispatched, but the created object could not be proven safely'); 326 } 327 if (proof.commentScope !== 'top_level_only' || proof.authorIdentity !== authorIdentity || !proof.targetMatches) { 328 throw new CliError('OUTCOME_UNKNOWN', 'Stable comment URL was found, but authorship or top-level scope could not be proven safely'); 329 } 330 return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', { 331 author_identity: authorIdentity, 332 created_url: proof.createdUrl, 333 }); 334 }, 335 });