Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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