From f9e6d4cea9a0aadc84bc97b2ad298b9edfb6d803 Mon Sep 17 00:00:00 2001 From: dong <1057762929@qq.com> Date: Tue, 21 Apr 2026 10:39:43 +0800 Subject: [PATCH 1/2] feat: add support for leetcode.cn in tui --- src/__tests__/tui/auth-effects.test.ts | 45 ++++++++++++++++++++++++-- src/__tests__/tui/login-screen.test.ts | 44 +++++++++++++++++++++++++ src/tui/commands/effects.ts | 12 +++++-- src/tui/screens/login/index.ts | 31 +++++++++++++++--- src/tui/screens/login/view.ts | 38 +++++++++++++++++----- src/tui/types.ts | 14 ++++++-- src/tui/update.ts | 16 +++++---- 7 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 src/__tests__/tui/login-screen.test.ts diff --git a/src/__tests__/tui/auth-effects.test.ts b/src/__tests__/tui/auth-effects.test.ts index d669f39..9229b0c 100644 --- a/src/__tests__/tui/auth-effects.test.ts +++ b/src/__tests__/tui/auth-effects.test.ts @@ -41,11 +41,20 @@ const { mockCredentials, mockLeetCodeClient } = vi.hoisted(() => ({ getPath: vi.fn(() => '/tmp/.leetcode/credentials.v2.enc.json'), }, mockLeetCodeClient: { + setSite: vi.fn(), setCredentials: vi.fn(), checkAuth: vi.fn(), }, })); +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + setSite: vi.fn(), + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + }, +})); + vi.mock('../../storage/credentials.js', () => ({ credentials: mockCredentials, describeCredentialStatus: vi.fn((status: { reason: string | null }) => { @@ -60,6 +69,10 @@ vi.mock('../../api/client.js', () => ({ leetcodeClient: mockLeetCodeClient, })); +vi.mock('../../storage/config.js', () => ({ + config: mockConfig, +})); + import { executeCommand } from '../../tui/commands/effects.js'; async function flushAsync(): Promise { @@ -84,7 +97,7 @@ describe('TUI auth effects', () => { const dispatched: AppMsg[] = []; executeCommand( - { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' }, + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' }, (msg) => dispatched.push(msg) ); @@ -109,7 +122,7 @@ describe('TUI auth effects', () => { const dispatched: AppMsg[] = []; executeCommand( - { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' }, + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' }, (msg) => dispatched.push(msg) ); @@ -165,4 +178,32 @@ describe('TUI auth effects', () => { expect(dispatched).toContainEqual({ type: 'GLOBAL_ERROR', error: 'System keychain is unavailable.' }); expect(dispatched).toContainEqual({ type: 'AUTH_CHECK_COMPLETE', user: null }); }); + + it('should persist selected site before verifying login credentials', async () => { + mockCredentials.status.mockResolvedValueOnce({ + mode: 'keychain', + backend: 'keychain', + source: null, + hasCredentials: false, + readOnly: false, + reason: null, + path: null, + } as any); + mockLeetCodeClient.checkAuth.mockResolvedValueOnce({ + isSignedIn: true, + username: 'dong', + }); + + const dispatched: AppMsg[] = []; + executeCommand( + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.cn' }, + (msg) => dispatched.push(msg) + ); + + await flushAsync(); + + expect(mockConfig.setSite).toHaveBeenCalledWith('leetcode.cn'); + expect(mockLeetCodeClient.setSite).toHaveBeenCalledWith('leetcode.cn'); + expect(dispatched).toContainEqual({ type: 'LOGIN_SUCCESS', username: 'dong' }); + }); }); diff --git a/src/__tests__/tui/login-screen.test.ts b/src/__tests__/tui/login-screen.test.ts new file mode 100644 index 0000000..c6e7cb5 --- /dev/null +++ b/src/__tests__/tui/login-screen.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + }, +})); + +vi.mock('../../storage/config.js', () => ({ + config: mockConfig, +})); + +import { init, update } from '../../tui/screens/login/index.js'; + +describe('TUI login screen', () => { + it('should move from instructions to site selection before credential entry', () => { + const [initialModel] = init(); + + const [siteModel] = update({ type: 'LOGIN_SUBMIT' }, initialModel); + const [inputModel] = update({ type: 'LOGIN_SUBMIT' }, siteModel); + + expect(siteModel.step).toBe('site'); + expect(inputModel.step).toBe('input'); + expect(inputModel.site).toBe('leetcode.com'); + }); + + it('should include selected site in the login command payload', () => { + const [initialModel] = init(); + const [siteModel] = update({ type: 'LOGIN_SUBMIT' }, initialModel); + const [cnModel] = update({ type: 'LOGIN_SWITCH_SITE' }, siteModel); + const [inputModel] = update({ type: 'LOGIN_SUBMIT' }, cnModel); + const [sessionModel] = update({ type: 'LOGIN_SESSION_INPUT', value: 'session-token' }, inputModel); + const [readyModel] = update({ type: 'LOGIN_CSRF_INPUT', value: 'csrf-token' }, sessionModel); + const [, cmd] = update({ type: 'LOGIN_SUBMIT' }, readyModel); + + expect(cmd).toEqual({ + type: 'CMD_LOGIN', + session: 'session-token', + csrf: 'csrf-token', + site: 'leetcode.cn', + }); + }); +}); diff --git a/src/tui/commands/effects.ts b/src/tui/commands/effects.ts index cc4bc85..ba0450f 100644 --- a/src/tui/commands/effects.ts +++ b/src/tui/commands/effects.ts @@ -123,7 +123,7 @@ export function executeCommand(cmd: Command, dispatch: Dispatch): void { return; case 'CMD_LOGIN': - login(cmd.session, cmd.csrf, dispatch); + login(cmd.session, cmd.csrf, cmd.site, dispatch); return; default: @@ -554,7 +554,12 @@ async function logout(dispatch: Dispatch): Promise { dispatch({ type: 'AUTH_CHECK_COMPLETE', user: null }); } -async function login(session: string, csrf: string, dispatch: Dispatch): Promise { +async function login( + session: string, + csrf: string, + site: import('../../types.js').LeetCodeSite, + dispatch: Dispatch +): Promise { const credentialStatus = await credentials.status(); if (credentialStatus.mode === 'env-readonly') { dispatch({ @@ -573,6 +578,9 @@ async function login(session: string, csrf: string, dispatch: Dispatch): Promise return; } + config.setSite(site); + configureLeetCodeClientSite(site); + const creds = { session, csrfToken: csrf }; leetcodeClient.setCredentials(creds); diff --git a/src/tui/screens/login/index.ts b/src/tui/screens/login/index.ts index e5e57e4..000ddcd 100644 --- a/src/tui/screens/login/index.ts +++ b/src/tui/screens/login/index.ts @@ -1,12 +1,17 @@ -import { AppModel, Cmd, Command, LoginScreenModel, LoginMsg } from '../../types.js'; -import { leetcodeClient } from '../../../api/client.js'; -import { credentials } from '../../../storage/credentials.js'; +import { Cmd, Command, LoginScreenModel, LoginMsg } from '../../types.js'; +import { config } from '../../../storage/config.js'; +import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../../../utils/site.js'; export { view } from './view.js'; +function getConfiguredSite() { + return normalizeLeetCodeSiteInput(config.getSite?.() ?? config.getConfig?.().site ?? '') ?? DEFAULT_LEETCODE_SITE; +} + export function init(): [LoginScreenModel, Command] { const model: LoginScreenModel = { step: 'instructions', + site: getConfiguredSite(), sessionToken: '', csrfToken: '', focusedField: 'session', @@ -17,6 +22,16 @@ export function init(): [LoginScreenModel, Command] { export function update(msg: LoginMsg, model: LoginScreenModel): [LoginScreenModel, Command] { switch (msg.type) { + case 'LOGIN_SWITCH_SITE': + return [ + { + ...model, + site: model.site === 'leetcode.cn' ? 'leetcode.com' : 'leetcode.cn', + error: null, + }, + Cmd.none(), + ]; + case 'LOGIN_SESSION_INPUT': return [{ ...model, sessionToken: msg.value }, Cmd.none()]; @@ -33,18 +48,24 @@ export function update(msg: LoginMsg, model: LoginScreenModel): [LoginScreenMode return [{ ...model, focusedField: msg.field }, Cmd.none()]; case 'LOGIN_BACK': + if (model.step === 'input' || model.step === 'error' || model.step === 'verifying') { + return [{ ...model, step: 'site', focusedField: 'session', error: null }, Cmd.none()]; + } return [{ ...model, step: 'instructions', focusedField: 'session', error: null }, Cmd.none()]; case 'LOGIN_SUBMIT': if (model.step === 'instructions') { - return [{ ...model, step: 'input', focusedField: 'session' }, Cmd.none()]; + return [{ ...model, step: 'site', focusedField: 'session', error: null }, Cmd.none()]; + } + if (model.step === 'site') { + return [{ ...model, step: 'input', focusedField: 'session', error: null }, Cmd.none()]; } if (!model.sessionToken || !model.csrfToken) { return [{ ...model, error: 'Both fields are required' }, Cmd.none()]; } return [ { ...model, step: 'verifying', error: null }, - Cmd.login(model.sessionToken, model.csrfToken), + Cmd.login(model.sessionToken, model.csrfToken, model.site), ]; case 'LOGIN_SUCCESS': diff --git a/src/tui/screens/login/view.ts b/src/tui/screens/login/view.ts index c70a2c2..4a865e9 100644 --- a/src/tui/screens/login/view.ts +++ b/src/tui/screens/login/view.ts @@ -32,13 +32,9 @@ export function view(model: LoginScreenModel, width: number, height: number): st const instructions = [ chalk.hex(colors.warning).bold('How to Login:'), '', - '1. Open https://leetcode.com in your browser', - '2. Login to your account', - '3. Open DevTools (F12) → Application → Cookies → leetcode.com', - '4. Copy the values of ' + - chalk.bold.cyan('LEETCODE_SESSION') + - ' and ' + - chalk.bold.cyan('csrftoken'), + '1. Continue to choose your LeetCode site', + '2. We will show the correct cookie domain on the next screen', + '3. Copy the values of ' + chalk.bold.cyan('LEETCODE_SESSION') + ' and ' + chalk.bold.cyan('csrftoken'), '', 'Default storage: system keychain.', 'Use LEETCODECLI_CREDENTIAL_BACKEND=file + LEETCODECLI_MASTER_KEY for encrypted file mode.', @@ -54,10 +50,34 @@ export function view(model: LoginScreenModel, width: number, height: number): st boxed.forEach((l) => contentLines.push(center(l, width))); contentLines.push(''); // Spacing - contentLines.push(center(keyHint('Enter', 'Continue to Login'), width)); + contentLines.push(center(keyHint('Enter', 'Choose Site'), width)); + } else if (model.step === 'site') { + const contentWidth = Math.max(28, Math.min(100, width - 4)); + const options = [ + model.site === 'leetcode.com' + ? chalk.hex(colors.primary)('> leetcode.com (Global)') + : ' leetcode.com (Global)', + model.site === 'leetcode.cn' + ? chalk.hex(colors.primary)('> leetcode.cn (China)') + : ' leetcode.cn (China)', + '', + chalk.gray('Use Up/Down/Tab to switch sites.'), + ]; + + const boxed = box(options, contentWidth, { + title: 'Choose Site', + borderColor: colors.primary, + padding: 1, + borderStyle: 'round', + }); + + boxed.forEach((l) => contentLines.push(center(l, width))); + contentLines.push(''); + contentLines.push(center(keyHint('Enter', 'Continue') + ' ' + keyHint('Esc', 'Back'), width)); } else if (model.step === 'input' || model.step === 'verifying' || model.step === 'error') { const contentWidth = Math.max(28, Math.min(100, width - 4)); const boxLines = []; + const domain = model.site === 'leetcode.cn' ? 'leetcode.cn' : 'leetcode.com'; const isSessionFocused = model.focusedField === 'session'; const isCsrfFocused = model.focusedField === 'csrf'; @@ -95,6 +115,8 @@ export function view(model: LoginScreenModel, width: number, height: number): st : ' ' + csrfVal + (model.csrfToken ? chalk.green(' ✔') : '') ); + boxLines.push(''); + boxLines.push(chalk.gray(`Cookie source: https://${domain} → DevTools → Application → Cookies → ${domain}`)); boxLines.push(''); if (model.error) { boxLines.push(center(chalk.red(model.error), contentWidth - 4)); diff --git a/src/tui/types.ts b/src/tui/types.ts index 72ffadb..e286ae1 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -4,6 +4,7 @@ import type { ProblemListFilters as RootProblemListFilters, DailyChallenge, } from '../types.js'; +import type { LeetCodeSite } from '../types.js'; export type Problem = RootProblem; export type ProblemDetail = RootProblemDetail; @@ -133,7 +134,8 @@ export interface HelpScreenModel { } export interface LoginScreenModel { - readonly step: 'instructions' | 'input' | 'verifying' | 'success' | 'error'; + readonly step: 'instructions' | 'site' | 'input' | 'verifying' | 'success' | 'error'; + readonly site: LeetCodeSite; readonly sessionToken: string; readonly csrfToken: string; readonly focusedField: 'session' | 'csrf'; @@ -356,6 +358,7 @@ export type HelpMsg = export type LoginMsg = | { readonly type: 'LOGIN_SESSION_INPUT'; readonly value: string } | { readonly type: 'LOGIN_CSRF_INPUT'; readonly value: string } + | { readonly type: 'LOGIN_SWITCH_SITE' } | { readonly type: 'LOGIN_SUBMIT' } | { readonly type: 'LOGIN_BACK' } | { readonly type: 'LOGIN_SWITCH_FOCUS' } @@ -438,7 +441,7 @@ export type Command = | { readonly type: 'CMD_SWITCH_WORKSPACE'; readonly name: string } | { readonly type: 'CMD_FETCH_CHANGELOG' } | { readonly type: 'CMD_LOGOUT' } - | { readonly type: 'CMD_LOGIN'; readonly session: string; readonly csrf: string }; + | { readonly type: 'CMD_LOGIN'; readonly session: string; readonly csrf: string; readonly site: LeetCodeSite }; export const Cmd = { none: (): Command => ({ type: 'CMD_NONE' }), @@ -482,7 +485,12 @@ export const Cmd = { switchWorkspace: (name: string): Command => ({ type: 'CMD_SWITCH_WORKSPACE', name }), fetchChangelog: (): Command => ({ type: 'CMD_FETCH_CHANGELOG' }), logout: (): Command => ({ type: 'CMD_LOGOUT' }), - login: (session: string, csrf: string): Command => ({ type: 'CMD_LOGIN', session, csrf }), + login: (session: string, csrf: string, site: LeetCodeSite): Command => ({ + type: 'CMD_LOGIN', + session, + csrf, + site, + }), }; export function createInitialModel(username?: string): AppModel { diff --git a/src/tui/update.ts b/src/tui/update.ts index e7948cd..df1afcb 100644 --- a/src/tui/update.ts +++ b/src/tui/update.ts @@ -127,13 +127,7 @@ export function update(msg: AppMsg, model: AppModel): [AppModel, Command] { user: null, screenState: { screen: 'login', - model: { - step: 'instructions', - sessionToken: '', - csrfToken: '', - focusedField: 'session', - error: null, - }, + model: LoginScreen.init()[0], }, needsRender: true, }, @@ -973,6 +967,14 @@ function handleLoginKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command] } else if (key.name === 'escape') { return [model, Cmd.exit()]; } + } else if (loginModel.step === 'site') { + if (key.name === 'escape') { + return updateLogin(model, { type: 'LOGIN_BACK' }); + } else if (key.name === 'tab' || key.name === 'down' || key.name === 'up' || key.name === 'left' || key.name === 'right') { + return updateLogin(model, { type: 'LOGIN_SWITCH_SITE' }); + } else if (key.name === 'return' || key.name === 'enter') { + return updateLogin(model, { type: 'LOGIN_SUBMIT' }); + } } else if (loginModel.step === 'input' || loginModel.step === 'error') { if (key.name === 'escape') { return updateLogin(model, { type: 'LOGIN_BACK' }); From de623e0b139a68a43c60e6cddd897d313318694c Mon Sep 17 00:00:00 2001 From: dong <1057762929@qq.com> Date: Tue, 21 Apr 2026 11:35:11 +0800 Subject: [PATCH 2/2] feat: add support for 'list' and other commands for leetcode.cn --- src/__tests__/api/client-cn.test.ts | 140 +++++++++++++++++++++++ src/__tests__/api/cn-adapter.test.ts | 116 +++++++++++++++++++ src/__tests__/api/query-resolver.test.ts | 8 ++ src/__tests__/commands/solve.test.ts | 10 ++ src/api/adapters/cn.ts | 110 +++++++++++++++++- src/api/client.ts | 44 ++++++- src/api/queries.cn.ts | 90 ++++++++++++++- src/schemas/api.ts | 68 +++++++++++ 8 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/api/client-cn.test.ts diff --git a/src/__tests__/api/client-cn.test.ts b/src/__tests__/api/client-cn.test.ts new file mode 100644 index 0000000..1beb63d --- /dev/null +++ b/src/__tests__/api/client-cn.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LeetCodeClient } from '../../api/client.js'; + +describe('LeetCodeClient cn getProblemById', () => { + it('uses list search to resolve cn problem ids by exact frontend id', async () => { + const client = new LeetCodeClient('leetcode.cn'); + + const getProblemsSpy = vi.spyOn(client, 'getProblems').mockResolvedValueOnce({ + total: 1, + problems: [ + { + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + }, + ], + }); + + const getProblemSpy = vi.spyOn(client, 'getProblem').mockResolvedValue({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + content: '

Given an array of integers...

', + codeSnippets: [], + sampleTestCase: '', + exampleTestcases: '', + hints: [], + companyTags: [], + stats: '{}', + }); + + const result = await client.getProblemById('1'); + + expect(getProblemsSpy).toHaveBeenCalledWith({ searchKeywords: '1', limit: 50, skip: 0 }); + expect(getProblemSpy).toHaveBeenCalledWith('two-sum'); + expect(result.titleSlug).toBe('two-sum'); + }); + + it('throws when cn list search does not contain the exact frontend id', async () => { + const client = new LeetCodeClient('leetcode.cn'); + + const getProblemsSpy = vi.spyOn(client, 'getProblems').mockResolvedValueOnce({ + total: 12, + problems: [ + { + questionId: '200', + questionFrontendId: '200', + title: '岛屿数量', + titleSlug: 'number-of-islands', + difficulty: 'Medium', + isPaidOnly: false, + acRate: 61.2, + topicTags: [], + status: null, + }, + ], + }); + + const getProblemSpy = vi.spyOn(client, 'getProblem'); + + await expect(client.getProblemById('2')).rejects.toThrow('Problem #2 not found'); + + expect(getProblemsSpy).toHaveBeenCalledWith({ searchKeywords: '2', limit: 50, skip: 0 }); + expect(getProblemSpy).not.toHaveBeenCalled(); + }); + + it('searches additional cn result pages until the exact frontend id is found', async () => { + const client = new LeetCodeClient('leetcode.cn'); + + const getProblemsSpy = vi + .spyOn(client, 'getProblems') + .mockResolvedValueOnce({ + total: 120, + problems: Array.from({ length: 50 }, (_, index) => ({ + questionId: String(index + 100), + questionFrontendId: String(index + 100), + title: `Problem ${index + 100}`, + titleSlug: `problem-${index + 100}`, + difficulty: 'Easy' as const, + isPaidOnly: false, + acRate: 50, + topicTags: [], + status: null, + })), + }) + .mockResolvedValueOnce({ + total: 120, + problems: [ + { + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + }, + ], + }); + + const getProblemSpy = vi.spyOn(client, 'getProblem').mockResolvedValue({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + content: '

Given an array of integers...

', + codeSnippets: [], + sampleTestCase: '', + exampleTestcases: '', + hints: [], + companyTags: [], + stats: '{}', + }); + + await client.getProblemById('1'); + + expect(getProblemsSpy).toHaveBeenNthCalledWith(1, { searchKeywords: '1', limit: 50, skip: 0 }); + expect(getProblemsSpy).toHaveBeenNthCalledWith(2, { searchKeywords: '1', limit: 50, skip: 50 }); + expect(getProblemSpy).toHaveBeenCalledWith('two-sum'); + }); +}); diff --git a/src/__tests__/api/cn-adapter.test.ts b/src/__tests__/api/cn-adapter.test.ts index 71913fb..0f6f87f 100644 --- a/src/__tests__/api/cn-adapter.test.ts +++ b/src/__tests__/api/cn-adapter.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from 'vitest'; import { normalizeCnDailyChallenge, + normalizeCnProblemDetail, + normalizeCnProblemList, normalizeCnSkillStats, normalizeCnUserProfile, } from '../../api/adapters/cn.js'; +import { CnProblemDetailSchema, CnProblemListSchema } from '../../schemas/api.js'; describe('cn adapters', () => { it('normalizes daily challenge payload from todayRecord', () => { @@ -33,6 +36,119 @@ describe('cn adapters', () => { expect(result.link).toBe('/problems/two-sum/'); }); + it('normalizes problem list payload from leetcode.cn', () => { + const result = normalizeCnProblemList({ + problemsetQuestionList: { + total: 3, + questions: [ + { + frontendQuestionId: '1', + title: 'Two Sum', + titleCn: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + paidOnly: false, + acRate: '52.3', + status: 'AC', + topicTags: [{ name: 'Array', nameTranslated: '数组', id: 'array', slug: 'array' }], + }, + { + frontendQuestionId: '2', + title: 'Add Two Numbers', + titleCn: '两数相加', + titleSlug: 'add-two-numbers', + difficulty: 'Medium', + paidOnly: false, + acRate: '42.1', + status: 'TRIED', + topicTags: [], + }, + { + frontendQuestionId: '3', + title: 'Longest Substring Without Repeating Characters', + titleCn: '无重复字符的最长子串', + titleSlug: 'longest-substring-without-repeating-characters', + difficulty: 'Medium', + paidOnly: false, + acRate: '38.4', + status: 'NOT_STARTED', + topicTags: [], + }, + ], + }, + }); + + expect(result.total).toBe(3); + expect(result.problems[0]).toMatchObject({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + status: 'ac', + }); + expect(result.problems[0]?.topicTags).toEqual([{ name: '数组', slug: 'array' }]); + expect(result.problems[1]?.status).toBe('notac'); + expect(result.problems[2]?.status).toBeNull(); + }); + + it('accepts leetcode.cn problem list status enums before normalization', () => { + const parsed = CnProblemListSchema.parse({ + problemsetQuestionList: { + total: 3, + questions: [ + { frontendQuestionId: '1', status: 'AC' }, + { frontendQuestionId: '2', status: 'TRIED' }, + { frontendQuestionId: '3', status: 'NOT_STARTED' }, + ], + }, + }); + + expect(parsed.problemsetQuestionList.questions).toHaveLength(3); + }); + + it('normalizes problem detail payload from leetcode.cn', () => { + const parsed = CnProblemDetailSchema.parse({ + question: { + questionId: '1', + questionFrontendId: '1', + title: 'Two Sum', + translatedTitle: '两数之和', + titleSlug: 'two-sum', + translatedContent: '

给定一个整数数组 nums 和一个整数目标值 target...

', + difficulty: 'Easy', + isPaidOnly: false, + acRate: '52.3', + status: 'AC', + topicTags: [{ name: 'Array', slug: 'array', translatedName: '数组' }], + codeSnippets: [{ lang: 'TypeScript', langSlug: 'typescript', code: 'function twoSum() {}' }], + sampleTestCase: '[2,7,11,15]\n9', + exampleTestcases: '[2,7,11,15]\n9', + hints: ['Use a hash map.'], + stats: '{}', + }, + }); + + const result = normalizeCnProblemDetail(parsed); + + expect(result).toMatchObject({ + questionId: '1', + questionFrontendId: '1', + title: '两数之和', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + status: 'ac', + sampleTestCase: '[2,7,11,15]\n9', + exampleTestcases: '[2,7,11,15]\n9', + }); + expect(result.topicTags).toEqual([{ name: '数组', slug: 'array' }]); + expect(result.companyTags).toBeNull(); + }); + it('normalizes cn profile payload into shared user profile shape', () => { const profile = normalizeCnUserProfile('night-slayer', { userProfilePublicProfile: { diff --git a/src/__tests__/api/query-resolver.test.ts b/src/__tests__/api/query-resolver.test.ts index db5d181..72a678d 100644 --- a/src/__tests__/api/query-resolver.test.ts +++ b/src/__tests__/api/query-resolver.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { getQueryPack } from '../../api/query-resolver.js'; +import { PROBLEM_LIST_QUERY as PROBLEM_LIST_QUERY_GLOBAL } from '../../api/queries.global.js'; +import { PROBLEM_DETAIL_QUERY as PROBLEM_DETAIL_QUERY_GLOBAL } from '../../api/queries.global.js'; import { DAILY_CHALLENGE_QUERY as DAILY_CHALLENGE_QUERY_GLOBAL } from '../../api/queries.global.js'; import { DAILY_CHALLENGE_QUERY_CN } from '../../api/queries.cn.js'; @@ -14,5 +16,11 @@ describe('query resolver', () => { const pack = getQueryPack('leetcode.cn'); expect(pack.DAILY_CHALLENGE_QUERY).toContain('todayRecord'); expect(pack.DAILY_CHALLENGE_QUERY).toBe(DAILY_CHALLENGE_QUERY_CN); + expect(pack.PROBLEM_LIST_QUERY).not.toBe(PROBLEM_LIST_QUERY_GLOBAL); + expect(pack.PROBLEM_LIST_QUERY).toContain('titleCn'); + expect(pack.PROBLEM_LIST_QUERY).toContain('frontendQuestionId'); + expect(pack.PROBLEM_DETAIL_QUERY).not.toBe(PROBLEM_DETAIL_QUERY_GLOBAL); + expect(pack.PROBLEM_DETAIL_QUERY).toContain('translatedTitle'); + expect(pack.PROBLEM_DETAIL_QUERY).toContain('translatedContent'); }); }); diff --git a/src/__tests__/commands/solve.test.ts b/src/__tests__/commands/solve.test.ts index 8862435..8bdd425 100644 --- a/src/__tests__/commands/solve.test.ts +++ b/src/__tests__/commands/solve.test.ts @@ -172,6 +172,16 @@ describe('Solve Commands', () => { // Should warn about existing file but not fail expect(leetcodeClient.getProblemById).toHaveBeenCalled(); }); + + it('should not create a file when problem id lookup fails', async () => { + vi.mocked(existsSync).mockReturnValue(false); + vi.mocked(leetcodeClient.getProblemById).mockRejectedValueOnce(new Error('Problem #1 not found')); + + const result = await pickCommand('1', { open: false }); + + expect(result).toBe(false); + expect(writeFile).not.toHaveBeenCalled(); + }); }); describe('file creation', () => { diff --git a/src/api/adapters/cn.ts b/src/api/adapters/cn.ts index 1dc9d91..8d05893 100644 --- a/src/api/adapters/cn.ts +++ b/src/api/adapters/cn.ts @@ -1,4 +1,4 @@ -import type { DailyChallenge, Problem } from '../../types.js'; +import type { DailyChallenge, Problem, ProblemDetail } from '../../types.js'; interface CnTopicTag { name?: string; @@ -17,10 +17,49 @@ interface CnQuestion { paidOnly?: boolean; isPaidOnly?: boolean; acRate?: number | string; - status?: 'ac' | 'notac' | null; + status?: string | null; topicTags?: CnTopicTag[]; } +interface CnProblemListItem { + frontendQuestionId?: string | number; + title?: string; + titleCn?: string; + titleSlug?: string; + difficulty?: string; + paidOnly?: boolean; + acRate?: number | string; + status?: string | null; + topicTags?: Array; +} + +interface CnProblemDetailTag { + name?: string; + slug?: string; + translatedName?: string; +} + +interface CnProblemDetailShape { + question: { + questionId?: string | number; + questionFrontendId?: string | number; + title?: string; + translatedTitle?: string; + titleSlug?: string; + translatedContent?: string | null; + difficulty?: string; + isPaidOnly?: boolean; + acRate?: number | string; + status?: string | null; + topicTags?: CnProblemDetailTag[]; + codeSnippets?: Array<{ lang: string; langSlug: string; code: string }> | null; + sampleTestCase?: string; + exampleTestcases?: string; + hints?: string[]; + stats?: string; + }; +} + interface CnDailyRecord { date?: string; link?: string; @@ -71,7 +110,10 @@ function toTitleCaseDifficulty(difficulty?: string): Problem['difficulty'] { } function toStatus(status: string | null | undefined): Problem['status'] { - if (status === 'ac' || status === 'notac') return status; + const value = (status ?? '').toLowerCase(); + if (value === 'ac') return 'ac'; + if (value === 'notac' || value === 'tried') return 'notac'; + if (value === 'not_started') return null; return null; } @@ -107,6 +149,30 @@ function toProblem(question: CnQuestion): Problem { }; } +function toProblemFromListEntry(question: CnProblemListItem): Problem { + const title = question.titleCn || question.title || 'Unknown Problem'; + const titleSlug = question.titleSlug || toSlug(title); + const tags = (question.topicTags ?? []).map((tag) => { + const tagName = tag.nameTranslated || tag.name || 'Tag'; + return { + name: tagName, + slug: tag.slug || (tag.id !== undefined ? String(tag.id) : toSlug(tagName)), + }; + }); + + return { + questionId: String(question.frontendQuestionId ?? ''), + questionFrontendId: String(question.frontendQuestionId ?? ''), + title, + titleSlug, + difficulty: toTitleCaseDifficulty(question.difficulty), + isPaidOnly: Boolean(question.paidOnly), + acRate: Number(question.acRate ?? 0), + topicTags: tags, + status: toStatus(question.status), + }; +} + export function normalizeCnDailyChallenge(input: { todayRecord?: CnDailyRecord[] }): DailyChallenge { const record = input.todayRecord?.[0]; if (!record || !record.question) { @@ -122,6 +188,44 @@ export function normalizeCnDailyChallenge(input: { todayRecord?: CnDailyRecord[] }; } +export function normalizeCnProblemList(input: { + problemsetQuestionList: { total: number; questions: CnProblemListItem[] }; +}): { total: number; problems: Problem[] } { + return { + total: input.problemsetQuestionList.total, + problems: input.problemsetQuestionList.questions.map((question) => toProblemFromListEntry(question)), + }; +} + +export function normalizeCnProblemDetail(input: CnProblemDetailShape): ProblemDetail { + const question = input.question; + const title = question.translatedTitle || question.title || 'Unknown Problem'; + const titleSlug = question.titleSlug || toSlug(title); + const topicTags = (question.topicTags ?? []).map((tag) => ({ + name: tag.translatedName || tag.name || 'Tag', + slug: tag.slug || toSlug(tag.translatedName || tag.name || 'tag'), + })); + + return { + questionId: String(question.questionId ?? ''), + questionFrontendId: String(question.questionFrontendId ?? ''), + title, + titleSlug, + content: question.translatedContent ?? null, + difficulty: toTitleCaseDifficulty(question.difficulty), + isPaidOnly: Boolean(question.isPaidOnly), + acRate: Number(question.acRate ?? 0), + topicTags, + codeSnippets: question.codeSnippets ?? null, + sampleTestCase: question.sampleTestCase ?? '', + exampleTestcases: question.exampleTestcases ?? '', + hints: question.hints ?? [], + companyTags: null, + stats: question.stats ?? '{}', + status: toStatus(question.status), + }; +} + export function normalizeCnUserProfile( username: string, input: CnProfileShape diff --git a/src/api/client.ts b/src/api/client.ts index c6cc1d2..583b1e2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -14,6 +14,8 @@ import type { TestResult, } from '../types.js'; import { + CnProblemDetailSchema, + CnProblemListSchema, CnDailyChallengeSchema, CnSkillStatsSchema, CnUserProfileSchema, @@ -29,7 +31,13 @@ import { } from '../schemas/api.js'; import { getQueryPack } from './query-resolver.js'; import type { QueryPack } from './queries.global.js'; -import { normalizeCnDailyChallenge, normalizeCnSkillStats, normalizeCnUserProfile } from './adapters/index.js'; +import { + normalizeCnDailyChallenge, + normalizeCnProblemDetail, + normalizeCnProblemList, + normalizeCnSkillStats, + normalizeCnUserProfile, +} from './adapters/index.js'; const BASE_URLS: Record = { 'leetcode.com': 'https://leetcode.com', @@ -218,6 +226,12 @@ export class LeetCodeClient { (variables.filters as Record).searchKeywords = filters.searchKeywords; } + if (this.site === 'leetcode.cn') { + const data = await this.graphql('PROBLEM_LIST', this.queries.PROBLEM_LIST_QUERY, variables); + const validated = CnProblemListSchema.parse(data); + return normalizeCnProblemList(validated); + } + const data = await this.graphql<{ problemsetQuestionList: { total: number; questions: Problem[] }; }>('PROBLEM_LIST', this.queries.PROBLEM_LIST_QUERY, variables); @@ -231,6 +245,14 @@ export class LeetCodeClient { } async getProblem(titleSlug: string): Promise { + if (this.site === 'leetcode.cn') { + const data = await this.graphql('PROBLEM_DETAIL', this.queries.PROBLEM_DETAIL_QUERY, { + titleSlug, + }); + const validated = CnProblemDetailSchema.parse(data); + return normalizeCnProblemDetail(validated); + } + const data = await this.graphql<{ question: ProblemDetail }>('PROBLEM_DETAIL', this.queries.PROBLEM_DETAIL_QUERY, { titleSlug, }); @@ -240,6 +262,26 @@ export class LeetCodeClient { } async getProblemById(id: string): Promise { + if (this.site === 'leetcode.cn') { + const limit = 50; + let skip = 0; + let total = 0; + let problem: Problem | undefined; + + do { + const result = await this.getProblems({ searchKeywords: id, limit, skip }); + total = result.total; + problem = result.problems.find((p) => p.questionFrontendId === id); + skip += limit; + } while (!problem && skip < total); + + if (!problem) { + throw new Error(`Problem #${id} not found`); + } + + return this.getProblem(problem.titleSlug); + } + const { problems } = await this.getProblems({ searchKeywords: id, limit: 10 }); const problem = problems.find((p) => p.questionFrontendId === id); diff --git a/src/api/queries.cn.ts b/src/api/queries.cn.ts index 990fada..450628a 100644 --- a/src/api/queries.cn.ts +++ b/src/api/queries.cn.ts @@ -1,8 +1,6 @@ // GraphQL queries for leetcode.cn (China schema) import { DAILY_CHALLENGE_QUERY as DAILY_CHALLENGE_QUERY_GLOBAL, - PROBLEM_DETAIL_QUERY, - PROBLEM_LIST_QUERY, RANDOM_PROBLEM_QUERY, SUBMISSION_DETAILS_QUERY, SUBMISSION_LIST_QUERY, @@ -10,6 +8,90 @@ import { type QueryPack, } from './queries.global.js'; +export const PROBLEM_LIST_QUERY_CN = ` + query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { + problemsetQuestionList( + categorySlug: $categorySlug + limit: $limit + skip: $skip + filters: $filters + ) { + total + questions { + frontendQuestionId + title + titleCn + titleSlug + difficulty + paidOnly + acRate + status + topicTags { + name + nameTranslated + id + slug + } + } + } + } +`; + +export const PROBLEM_DETAIL_QUERY_CN = ` + query questionData($titleSlug: String!) { + question(titleSlug: $titleSlug) { + questionId + questionFrontendId + boundTopicId + title + titleSlug + content + translatedTitle + translatedContent + difficulty + isPaidOnly + acRate + likes + dislikes + isLiked + similarQuestions + exampleTestcases + contributors { + username + profileUrl + avatarUrl + } + status + topicTags { + name + slug + translatedName + } + companyTagStats + codeSnippets { + lang + langSlug + code + } + stats + hints + solution { + id + canSeeDetail + } + sampleTestCase + metaData + judgerAvailable + judgeType + mysqlSchemas + enableRunCode + enableTestMode + libraryUrl + note + } + } +`; + export const DAILY_CHALLENGE_QUERY_CN = ` query questionOfToday { todayRecord { @@ -75,8 +157,8 @@ export const SKILL_STATS_QUERY_CN = ` `; export const CN_QUERY_PACK: QueryPack = { - PROBLEM_LIST_QUERY, - PROBLEM_DETAIL_QUERY, + PROBLEM_LIST_QUERY: PROBLEM_LIST_QUERY_CN, + PROBLEM_DETAIL_QUERY: PROBLEM_DETAIL_QUERY_CN, USER_STATUS_QUERY, USER_PROFILE_QUERY: USER_PROFILE_QUERY_CN, SKILL_STATS_QUERY: SKILL_STATS_QUERY_CN, diff --git a/src/schemas/api.ts b/src/schemas/api.ts index 46026c2..6aa2d46 100644 --- a/src/schemas/api.ts +++ b/src/schemas/api.ts @@ -86,6 +86,72 @@ export const CnDailyChallengeSchema = z.object({ .optional(), }); +export const CnProblemListSchema = z.object({ + problemsetQuestionList: z.object({ + total: z.number(), + questions: z.array( + z.object({ + frontendQuestionId: z.union([z.string(), z.number()]).optional(), + title: z.string().optional(), + titleCn: z.string().optional(), + titleSlug: z.string().optional(), + difficulty: z.string().optional(), + paidOnly: z.boolean().optional(), + acRate: z.union([z.number(), z.string()]).optional(), + status: z.string().nullable().optional(), + topicTags: z + .array( + z.object({ + name: z.string().optional(), + nameTranslated: z.string().optional(), + id: z.union([z.string(), z.number()]).optional(), + slug: z.string().optional(), + }) + ) + .optional(), + }) + ), + }), +}); + +export const CnProblemDetailSchema = z.object({ + question: z.object({ + questionId: z.union([z.string(), z.number()]).optional(), + questionFrontendId: z.union([z.string(), z.number()]).optional(), + title: z.string().optional(), + translatedTitle: z.string().optional(), + titleSlug: z.string().optional(), + translatedContent: z.string().nullable().optional(), + difficulty: z.string().optional(), + isPaidOnly: z.boolean().optional(), + acRate: z.union([z.number(), z.string()]).optional(), + status: z.string().nullable().optional(), + topicTags: z + .array( + z.object({ + name: z.string().optional(), + slug: z.string().optional(), + translatedName: z.string().optional(), + }) + ) + .optional(), + codeSnippets: z + .array( + z.object({ + lang: z.string(), + langSlug: z.string(), + code: z.string(), + }) + ) + .nullable() + .optional(), + sampleTestCase: z.string().optional(), + exampleTestcases: z.string().optional(), + hints: z.array(z.string()).optional(), + stats: z.string().optional(), + }), +}); + // --- Submission Schemas --- export const SubmissionSchema = z.object({ @@ -236,5 +302,7 @@ export type ValidatedSubmissionDetails = z.infer export type ValidatedTestResult = z.infer; export type ValidatedSubmissionResult = z.infer; export type ValidatedCnDailyChallenge = z.infer; +export type ValidatedCnProblemList = z.infer; +export type ValidatedCnProblemDetail = z.infer; export type ValidatedCnUserProfile = z.infer; export type ValidatedCnSkillStats = z.infer;