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
37 changes: 12 additions & 25 deletions src/clis/zhihu/question.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AuthRequiredError, CliError } from '../../errors.js';
import './question.js';

describe('zhihu question', () => {
it('returns answers even when the unused question detail request fails', async () => {
it('returns answers from the Zhihu API', async () => {
const cmd = getRegistry().get('zhihu/question');
expect(cmd?.func).toBeTypeOf('function');

Expand All @@ -13,22 +13,17 @@ describe('zhihu question', () => {
expect(js).toContain('questions/2021881398772981878/answers?limit=3');
expect(js).toContain("credentials: 'include'");
return {
ok: true,
answers: [
data: [
{
rank: 1,
author: 'alice',
votes: 12,
author: { name: 'alice' },
voteup_count: 12,
content: 'Hello Zhihu',
},
],
};
});

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

await expect(
cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
Expand All @@ -47,56 +42,48 @@ describe('zhihu question', () => {

it('maps auth-like answer failures to AuthRequiredError', 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: 403 }),
evaluate: vi.fn().mockResolvedValue({ __httpError: 403 }),
} as any;

await expect(
cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
).rejects.toBeInstanceOf(AuthRequiredError);
});

it('preserves non-auth fetch failures as CliError instead of login errors', async () => {
it('preserves non-auth fetch failures as CliError', 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: 500 }),
evaluate: vi.fn().mockResolvedValue({ __httpError: 500 }),
} as any;

await expect(
cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
).rejects.toMatchObject({
code: 'FETCH_ERROR',
message: 'Zhihu question answers request failed with HTTP 500',
message: 'Zhihu question answers request failed (HTTP 500)',
});
});

it('surfaces browser-side fetch exceptions instead of HTTP unknown', async () => {
it('handles null evaluate response as fetch error', 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' }),
evaluate: vi.fn().mockResolvedValue(null),
} 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',
message: 'Zhihu question answers request failed',
});
});

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(
Expand Down
47 changes: 26 additions & 21 deletions src/clis/zhihu/question.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { cli, Strategy } from '../../registry.js';
import { AuthRequiredError, CliError } from '../../errors.js';

function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.trim();
}

cli({
site: 'zhihu',
name: 'question',
Expand All @@ -23,36 +33,31 @@ cli({
await page.goto(`https://www.zhihu.com/question/${questionId}`);

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 data: any = await page.evaluate(`
(async () => {
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 (!r.ok) return { __httpError: r.status };
return await r.json();
})()
`);

if (!result?.ok) {
if (result?.status === 401 || result?.status === 403) {
if (!data || data.__httpError) {
const status = data?.__httpError;
if (status === 401 || 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${detail}`,
status ? `Zhihu question answers request failed (HTTP ${status})` : 'Zhihu question answers request failed',
'Try again later or rerun with -v for more detail',
);
}

return result.answers;
return (data.data || []).map((item: any, i: number) => ({
rank: i + 1,
author: item.author?.name || 'anonymous',
votes: item.voteup_count || 0,
content: stripHtml(item.content || '').substring(0, 200),
}));
},
});