From 2be3135f4b8102e8687c173398f4f6742a84cd0c Mon Sep 17 00:00:00 2001 From: tamnd <1218621+tamnd@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:52:52 +0700 Subject: [PATCH] feat: add REST-based problems command and problem alias Add `leetcode problems` backed by GET /api/problems/all/ returning int IDs plus submission stats (submitted, accepted) and paid_only flag. Problems are sorted newest-first by ID; difficulty filter is case-insensitive client-side. Add `leetcode problem ` as a cleaner alias for `question` (existing `list` and `question` commands unchanged). New types: RestProblem in leetcode_rest.go. New tests: TestProblemsSendsUserAgent, TestProblemsParsesItems, TestProblemsFilterByDifficulty, TestProblemsLimit, TestProblemsRESTRetriesOn503 in rest_test.go. --- cli/cmd_problem.go | 23 +++++ cli/cmd_problems.go | 28 ++++++ cli/root.go | 2 + leetcode/leetcode_rest.go | 170 +++++++++++++++++++++++++++++++++++ leetcode/rest_test.go | 183 ++++++++++++++++++++++++++++++++++++++ leetcode/types.go | 4 +- 6 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 cli/cmd_problem.go create mode 100644 cli/cmd_problems.go create mode 100644 leetcode/leetcode_rest.go create mode 100644 leetcode/rest_test.go diff --git a/cli/cmd_problem.go b/cli/cmd_problem.go new file mode 100644 index 0000000..dd74509 --- /dev/null +++ b/cli/cmd_problem.go @@ -0,0 +1,23 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func (a *App) problemCmd() *cobra.Command { + return &cobra.Command{ + Use: "problem ", + Short: "Show full details of a LeetCode problem by its slug", + Long: `Show full details of a LeetCode problem by its title slug (e.g. "two-sum").`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + slug := args[0] + a.progressf("fetching problem %q...", slug) + q, err := a.client.Question(cmd.Context(), slug) + if err != nil { + return mapFetchErr(err) + } + return a.render([]any{q}) + }, + } +} diff --git a/cli/cmd_problems.go b/cli/cmd_problems.go new file mode 100644 index 0000000..1372cfa --- /dev/null +++ b/cli/cmd_problems.go @@ -0,0 +1,28 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func (a *App) problemsCmd() *cobra.Command { + var difficulty string + + cmd := &cobra.Command{ + Use: "problems", + Short: "List all LeetCode problems via the REST API", + Long: `List LeetCode problems fetched from the REST endpoint /api/problems/all/. +Includes submission stats and a paid-only flag. Filter by difficulty with --difficulty.`, + RunE: func(cmd *cobra.Command, _ []string) error { + n := a.effectiveLimit(0) + a.progressf("fetching problems (difficulty=%q, limit=%d)...", difficulty, n) + problems, err := a.client.ProblemsREST(cmd.Context(), difficulty, n) + if err != nil { + return mapFetchErr(err) + } + return a.renderOrEmpty(problems, len(problems)) + }, + } + + cmd.Flags().StringVar(&difficulty, "difficulty", "", "filter by difficulty: easy, medium, or hard (case-insensitive)") + return cmd +} diff --git a/cli/root.go b/cli/root.go index 77f97a6..fd9a6b7 100644 --- a/cli/root.go +++ b/cli/root.go @@ -88,6 +88,8 @@ leetcode is an independent tool and is not affiliated with LeetCode.`, root.AddCommand( app.listCmd(), + app.problemsCmd(), + app.problemCmd(), app.dailyCmd(), app.questionCmd(), newVersionCmd(), diff --git a/leetcode/leetcode_rest.go b/leetcode/leetcode_rest.go new file mode 100644 index 0000000..0233b0b --- /dev/null +++ b/leetcode/leetcode_rest.go @@ -0,0 +1,170 @@ +package leetcode + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +// RestProblem is emitted by the problems command (REST /api/problems/all/). +// It carries submission stats and a paid_only flag not available via GraphQL. +type RestProblem struct { + Rank int `json:"rank"` + ID int `json:"id"` // frontend_question_id + Slug string `json:"slug"` // question__title_slug + Title string `json:"title"` + Difficulty string `json:"difficulty"` // "Easy", "Medium", "Hard" + PaidOnly bool `json:"paid_only"` + Submitted int `json:"submitted"` // total_submitted + Accepted int `json:"accepted"` // total_acs + URL string `json:"url"` // https://leetcode.com/problems/{slug}/ +} + +// ─── REST wire types ────────────────────────────────────────────────────────── + +type restProblemsResponse struct { + UserName string `json:"user_name"` + NumTotal int `json:"num_total"` + Pairs []restStatPair `json:"stat_status_pairs"` +} + +type restStatPair struct { + Stat restStat `json:"stat"` + Difficulty restDifficulty `json:"difficulty"` + PaidOnly bool `json:"paid_only"` +} + +type restStat struct { + FrontendID int `json:"frontend_question_id"` + Title string `json:"question__title"` + Slug string `json:"question__title_slug"` + TotalSubmitted int `json:"total_submitted"` + TotalACS int `json:"total_acs"` +} + +type restDifficulty struct { + Level int `json:"level"` +} + +// ─── helpers ────────────────────────────────────────────────────────────────── + +func difficultyFromLevel(level int) string { + switch level { + case 1: + return "Easy" + case 2: + return "Medium" + case 3: + return "Hard" + default: + return "Unknown" + } +} + +// ─── REST GET transport ─────────────────────────────────────────────────────── + +func (c *Client) get(ctx context.Context, url string) ([]byte, error) { + var lastErr error + for attempt := 0; attempt <= c.cfg.Retries; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff(attempt)): + } + } + b, retry, err := c.doGet(ctx, url) + if err == nil { + return b, nil + } + lastErr = err + if !retry { + return nil, err + } + } + return nil, fmt.Errorf("get %s: %w", url, lastErr) +} + +func (c *Client) doGet(ctx context.Context, rawURL string) ([]byte, bool, error) { + c.pace() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, false, err + } + req.Header.Set("User-Agent", c.cfg.UserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, true, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + return nil, true, fmt.Errorf("http %d", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return nil, false, fmt.Errorf("http %d", resp.StatusCode) + } + + b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return nil, true, err + } + return b, false, nil +} + +// ─── ProblemsREST ───────────────────────────────────────────────────────────── + +// ProblemsREST fetches all problems from the REST endpoint /api/problems/all/. +// difficulty is case-insensitive ("easy", "medium", "hard", or "" for all). +// limit 0 returns all matching problems. +func (c *Client) ProblemsREST(ctx context.Context, difficulty string, limit int) ([]RestProblem, error) { + url := c.cfg.BaseURL + "/api/problems/all/" + body, err := c.get(ctx, url) + if err != nil { + return nil, err + } + + var resp restProblemsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode problems: %w", err) + } + + out := make([]RestProblem, 0, len(resp.Pairs)) + for _, pair := range resp.Pairs { + diff := difficultyFromLevel(pair.Difficulty.Level) + if difficulty != "" && !strings.EqualFold(diff, difficulty) { + continue + } + out = append(out, RestProblem{ + ID: pair.Stat.FrontendID, + Slug: pair.Stat.Slug, + Title: pair.Stat.Title, + Difficulty: diff, + PaidOnly: pair.PaidOnly, + Submitted: pair.Stat.TotalSubmitted, + Accepted: pair.Stat.TotalACS, + URL: "https://leetcode.com/problems/" + pair.Stat.Slug + "/", + }) + } + + // Sort descending by ID (newest first). + sort.Slice(out, func(i, j int) bool { + return out[i].ID > out[j].ID + }) + + // Assign rank after sort. + for i := range out { + out[i].Rank = i + 1 + } + + if limit > 0 && limit < len(out) { + out = out[:limit] + } + return out, nil +} diff --git a/leetcode/rest_test.go b/leetcode/rest_test.go new file mode 100644 index 0000000..9975d5b --- /dev/null +++ b/leetcode/rest_test.go @@ -0,0 +1,183 @@ +package leetcode_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/tamnd/leetcode-cli/leetcode" +) + +const mockRESTProblemsResponse = `{ + "user_name": "", + "num_solved": 0, + "num_total": 3, + "stat_status_pairs": [ + { + "stat": { + "frontend_question_id": 3, + "question__title": "Longest Substring Without Repeating Characters", + "question__title_slug": "longest-substring-without-repeating-characters", + "total_submitted": 10000, + "total_acs": 5000 + }, + "difficulty": {"level": 2}, + "paid_only": false + }, + { + "stat": { + "frontend_question_id": 2, + "question__title": "Add Two Numbers", + "question__title_slug": "add-two-numbers", + "total_submitted": 8000, + "total_acs": 3000 + }, + "difficulty": {"level": 2}, + "paid_only": false + }, + { + "stat": { + "frontend_question_id": 1, + "question__title": "Two Sum", + "question__title_slug": "two-sum", + "total_submitted": 20000, + "total_acs": 10000 + }, + "difficulty": {"level": 1}, + "paid_only": false + } + ] +}` + +func TestProblemsSendsUserAgent(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") == "" { + t.Error("request carried no User-Agent") + } + _, _ = w.Write([]byte(mockRESTProblemsResponse)) + })) + defer ts.Close() + + c := newTestClient(ts) + _, err := c.ProblemsREST(context.Background(), "", 0) + if err != nil { + t.Fatal(err) + } +} + +func TestProblemsParsesItems(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(mockRESTProblemsResponse)) + })) + defer ts.Close() + + c := newTestClient(ts) + problems, err := c.ProblemsREST(context.Background(), "", 0) + if err != nil { + t.Fatal(err) + } + if len(problems) != 3 { + t.Fatalf("got %d problems, want 3", len(problems)) + } + + // Sorted descending by ID: 3, 2, 1. + p := problems[0] + if p.ID != 3 { + t.Errorf("first ID = %d, want 3 (newest first)", p.ID) + } + if p.Rank != 1 { + t.Errorf("first rank = %d, want 1", p.Rank) + } + if p.Difficulty != "Medium" { + t.Errorf("difficulty = %q, want Medium (level 2)", p.Difficulty) + } + if p.Submitted != 10000 { + t.Errorf("submitted = %d, want 10000", p.Submitted) + } + if p.Accepted != 5000 { + t.Errorf("accepted = %d, want 5000", p.Accepted) + } + if p.URL != "https://leetcode.com/problems/longest-substring-without-repeating-characters/" { + t.Errorf("url = %q", p.URL) + } + + // Last problem should be ID 1, difficulty Easy. + last := problems[2] + if last.ID != 1 { + t.Errorf("last ID = %d, want 1", last.ID) + } + if last.Difficulty != "Easy" { + t.Errorf("last difficulty = %q, want Easy (level 1)", last.Difficulty) + } +} + +func TestProblemsFilterByDifficulty(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(mockRESTProblemsResponse)) + })) + defer ts.Close() + + c := newTestClient(ts) + // "medium" lowercase → should match difficulty "Medium" (case-insensitive). + problems, err := c.ProblemsREST(context.Background(), "medium", 0) + if err != nil { + t.Fatal(err) + } + if len(problems) != 2 { + t.Fatalf("got %d problems, want 2 (Medium only)", len(problems)) + } + for _, p := range problems { + if p.Difficulty != "Medium" { + t.Errorf("got difficulty %q, want Medium", p.Difficulty) + } + } +} + +func TestProblemsLimit(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(mockRESTProblemsResponse)) + })) + defer ts.Close() + + c := newTestClient(ts) + problems, err := c.ProblemsREST(context.Background(), "", 2) + if err != nil { + t.Fatal(err) + } + if len(problems) != 2 { + t.Fatalf("got %d problems, want 2 (limit applied)", len(problems)) + } +} + +func TestProblemsRESTRetriesOn503(t *testing.T) { + var hits int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + if hits < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte(mockRESTProblemsResponse)) + })) + defer ts.Close() + + cfg := leetcode.DefaultConfig() + cfg.BaseURL = ts.URL + cfg.Rate = 0 + cfg.Retries = 5 + c := leetcode.NewClient(cfg) + + start := time.Now() + _, err := c.ProblemsREST(context.Background(), "", 0) + if err != nil { + t.Fatal(err) + } + if hits != 3 { + t.Errorf("server saw %d hits, want 3", hits) + } + if time.Since(start) < 500*time.Millisecond { + t.Error("retries did not back off") + } +} diff --git a/leetcode/types.go b/leetcode/types.go index 538f9c9..b5d99d6 100644 --- a/leetcode/types.go +++ b/leetcode/types.go @@ -73,8 +73,8 @@ type graphqlErrors []struct { type problemListResponse struct { Data struct { ProblemsetQuestionList struct { - Total int `json:"total"` - Questions []lcProblemWire `json:"questions"` + Total int `json:"total"` + Questions []lcProblemWire `json:"questions"` } `json:"problemsetQuestionList"` } `json:"data"` Errors graphqlErrors `json:"errors"`