Skip to content

Commit c941ff2

Browse files
committed
fix(zhihu): rewrite like, follow, favorite to use API
Same DOM breakage as comment/answer. Replaced UI-based click flows with direct Zhihu API calls for all write commands.
1 parent 3139c3b commit c941ff2

8 files changed

Lines changed: 133 additions & 571 deletions

File tree

cli-manifest.json

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20319,7 +20319,7 @@
2031920319
"name": "answer",
2032020320
"description": "Answer a Zhihu question",
2032120321
"domain": "www.zhihu.com",
20322-
"strategy": "ui",
20322+
"strategy": "cookie",
2032320323
"browser": true,
2032420324
"args": [
2032520325
{
@@ -20362,14 +20362,14 @@
2036220362
"type": "js",
2036320363
"modulePath": "zhihu/answer.js",
2036420364
"sourceFile": "zhihu/answer.js",
20365-
"navigateBefore": true
20365+
"navigateBefore": "https://www.zhihu.com"
2036620366
},
2036720367
{
2036820368
"site": "zhihu",
2036920369
"name": "comment",
2037020370
"description": "Create a top-level comment on a Zhihu answer or article",
2037120371
"domain": "zhihu.com",
20372-
"strategy": "ui",
20372+
"strategy": "cookie",
2037320373
"browser": true,
2037420374
"args": [
2037520375
{
@@ -20406,13 +20406,12 @@
2040620406
"target_type",
2040720407
"target",
2040820408
"author_identity",
20409-
"created_url",
20410-
"created_proof"
20409+
"created_url"
2041120410
],
2041220411
"type": "js",
2041320412
"modulePath": "zhihu/comment.js",
2041420413
"sourceFile": "zhihu/comment.js",
20415-
"navigateBefore": true
20414+
"navigateBefore": "https://zhihu.com"
2041620415
},
2041720416
{
2041820417
"site": "zhihu",
@@ -20460,7 +20459,7 @@
2046020459
"name": "favorite",
2046120460
"description": "Favorite a Zhihu answer or article into a specific collection",
2046220461
"domain": "zhihu.com",
20463-
"strategy": "ui",
20462+
"strategy": "cookie",
2046420463
"browser": true,
2046520464
"args": [
2046620465
{
@@ -20501,14 +20500,14 @@
2050120500
"type": "js",
2050220501
"modulePath": "zhihu/favorite.js",
2050320502
"sourceFile": "zhihu/favorite.js",
20504-
"navigateBefore": true
20503+
"navigateBefore": "https://zhihu.com"
2050520504
},
2050620505
{
2050720506
"site": "zhihu",
2050820507
"name": "follow",
2050920508
"description": "Follow a Zhihu user or question",
2051020509
"domain": "www.zhihu.com",
20511-
"strategy": "ui",
20510+
"strategy": "cookie",
2051220511
"browser": true,
2051320512
"args": [
2051420513
{
@@ -20535,7 +20534,7 @@
2053520534
"type": "js",
2053620535
"modulePath": "zhihu/follow.js",
2053720536
"sourceFile": "zhihu/follow.js",
20538-
"navigateBefore": true
20537+
"navigateBefore": "https://www.zhihu.com"
2053920538
},
2054020539
{
2054120540
"site": "zhihu",
@@ -20569,7 +20568,7 @@
2056920568
"name": "like",
2057020569
"description": "Like a Zhihu answer or article",
2057120570
"domain": "zhihu.com",
20572-
"strategy": "ui",
20571+
"strategy": "cookie",
2057320572
"browser": true,
2057420573
"args": [
2057520574
{
@@ -20596,7 +20595,7 @@
2059620595
"type": "js",
2059720596
"modulePath": "zhihu/like.js",
2059820597
"sourceFile": "zhihu/like.js",
20599-
"navigateBefore": true
20598+
"navigateBefore": "https://zhihu.com"
2060020599
},
2060120600
{
2060220601
"site": "zhihu",

clis/zhihu/comment.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ describe('zhihu comment', () => {
1414
goto: vi.fn().mockResolvedValue(undefined),
1515
wait: vi.fn().mockResolvedValue(undefined),
1616
evaluate: vi.fn()
17-
.mockResolvedValueOnce({ slug: 'alice' }) // resolveCurrentUserIdentity
18-
.mockResolvedValueOnce({ ok: true, id: 99, url: 'https://www.zhihu.com/api/v4/comments/99' }), // API call
17+
.mockResolvedValueOnce({ slug: 'alice' })
18+
.mockResolvedValueOnce({ ok: true, id: 99, url: 'https://www.zhihu.com/api/v4/comments/99' }),
1919
};
2020
const rows = await cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true });
2121
expect(rows).toEqual([

clis/zhihu/favorite.js

Lines changed: 31 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
22
import { cli, Strategy } from '@jackwener/opencli/registry';
33
import { assertAllowedKinds, parseTarget } from './target.js';
44
import { buildResultRow, requireExecute } from './write-shared.js';
5-
function rowKey(row) {
6-
return row.id || `name:${normalizeCollectionName(row.name)}`;
7-
}
85
function normalizeCollectionName(value) {
96
return value
107
.replace(/\s+/g, ' ')
@@ -17,7 +14,7 @@ cli({
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

Comments
 (0)