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
23 changes: 23 additions & 0 deletions cli/cmd_problem.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cli

import (
"github.com/spf13/cobra"
)

func (a *App) problemCmd() *cobra.Command {
return &cobra.Command{
Use: "problem <slug>",
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})
},
}
}
28 changes: 28 additions & 0 deletions cli/cmd_problems.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
170 changes: 170 additions & 0 deletions leetcode/leetcode_rest.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading