@@ -2,9 +2,6 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
22import { cli , Strategy } from '@jackwener/opencli/registry' ;
33import { assertAllowedKinds , parseTarget } from './target.js' ;
44import { buildResultRow , requireExecute } from './write-shared.js' ;
5- function rowKey ( row ) {
6- return row . id || `name:${ normalizeCollectionName ( row . name ) } ` ;
7- }
85function normalizeCollectionName ( value ) {
96 return value
107 . replace ( / \s + / g, ' ' )
1714 name : 'favorite' ,
1815 description : 'Favorite a Zhihu answer or article into a specific collection' ,
1916 domain : 'zhihu.com' ,
20- strategy : Strategy . UI ,
17+ strategy : Strategy . COOKIE ,
2118 browser : true ,
2219 args : [
2320 { name : 'target' , positional : true , required : true , help : 'Zhihu target URL or typed target' } ,
@@ -37,188 +34,40 @@ cli({
3734 if ( ( collectionName ? 1 : 0 ) + ( collectionId ? 1 : 0 ) !== 1 ) {
3835 throw new CliError ( 'INVALID_INPUT' , 'Use exactly one of --collection or --collection-id' ) ;
3936 }
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);
37+ await page . goto ( 'https://www.zhihu.com' ) ;
38+ await page . wait ( 2 ) ;
39+ const apiResult = await page . evaluate ( `(async () => {
40+ var collectionId = ${ JSON . stringify ( collectionId || null ) } ;
41+ var collectionName = ${ JSON . stringify ( collectionName || null ) } ;
42+ var targetKind = ${ JSON . stringify ( target . kind ) } ;
43+ var targetId = ${ JSON . stringify ( target . id ) } ;
8344
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);
45+ if (!collectionId && collectionName) {
46+ var listResp = await fetch('https://www.zhihu.com/api/v4/people/self/collections?limit=50', { credentials: 'include' });
47+ var listData = await listResp.json();
48+ var needle = collectionName.replace(/\\s+/g, ' ').trim().toLowerCase();
49+ var match = (listData.data || []).find(function(c) { return c.title.toLowerCase().includes(needle); });
50+ if (!match) return { ok: false, message: 'Collection not found: ' + collectionName };
51+ collectionId = String(match.id);
52+ }
12353
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);
54+ var resp = await fetch('https://www.zhihu.com/api/v4/favlists/' + collectionId + '/items', {
55+ method: 'POST',
56+ credentials: 'include',
57+ headers: { 'Content-Type': 'application/json' },
58+ body: JSON.stringify({ item_id: targetId, item_type: targetKind }),
16159 });
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 ) } ` ) ;
60+ if (resp.ok || resp.status === 204) return { ok: true, collectionId: collectionId };
61+ var data = {};
62+ try { data = await resp.json(); } catch(e) {}
63+ return { ok: false, message: data.error ? data.error.message : 'HTTP ' + resp.status };
64+ })()` ) ;
65+ if ( ! apiResult ?. ok ) {
66+ throw new CliError ( 'COMMAND_EXEC' , apiResult ?. message || 'Failed to favorite' ) ;
21767 }
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 } : { } ) ,
68+ return buildResultRow ( `Favorited ${ target . kind } ${ target . id } ` , target . kind , rawTarget , 'applied' , {
69+ collection_name : collectionName || '' ,
70+ collection_id : apiResult . collectionId || collectionId || '' ,
22271 } ) ;
22372 } ,
22473} ) ;
0 commit comments