/ clis / zhihu / favorite.js
favorite.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  function rowKey(row) {
  6      return row.id || `name:${normalizeCollectionName(row.name)}`;
  7  }
  8  function normalizeCollectionName(value) {
  9      return value
 10          .replace(/\s+/g, ' ')
 11          .replace(/\s+\d+\s*(条内容|个内容|items?)$/i, '')
 12          .replace(/\s+(公开|私密|默认)$/i, '')
 13          .trim();
 14  }
 15  cli({
 16      site: 'zhihu',
 17      name: 'favorite',
 18      description: 'Favorite a Zhihu answer or article into a specific collection',
 19      domain: 'zhihu.com',
 20      strategy: Strategy.UI,
 21      browser: true,
 22      args: [
 23          { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
 24          { name: 'collection', help: 'Collection name' },
 25          { name: 'collection-id', help: 'Stable collection id' },
 26          { name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
 27      ],
 28      columns: ['status', 'outcome', 'message', 'target_type', 'target', 'collection_name', 'collection_id'],
 29      func: async (page, kwargs) => {
 30          if (!page)
 31              throw new CommandExecutionError('Browser session required for zhihu favorite');
 32          requireExecute(kwargs);
 33          const rawTarget = String(kwargs.target);
 34          const target = assertAllowedKinds('favorite', parseTarget(rawTarget));
 35          const collectionName = typeof kwargs.collection === 'string' ? kwargs.collection : undefined;
 36          const collectionId = typeof kwargs['collection-id'] === 'string' ? kwargs['collection-id'] : undefined;
 37          if ((collectionName ? 1 : 0) + (collectionId ? 1 : 0) !== 1) {
 38              throw new CliError('INVALID_INPUT', 'Use exactly one of --collection or --collection-id');
 39          }
 40          await page.goto(target.url);
 41          const preflight = await page.evaluate(`(async () => {
 42        const targetKind = ${JSON.stringify(target.kind)};
 43        const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
 44        const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
 45        const wantedName = ${JSON.stringify(collectionName ?? null)};
 46        const wantedId = ${JSON.stringify(collectionId ?? null)};
 47  
 48        let scope = document;
 49        if (targetKind === 'answer') {
 50          const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
 51            const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
 52            if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
 53            return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
 54              const href = link.getAttribute('href') || '';
 55              return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
 56            });
 57          });
 58          if (!block) return { wrongAnswer: true, chooserRows: [] };
 59          scope = block;
 60        } else {
 61          scope =
 62            document.querySelector('article')
 63            || document.querySelector('.Post-Main')
 64            || document.querySelector('[itemprop="articleBody"]')
 65            || document;
 66        }
 67  
 68        const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
 69        if (!favoriteButton) return { wrongAnswer: false, missingChooser: true, chooserRows: [] };
 70        favoriteButton.click();
 71        await new Promise((resolve) => setTimeout(resolve, 600));
 72  
 73        const chooserRows = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button'))
 74          .map((node) => {
 75            const text = (node.textContent || '').trim();
 76            const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
 77            const selected = node.getAttribute('aria-checked') === 'true'
 78              || node.getAttribute('aria-pressed') === 'true'
 79              || /已选|已收藏/.test(text);
 80            return text ? { id, name: text, selected } : null;
 81          })
 82          .filter(Boolean);
 83  
 84        return {
 85          wrongAnswer: false,
 86          missingChooser: chooserRows.length === 0,
 87          chooserRows,
 88          targetRowId: wantedId,
 89          targetRowName: wantedName,
 90        };
 91      })()`);
 92          if (preflight.wrongAnswer) {
 93              throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
 94          }
 95          if (preflight.missingChooser) {
 96              throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser did not open on the requested target');
 97          }
 98          const matchingRows = preflight.chooserRows.filter((row) => (collectionId
 99              ? row.id === collectionId
100              : normalizeCollectionName(row.name) === normalizeCollectionName(collectionName || '')));
101          if (collectionId && !matchingRows.some((row) => row.id === collectionId)) {
102              throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not confirm the requested stable collection id');
103          }
104          if (!collectionId && matchingRows.length !== 1) {
105              throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not prove that the requested collection name is globally unique');
106          }
107          const targetRow = matchingRows[0];
108          const targetRowKey = rowKey(targetRow);
109          const selectedBefore = preflight.chooserRows.filter((row) => row.selected).map(rowKey);
110          const verify = await page.evaluate(`(async () => {
111        const targetKind = ${JSON.stringify(target.kind)};
112        const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
113        const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
114        const targetWasSelected = ${JSON.stringify(targetRow.selected)};
115        const wantedName = ${JSON.stringify(collectionName ?? null)};
116        const wantedId = ${JSON.stringify(collectionId ?? null)};
117        const normalizeCollectionName = (value) => String(value || '')
118          .replace(/\\s+/g, ' ')
119          .replace(/\\s+\\d+\\s*(条内容|个内容|items?)$/i, '')
120          .replace(/\\s+(公开|私密|默认)$/i, '')
121          .trim();
122        const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);
123  
124        const chooserSelector = '[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button';
125        const readChooserRows = () => Array.from(document.querySelectorAll(chooserSelector))
126          .map((node) => {
127            const text = (node.textContent || '').trim();
128            const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
129            const selected = node.getAttribute('aria-checked') === 'true'
130              || node.getAttribute('aria-pressed') === 'true'
131              || /已选|已收藏/.test(text);
132            return text ? { id, name: text, selected } : null;
133          })
134          .filter(Boolean);
135        const waitForChooserRows = async (expectedPresent) => {
136          for (let attempt = 0; attempt < 10; attempt += 1) {
137            const rows = readChooserRows();
138            if (expectedPresent ? rows.length > 0 : rows.length === 0) return rows;
139            await new Promise((resolve) => setTimeout(resolve, 150));
140          }
141          return readChooserRows();
142        };
143        const closeChooser = async () => {
144          const closeButton = Array.from(document.querySelectorAll('[role="dialog"] button, [role="dialog"] [role="button"]')).find((node) => {
145            const text = (node.textContent || '').trim();
146            const aria = node.getAttribute('aria-label') || '';
147            return /关闭|取消|收起/.test(text) || /关闭|cancel|close/i.test(aria);
148          });
149          closeButton && closeButton.click();
150          return waitForChooserRows(false);
151        };
152        const reopenChooser = async () => {
153          let scope = document;
154          if (targetKind === 'answer') {
155            const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
156              const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
157              if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
158              return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
159                const href = link.getAttribute('href') || '';
160                return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
161              });
162            });
163            if (!block) return [];
164            scope = block;
165          } else {
166            scope =
167              document.querySelector('article')
168              || document.querySelector('.Post-Main')
169              || document.querySelector('[itemprop="articleBody"]')
170              || document;
171          }
172          const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
173          favoriteButton && favoriteButton.click();
174          return waitForChooserRows(true);
175        };
176  
177        let chooserRows = readChooserRows();
178        let sawChooserClose = false;
179        if (!targetWasSelected) {
180          const row = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button')).find((node) => {
181            const text = (node.textContent || '').trim();
182            const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
183            return wantedId ? id === wantedId : normalizeCollectionName(text) === normalizeCollectionName(wantedName);
184          });
185          row && row.click();
186          await new Promise((resolve) => setTimeout(resolve, 300));
187          const submit = Array.from(document.querySelectorAll('[role="dialog"] button')).find((node) => /完成|确定|保存/.test(node.textContent || ''));
188          submit && submit.click();
189          chooserRows = await waitForChooserRows(false);
190          sawChooserClose = chooserRows.length === 0;
191        } else {
192          chooserRows = await closeChooser();
193          sawChooserClose = chooserRows.length === 0;
194        }
195        if (sawChooserClose) {
196          chooserRows = await reopenChooser();
197        }
198  
199        return {
200          persisted: sawChooserClose && chooserRows.length > 0,
201          readbackSource: sawChooserClose && chooserRows.length > 0 ? 'reopened_chooser' : (chooserRows.length > 0 ? 'same_modal' : 'missing'),
202          selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey),
203          targetSelected: chooserRows.some((row) => rowKey(row) === ${JSON.stringify(targetRowKey)} && row.selected),
204        };
205      })()`);
206          if (!verify.persisted) {
207              throw new CliError('OUTCOME_UNKNOWN', 'Favorite action may have been applied, but persisted read-back was unavailable');
208          }
209          if (verify.readbackSource !== 'reopened_chooser') {
210              throw new CliError('OUTCOME_UNKNOWN', 'Favorite state was not re-read from a reopened chooser after submit');
211          }
212          if (!verify.targetSelected) {
213              throw new CliError('OUTCOME_UNKNOWN', 'Favorite chooser remained readable, but the requested collection was not confirmed as selected');
214          }
215          if (!selectedBefore.every((row) => verify.selectedAfter.includes(row))) {
216              throw new CliError('OUTCOME_UNKNOWN', `Favorite action changed unrelated collection membership: before=${JSON.stringify(selectedBefore)} after=${JSON.stringify(verify.selectedAfter)}`);
217          }
218          const outcome = targetRow.selected ? 'already_applied' : 'applied';
219          return buildResultRow(targetRow.selected ? `Already favorited ${target.kind}` : `Favorited ${target.kind}`, target.kind, rawTarget, outcome, {
220              collection_name: collectionName ?? targetRow.name,
221              ...(targetRow.id ? { collection_id: targetRow.id } : {}),
222          });
223      },
224  });