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/__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/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;
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' });