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
281 lines
11 KiB
JavaScript
281 lines
11 KiB
JavaScript
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { stripHtml as stripHtmlText } from './text.js';
|
|
|
|
function stripHtml(html) {
|
|
return stripHtmlText(html, { preserveBlocks: true });
|
|
}
|
|
|
|
const ANSWER_ID_RE = /^\d+$/;
|
|
const ANSWER_TYPED_RE = /^answer:(\d+):(\d+)$/;
|
|
const ANSWER_PATH_RE = /^\/question\/(\d+)\/answer\/(\d+)\/?$/;
|
|
const BARE_ANSWER_PATH_RE = /^\/answer\/(\d+)\/?$/;
|
|
|
|
function parseAnswerTarget(input) {
|
|
const value = String(input ?? '').trim();
|
|
if (!value) return null;
|
|
if (ANSWER_ID_RE.test(value)) return { answerId: value, questionId: '' };
|
|
const typed = value.match(ANSWER_TYPED_RE);
|
|
if (typed) return { questionId: typed[1], answerId: typed[2] };
|
|
try {
|
|
const url = new URL(value);
|
|
if (
|
|
url.protocol !== 'https:' ||
|
|
url.username ||
|
|
url.password ||
|
|
url.port ||
|
|
(url.hostname !== 'www.zhihu.com' && url.hostname !== 'zhihu.com')
|
|
) {
|
|
return null;
|
|
}
|
|
let m = url.pathname.match(ANSWER_PATH_RE);
|
|
if (m) return { questionId: m[1], answerId: m[2] };
|
|
m = url.pathname.match(BARE_ANSWER_PATH_RE);
|
|
if (m) return { answerId: m[1], questionId: '' };
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractQuestionIdFromAnswerUrl(input) {
|
|
const value = String(input ?? '').trim();
|
|
if (!value) return '';
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'https:' || (url.hostname !== 'www.zhihu.com' && url.hostname !== 'zhihu.com')) return '';
|
|
return url.pathname.match(ANSWER_PATH_RE)?.[1] || '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function normalizeCount(value) {
|
|
return Number.isInteger(value) && value >= 0 ? value : 0;
|
|
}
|
|
|
|
function normalizeUnixSeconds(value) {
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
? new Date(value * 1000).toISOString()
|
|
: '';
|
|
}
|
|
|
|
function memberName(author) {
|
|
return author?.member?.name || author?.name || '';
|
|
}
|
|
|
|
function normalizeCommentId(value) {
|
|
if (typeof value === 'string') return value.trim();
|
|
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return String(value);
|
|
return '';
|
|
}
|
|
|
|
function normalizeCommentUrl(url, questionId, answerId, commentId) {
|
|
if (questionId && answerId && commentId) {
|
|
return `https://www.zhihu.com/question/${questionId}/answer/${answerId}#comment-${commentId}`;
|
|
}
|
|
return typeof url === 'string' ? url : '';
|
|
}
|
|
|
|
function normalizeCommentsApiUrl(url, answerId) {
|
|
if (typeof url !== 'string' || !url) return '';
|
|
try {
|
|
const parsed = new URL(url);
|
|
const expectedWwwPath = `/api/v4/answers/${answerId}/comments`;
|
|
const expectedApiPath = `/answers/${answerId}/comments`;
|
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) return '';
|
|
if (parsed.hostname === 'www.zhihu.com' && parsed.pathname === expectedWwwPath) return parsed.toString();
|
|
if (parsed.hostname === 'api.zhihu.com' && parsed.pathname === expectedApiPath) {
|
|
return `https://www.zhihu.com${expectedWwwPath}${parsed.search}`;
|
|
}
|
|
} catch {
|
|
return '';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function buildRows(comments, { answerId, questionId, topLevelLimit, repliesLimit }) {
|
|
const rows = [];
|
|
let topLevelCount = 0;
|
|
let currentCommentRank = 0;
|
|
let currentReplyCount = 0;
|
|
let reachedTopLevelLimit = false;
|
|
let malformedComments = 0;
|
|
|
|
for (const comment of comments) {
|
|
if (!comment || typeof comment !== 'object' || Array.isArray(comment)) {
|
|
malformedComments += 1;
|
|
continue;
|
|
}
|
|
const id = normalizeCommentId(comment.id);
|
|
if (!id) {
|
|
malformedComments += 1;
|
|
continue;
|
|
}
|
|
const author = memberName(comment.author);
|
|
const replyToAuthor = memberName(comment.reply_to_author);
|
|
const isReply = Boolean(replyToAuthor);
|
|
|
|
if (!isReply) {
|
|
if (topLevelCount >= topLevelLimit) {
|
|
reachedTopLevelLimit = true;
|
|
break;
|
|
}
|
|
topLevelCount += 1;
|
|
currentCommentRank = topLevelCount;
|
|
currentReplyCount = 0;
|
|
} else if (!currentCommentRank || currentReplyCount >= repliesLimit) {
|
|
continue;
|
|
} else {
|
|
currentReplyCount += 1;
|
|
}
|
|
|
|
rows.push({
|
|
rank: rows.length + 1,
|
|
comment_rank: currentCommentRank,
|
|
reply_rank: isReply ? currentReplyCount : 0,
|
|
depth: 0,
|
|
id,
|
|
parent_id: '',
|
|
author: author || 'anonymous',
|
|
reply_to: replyToAuthor,
|
|
likes: normalizeCount(comment.vote_count),
|
|
created_at: normalizeUnixSeconds(comment.created_time),
|
|
url: normalizeCommentUrl(comment.url, questionId, answerId, id),
|
|
content: stripHtml(comment.content || ''),
|
|
});
|
|
}
|
|
return { rows, topLevelCount, reachedTopLevelLimit, malformedComments };
|
|
}
|
|
|
|
const MAX_LIMIT = 1000;
|
|
const MAX_REPLIES_LIMIT = 100;
|
|
const ZHIHU_PAGE_SIZE = 20;
|
|
|
|
cli({
|
|
site: 'zhihu',
|
|
name: 'answer-comments',
|
|
access: 'read',
|
|
description: '知乎回答评论列表',
|
|
domain: 'www.zhihu.com',
|
|
strategy: Strategy.COOKIE,
|
|
args: [
|
|
{ name: 'id', required: true, positional: true, help: 'Answer ID, full Zhihu answer URL, or typed target (answer:<qid>:<aid>)' },
|
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 1000)' },
|
|
{ name: 'replies-limit', type: 'int', default: 3, help: 'Number of replies to include per top-level comment (max 100)' },
|
|
],
|
|
columns: ['rank', 'comment_rank', 'reply_rank', 'depth', 'id', 'parent_id', 'author', 'reply_to', 'likes', 'created_at', 'url', 'content'],
|
|
func: async (page, kwargs) => {
|
|
const target = parseAnswerTarget(kwargs.id);
|
|
if (!target) {
|
|
throw new ArgumentError(
|
|
'Answer ID must be a numeric id, a Zhihu answer URL, or answer:<qid>:<aid>',
|
|
'Example: opencli zhihu answer-comments 1937205528846655537',
|
|
);
|
|
}
|
|
const topLevelLimit = Number(kwargs.limit ?? 20);
|
|
if (!Number.isInteger(topLevelLimit) || topLevelLimit <= 0 || topLevelLimit > MAX_LIMIT) {
|
|
throw new ArgumentError(`--limit must be a positive integer no greater than ${MAX_LIMIT}`);
|
|
}
|
|
const repliesLimit = Number(kwargs['replies-limit'] ?? 3);
|
|
if (!Number.isInteger(repliesLimit) || repliesLimit < 0 || repliesLimit > MAX_REPLIES_LIMIT) {
|
|
throw new ArgumentError(`--replies-limit must be an integer between 0 and ${MAX_REPLIES_LIMIT}`);
|
|
}
|
|
|
|
const { answerId } = target;
|
|
try {
|
|
await page.goto(`https://www.zhihu.com/answer/${answerId}`);
|
|
} catch (err) {
|
|
throw new CommandExecutionError(
|
|
`Failed to open Zhihu answer ${answerId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
'Open the answer URL in Chrome and retry after the page is reachable.',
|
|
);
|
|
}
|
|
const currentQuestionId = page.getCurrentUrl
|
|
? extractQuestionIdFromAnswerUrl(await page.getCurrentUrl().catch(() => ''))
|
|
: '';
|
|
const questionId = target.questionId || currentQuestionId;
|
|
|
|
let url = `https://www.zhihu.com/api/v4/answers/${answerId}/comments?order=normal&limit=${ZHIHU_PAGE_SIZE}&offset=0&status=open`;
|
|
const fetched = [];
|
|
const visited = new Set();
|
|
|
|
while (url && !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 };
|
|
try {
|
|
return await r.json();
|
|
} catch (error) {
|
|
return { __malformedJson: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
})()
|
|
`).catch((err) => {
|
|
throw new CommandExecutionError(
|
|
`Zhihu answer comments request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
'Try again later or rerun with -v for more detail.',
|
|
);
|
|
});
|
|
if (!data || data.__httpError) {
|
|
const status = data?.__httpError;
|
|
if (status === 401 || status === 403) {
|
|
throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch Zhihu answer comments');
|
|
}
|
|
if (status === 404) {
|
|
throw new EmptyResultError('zhihu answer-comments', `No Zhihu answer comments resource was found for ${answerId}.`);
|
|
}
|
|
throw new CommandExecutionError(
|
|
status
|
|
? `Zhihu answer comments request failed (HTTP ${status})`
|
|
: 'Zhihu answer comments request failed',
|
|
'Try again later or rerun with -v for more detail',
|
|
);
|
|
}
|
|
if (data.__malformedJson) {
|
|
throw new CommandExecutionError(
|
|
`Zhihu answer comments returned malformed JSON: ${data.__malformedJson}`,
|
|
'Try again later or rerun with -v for more detail',
|
|
);
|
|
}
|
|
if (!Array.isArray(data.data) || !data.paging || typeof data.paging !== 'object') {
|
|
throw new CommandExecutionError(
|
|
'Zhihu answer comments returned a malformed payload',
|
|
'Try again later or rerun with -v for more detail',
|
|
);
|
|
}
|
|
fetched.push(...data.data);
|
|
const built = buildRows(fetched, { answerId, questionId, topLevelLimit, repliesLimit });
|
|
if (built.malformedComments > 0) {
|
|
throw new CommandExecutionError('Zhihu answer comments contained rows without comment ids');
|
|
}
|
|
if (built.reachedTopLevelLimit || data.paging?.is_end) {
|
|
if (built.rows.length === 0) {
|
|
throw new EmptyResultError('zhihu answer-comments', `No comments found for answer ${answerId}.`);
|
|
}
|
|
return built.rows;
|
|
}
|
|
const next = normalizeCommentsApiUrl(data.paging?.next, answerId);
|
|
if (!next) {
|
|
throw new CommandExecutionError('Zhihu answer comments pagination returned malformed next URL');
|
|
}
|
|
if (visited.has(next)) {
|
|
throw new CommandExecutionError('Zhihu answer comments pagination returned a repeated next URL');
|
|
}
|
|
url = next;
|
|
}
|
|
|
|
const built = buildRows(fetched, { answerId, questionId, topLevelLimit, repliesLimit });
|
|
if (built.malformedComments > 0) {
|
|
throw new CommandExecutionError('Zhihu answer comments contained rows without comment ids');
|
|
}
|
|
if (built.rows.length === 0) {
|
|
throw new EmptyResultError('zhihu answer-comments', `No comments found for answer ${answerId}.`);
|
|
}
|
|
return built.rows;
|
|
},
|
|
});
|
|
|
|
export const __test__ = { stripHtml, parseAnswerTarget, normalizeCommentsApiUrl, buildRows };
|