/ clis / zhihu / comment.js
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  });