Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions src/clis/zhihu/question.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
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', () => {
it('returns answers even when the unused question detail request fails', async () => {
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: '<p>Hello <b>Zhihu</b></p>',
rank: 1,
author: 'alice',
votes: 12,
content: 'Hello Zhihu',
},
],
};
});

const page = {
goto,
evaluate,
} as any;

Expand All @@ -37,6 +41,7 @@ describe('zhihu question', () => {
},
]);

expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878');
expect(evaluate).toHaveBeenCalledTimes(1);
});

Expand All @@ -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;

Expand All @@ -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;

Expand All @@ -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();
});
});
52 changes: 26 additions & 26 deletions src/clis/zhihu/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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;
},
});