diff --git a/src/clis/zhihu/question.test.ts b/src/clis/zhihu/question.test.ts index 01288b6e..7e994991 100644 --- a/src/clis/zhihu/question.test.ts +++ b/src/clis/zhihu/question.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { getRegistry } from '../../registry.js'; -import { AuthRequiredError } from '../../errors.js'; +import { AuthRequiredError, CliError } from '../../errors.js'; import './question.js'; describe('zhihu question', () => { @@ -8,21 +8,25 @@ describe('zhihu question', () => { const cmd = getRegistry().get('zhihu/question'); expect(cmd?.func).toBeTypeOf('function'); - const evaluate = vi.fn().mockImplementation(async (_fn: unknown, args: { questionId: string; answerLimit: number }) => { - expect(args).toEqual({ questionId: '2021881398772981878', answerLimit: 3 }); + const goto = vi.fn().mockResolvedValue(undefined); + const evaluate = vi.fn().mockImplementation(async (js: string) => { + expect(js).toContain('questions/2021881398772981878/answers?limit=3'); + expect(js).toContain("credentials: 'include'"); return { ok: true, answers: [ { - author: { name: 'alice' }, - voteup_count: 12, - content: '

Hello Zhihu

', + rank: 1, + author: 'alice', + votes: 12, + content: 'Hello Zhihu', }, ], }; }); const page = { + goto, evaluate, } as any; @@ -37,6 +41,7 @@ describe('zhihu question', () => { }, ]); + expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878'); expect(evaluate).toHaveBeenCalledTimes(1); }); @@ -45,6 +50,7 @@ describe('zhihu question', () => { expect(cmd?.func).toBeTypeOf('function'); const page = { + goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue({ ok: false, status: 403 }), } as any; @@ -58,6 +64,7 @@ describe('zhihu question', () => { expect(cmd?.func).toBeTypeOf('function'); const page = { + goto: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue({ ok: false, status: 500 }), } as any; @@ -68,4 +75,35 @@ describe('zhihu question', () => { message: 'Zhihu question answers request failed with HTTP 500', }); }); + + it('surfaces browser-side fetch exceptions instead of HTTP unknown', async () => { + const cmd = getRegistry().get('zhihu/question'); + expect(cmd?.func).toBeTypeOf('function'); + + const page = { + goto: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ ok: false, status: 0, error: 'Failed to fetch' }), + } as any; + + await expect( + cmd!.func!(page, { id: '2021881398772981878', limit: 3 }), + ).rejects.toMatchObject({ + code: 'FETCH_ERROR', + message: 'Zhihu question answers request failed: Failed to fetch', + }); + }); + + it('rejects non-numeric question IDs', async () => { + const cmd = getRegistry().get('zhihu/question'); + expect(cmd?.func).toBeTypeOf('function'); + + const page = { goto: vi.fn(), evaluate: vi.fn() } as any; + + await expect( + cmd!.func!(page, { id: "abc'; alert(1); //", limit: 1 }), + ).rejects.toBeInstanceOf(CliError); + + expect(page.goto).not.toHaveBeenCalled(); + expect(page.evaluate).not.toHaveBeenCalled(); + }); }); diff --git a/src/clis/zhihu/question.ts b/src/clis/zhihu/question.ts index 7c220a37..7fac8060 100644 --- a/src/clis/zhihu/question.ts +++ b/src/clis/zhihu/question.ts @@ -14,45 +14,45 @@ cli({ columns: ['rank', 'author', 'votes', '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); - const stripHtml = (html: string) => - (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim(); + await page.goto(`https://www.zhihu.com/question/${questionId}`); - // Only fetch answers here. The question detail endpoint is not used by the - // current CLI output and can fail independently, which would incorrectly - // turn a successful answers response into a login error. - const result = await (page as any).evaluate( - async ({ questionId, answerLimit }: { questionId: string; answerLimit: number }) => { - const aResp = await fetch( - `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${answerLimit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author`, - { credentials: 'include' }, - ); - if (!aResp.ok) return { ok: false as const, status: aResp.status }; - const a = await aResp.json(); - return { ok: true as const, answers: Array.isArray(a?.data) ? a.data : [] }; - }, - { questionId: String(id), answerLimit }, - ); + const url = `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${answerLimit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author`; + const result: any = await page.evaluate(`(async () => { + const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim(); + try { + const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' }); + if (!r.ok) return { ok: false, status: r.status }; + const a = await r.json(); + const answers = (a?.data || []).map((item, i) => ({ + rank: i + 1, + author: item.author?.name || 'anonymous', + votes: item.voteup_count || 0, + content: strip(item.content || '').substring(0, 200), + })); + return { ok: true, answers }; + } catch (e) { + return { ok: false, status: 0, error: e instanceof Error ? e.message : String(e) }; + } + })()`); if (!result?.ok) { if (result?.status === 401 || result?.status === 403) { throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu'); } + const detail = result?.status > 0 ? ` with HTTP ${result.status}` : (result?.error ? `: ${result.error}` : ''); throw new CliError( 'FETCH_ERROR', - `Zhihu question answers request failed with HTTP ${result?.status ?? 'unknown'}`, + `Zhihu question answers request failed${detail}`, 'Try again later or rerun with -v for more detail', ); } - const answers = result.answers.slice(0, answerLimit).map((a: any, i: number) => ({ - rank: i + 1, - author: a.author?.name ?? 'anonymous', - votes: a.voteup_count ?? 0, - content: stripHtml(a.content ?? '').slice(0, 200), - })); - - return answers; + return result.answers; }, });