/ clis / zhihu / like.js
like.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 } from './write-shared.js';
 5  cli({
 6      site: 'zhihu',
 7      name: 'like',
 8      description: 'Like a Zhihu answer or article',
 9      domain: 'zhihu.com',
10      strategy: Strategy.UI,
11      browser: true,
12      args: [
13          { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
14          { name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
15      ],
16      columns: ['status', 'outcome', 'message', 'target_type', 'target'],
17      func: async (page, kwargs) => {
18          if (!page)
19              throw new CommandExecutionError('Browser session required for zhihu like');
20          requireExecute(kwargs);
21          const rawTarget = String(kwargs.target);
22          const target = assertAllowedKinds('like', parseTarget(rawTarget));
23          await page.goto(target.url);
24          const result = await page.evaluate(`(async () => {
25        const targetKind = ${JSON.stringify(target.kind)};
26        const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
27        const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
28  
29        let btn = null;
30        if (targetKind === 'answer') {
31          const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
32            const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
33            if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
34            return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
35              const href = link.getAttribute('href') || '';
36              return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
37            });
38          });
39          if (!block) return { state: 'wrong_answer' };
40          const candidates = Array.from(block?.querySelectorAll('button') || []).filter((node) => {
41            const text = (node.textContent || '').trim();
42            const inCommentItem = Boolean(node.closest('[data-comment-id], .CommentItem'));
43            return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed') && !inCommentItem;
44          });
45          if (candidates.length !== 1) return { state: 'ambiguous_answer_like' };
46          btn = candidates[0];
47        } else {
48          const articleRoot =
49            document.querySelector('article')
50            || document.querySelector('.Post-Main')
51            || document.querySelector('[itemprop="articleBody"]')
52            || document;
53          const candidates = Array.from(articleRoot.querySelectorAll('button')).filter((node) => {
54            const text = (node.textContent || '').trim();
55            return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed');
56          });
57          if (candidates.length !== 1) return { state: 'ambiguous_article_like' };
58          btn = candidates[0];
59        }
60  
61        if (!btn) return { state: 'missing' };
62        if (btn.getAttribute('aria-pressed') === 'true') return { state: 'already_liked' };
63  
64        btn.click();
65        await new Promise((resolve) => setTimeout(resolve, 1200));
66  
67        return btn.getAttribute('aria-pressed') === 'true'
68          ? { state: 'liked' }
69          : { state: 'unknown' };
70      })()`);
71          if (result?.state === 'wrong_answer') {
72              throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
73          }
74          if (result?.state === 'already_liked') {
75              return buildResultRow(`Already liked ${target.kind}`, target.kind, rawTarget, 'already_applied');
76          }
77          if (result?.state === 'ambiguous_answer_like') {
78              throw new CliError('ACTION_NOT_AVAILABLE', 'Answer like control was not uniquely anchored on the requested answer');
79          }
80          if (result?.state === 'ambiguous_article_like') {
81              throw new CliError('ACTION_NOT_AVAILABLE', 'Article like control was not uniquely anchored on the requested target');
82          }
83          if (result?.state === 'missing') {
84              throw new CliError('ACTION_FAILED', 'Zhihu like control was missing before any write was dispatched');
85          }
86          if (result?.state !== 'liked') {
87              throw new CliError('OUTCOME_UNKNOWN', 'Zhihu like click was dispatched, but the final state could not be verified safely');
88          }
89          return buildResultRow(`Liked ${target.kind}`, target.kind, rawTarget, 'applied');
90      },
91  });