9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
114 lines
5.2 KiB
JavaScript
114 lines
5.2 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { AuthRequiredError, CliError } from '@jackwener/opencli/errors';
|
|
import { stripHtml } from './text.js';
|
|
|
|
function answerIdFromUrl(url) {
|
|
if (typeof url !== 'string') return '';
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.hostname !== 'www.zhihu.com' && parsed.hostname !== 'zhihu.com') return '';
|
|
return parsed.pathname.match(/^\/question\/\d+\/answer\/(\d+)\/?$/)?.[1]
|
|
|| parsed.pathname.match(/^\/api\/v4\/answers\/(\d+)\/?$/)?.[1]
|
|
|| parsed.pathname.match(/^\/answer\/(\d+)\/?$/)?.[1]
|
|
|| '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function answerId(item) {
|
|
const fromUrl = answerIdFromUrl(item.url);
|
|
if (fromUrl) return fromUrl;
|
|
if (typeof item.id === 'string' && /^\d+$/.test(item.id)) return item.id;
|
|
if (typeof item.id === 'number' && Number.isSafeInteger(item.id) && item.id > 0) return String(item.id);
|
|
return '';
|
|
}
|
|
|
|
function answerDedupeKey(item) {
|
|
const id = answerId(item);
|
|
if (id) return `id:${id}`;
|
|
return `fallback:${item.author?.name || 'anonymous'}:${item.content || ''}`;
|
|
}
|
|
|
|
const MAX_LIMIT = 1000;
|
|
|
|
cli({
|
|
site: 'zhihu',
|
|
name: 'question',
|
|
access: 'read',
|
|
description: '知乎问题详情和回答',
|
|
domain: 'www.zhihu.com',
|
|
strategy: Strategy.COOKIE,
|
|
args: [
|
|
{ name: 'id', required: true, positional: true, help: 'Question ID (numeric)' },
|
|
{ name: 'limit', type: 'int', default: 5, help: 'Number of answers (max 1000; use normal-sized requests)' },
|
|
{ name: 'sort', default: 'default', choices: ['default', 'created'], help: 'Answer order: default or created' },
|
|
],
|
|
columns: ['rank', 'id', 'author', 'votes', 'url', 'content'],
|
|
func: async (page, kwargs) => {
|
|
const { id, limit = 5 } = kwargs;
|
|
const questionId = String(id);
|
|
if (!/^\d+$/.test(questionId)) {
|
|
throw new CliError('INVALID_INPUT', 'Question ID must be numeric', 'Example: opencli zhihu question 123456789');
|
|
}
|
|
const answerLimit = Number(limit);
|
|
if (!Number.isInteger(answerLimit) || answerLimit <= 0 || answerLimit > MAX_LIMIT) {
|
|
throw new CliError('INVALID_INPUT', `Limit must be a positive integer no greater than ${MAX_LIMIT}`, 'Use a normal-sized limit to avoid slow requests or Zhihu risk controls');
|
|
}
|
|
const sort = String(kwargs.sort || 'default');
|
|
if (sort !== 'default' && sort !== 'created') {
|
|
throw new CliError('INVALID_INPUT', 'Sort must be one of: default, created', 'Example: opencli zhihu question 123456789 --sort created');
|
|
}
|
|
await page.goto(sort === 'created'
|
|
? `https://www.zhihu.com/question/${questionId}/answers/updated`
|
|
: `https://www.zhihu.com/question/${questionId}`);
|
|
// Zhihu caps `limit` at 20 per request, so always ask for the API
|
|
// maximum. The pagination loop below trims to `answerLimit` via the
|
|
// `answers.length >= answerLimit` break, so a smaller --limit only
|
|
// costs one over-fetched page worth of bandwidth and never silently
|
|
// clamps the user-requested count.
|
|
const ZHIHU_PAGE_SIZE = 20;
|
|
let url = `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${ZHIHU_PAGE_SIZE}&offset=0&sort_by=${sort}&include=data[*].content,url,voteup_count,comment_count,author`;
|
|
const answers = [];
|
|
const seen = new Set();
|
|
const visited = new Set();
|
|
while (url && answers.length < answerLimit && !visited.has(url)) {
|
|
visited.add(url);
|
|
const data = await page.evaluate(`
|
|
(async () => {
|
|
const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
if (!r.ok) return { __httpError: r.status };
|
|
return await r.json();
|
|
})()
|
|
`);
|
|
if (!data || data.__httpError) {
|
|
const status = data?.__httpError;
|
|
if (status === 401 || status === 403) {
|
|
throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
|
|
}
|
|
throw new CliError('FETCH_ERROR', status ? `Zhihu question answers request failed (HTTP ${status})` : 'Zhihu question answers request failed', 'Try again later or rerun with -v for more detail');
|
|
}
|
|
for (const item of data.data || []) {
|
|
const key = answerDedupeKey(item);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
answers.push(item);
|
|
if (answers.length >= answerLimit) break;
|
|
}
|
|
if (data.paging?.is_end) break;
|
|
url = typeof data.paging?.next === 'string' ? data.paging.next : '';
|
|
}
|
|
return answers.map((item, i) => {
|
|
const id = answerId(item);
|
|
return {
|
|
rank: i + 1,
|
|
id,
|
|
author: item.author?.name || 'anonymous',
|
|
votes: item.voteup_count || 0,
|
|
url: id ? `https://www.zhihu.com/question/${questionId}/answer/${id}` : '',
|
|
content: stripHtml(item.content || '').substring(0, 200),
|
|
};
|
|
});
|
|
},
|
|
});
|