import { cli, Strategy } from '@jackwener/opencli/registry'; import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; import { log } from '@jackwener/opencli/logger'; function validatePositiveInt(value, name) { const n = Number(value); if (!Number.isInteger(n) || n <= 0) { throw new ArgumentError(`zhihu collections --${name} must be a positive integer`, 'Example: opencli zhihu collections --limit 20'); } return n; } async function fetchJson(page, url, errorLabel) { 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', `${errorLabel} from Zhihu failed. Please ensure you are logged in.`); } throw new CommandExecutionError( status ? `${errorLabel} from Zhihu failed (HTTP ${status})` : `${errorLabel} from Zhihu failed`, 'Try again later or rerun with -v for more detail', ); } return data; } function collectionKey(item) { return String(item?.id || item?.url || item?.title || ''); } cli({ site: 'zhihu', name: 'collections', access: 'read', description: '知乎收藏夹列表(需要登录)', domain: 'www.zhihu.com', strategy: Strategy.COOKIE, browser: true, args: [ { name: 'limit', type: 'int', default: 20, help: '每页数量(最大 20)' }, ], columns: ['rank', 'title', 'item_count', 'description', 'collection_id'], func: async (page, kwargs) => { const { limit = 20 } = kwargs; const requestedLimit = validatePositiveInt(limit, 'limit'); // 先访问知乎主页建立 session await page.goto('https://www.zhihu.com'); // 获取当前用户的 url_token const meData = await fetchJson(page, 'https://www.zhihu.com/api/v4/me?include=url_token', 'Zhihu user info request'); const urlToken = meData.url_token; if (!urlToken) { throw new CommandExecutionError('Failed to get user url_token from Zhihu', 'Please ensure you are logged in.'); } const collected = []; const seen = new Set(); let totals = 0; let offset = 0; const pageLimit = Math.min(requestedLimit, 20); const maxPages = Math.ceil(requestedLimit / pageLimit) + 2; for (let pageIndex = 0; pageIndex < maxPages && collected.length < requestedLimit; pageIndex += 1) { const currentFetchLimit = Math.min(pageLimit, requestedLimit - collected.length); const url = `https://www.zhihu.com/api/v4/people/${urlToken}/collections?include=data%5B*%5D.updated_time&offset=${offset}&limit=${currentFetchLimit}`; const data = await fetchJson(page, url, 'Zhihu favorite collections request'); const items = Array.isArray(data.data) ? data.data : []; const paging = data.paging || {}; totals = Number(paging.totals || totals || 0); for (const item of items) { const key = collectionKey(item); if (key && !seen.has(key)) { seen.add(key); collected.push(item); } if (collected.length >= requestedLimit) break; } if (items.length === 0 || paging.is_end || collected.length >= requestedLimit) break; if (typeof paging.next === 'string') { try { const nextUrl = new URL(paging.next); const parsedOffset = Number(nextUrl.searchParams.get('offset')); if (Number.isInteger(parsedOffset) && parsedOffset > offset) { offset = parsedOffset; continue; } } catch {} } if (items.length < currentFetchLimit) break; const fallbackOffset = offset + items.length; if (fallbackOffset <= offset) break; offset = fallbackOffset; if (totals && offset >= totals) break; } if (totals > 0) { log.info(`共有 ${totals} 个收藏夹`); } if (collected.length === 0) { throw new EmptyResultError('zhihu collections', 'No favorite collections were returned for the logged-in user.'); } return collected.slice(0, requestedLimit).map((item, i) => ({ rank: i + 1, title: item.title || '未命名', item_count: item.item_count ?? item.answer_count ?? 0, description: item.description || '', collection_id: String(item.id || ''), })); }, }); export const __test__ = { validatePositiveInt, collectionKey, };