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 });