opencode-leetcode-realworld 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -2
- package/package.json +33 -4
- package/src/guard.ts +61 -0
- package/src/index.ts +173 -0
- package/src/languages.ts +358 -0
- package/src/leetcode.ts +257 -0
- package/src/patterns.ts +141 -0
- package/src/prompt.ts +111 -0
- package/src/scaffold.ts +310 -0
- package/src/types.ts +92 -0
package/src/languages.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { FileEntry, LanguageId } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface LanguageContext {
|
|
4
|
+
projectName: string;
|
|
5
|
+
functionName: string;
|
|
6
|
+
title: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LanguageAdapter {
|
|
10
|
+
id: LanguageId;
|
|
11
|
+
name: string;
|
|
12
|
+
aliases: string[];
|
|
13
|
+
extension: string;
|
|
14
|
+
/** Human hint shown in the generated README. */
|
|
15
|
+
runtime: string;
|
|
16
|
+
starter(ctx: LanguageContext): string;
|
|
17
|
+
/** Extra project files (build manifests etc). */
|
|
18
|
+
files(ctx: LanguageContext): FileEntry[];
|
|
19
|
+
/** Command run once before the cases, or null when not needed. */
|
|
20
|
+
build(ctx: LanguageContext): string | null;
|
|
21
|
+
/** Command that runs the solution once, reading one JSON value on stdin. */
|
|
22
|
+
run(ctx: LanguageContext): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function packageJson(projectName: string, script: string): FileEntry {
|
|
26
|
+
return {
|
|
27
|
+
relative: "package.json",
|
|
28
|
+
contents:
|
|
29
|
+
JSON.stringify(
|
|
30
|
+
{
|
|
31
|
+
name: projectName,
|
|
32
|
+
version: "0.1.0",
|
|
33
|
+
private: true,
|
|
34
|
+
type: "module",
|
|
35
|
+
scripts: { test: script, check: script },
|
|
36
|
+
},
|
|
37
|
+
null,
|
|
38
|
+
2,
|
|
39
|
+
) + "\n",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const adapters: Record<LanguageId, LanguageAdapter> = {
|
|
44
|
+
typescript: {
|
|
45
|
+
id: "typescript",
|
|
46
|
+
name: "TypeScript",
|
|
47
|
+
aliases: ["ts"],
|
|
48
|
+
extension: "ts",
|
|
49
|
+
runtime: "Node.js 22.6+ (uses `--experimental-strip-types`)",
|
|
50
|
+
starter: ({ functionName }) => `import { readFileSync } from "node:fs";
|
|
51
|
+
|
|
52
|
+
function ${functionName}(payload: unknown): unknown {
|
|
53
|
+
throw new Error("Not implemented: ${functionName}");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const input = readFileSync(0, "utf8");
|
|
57
|
+
const payload = JSON.parse(input.trim() === "" ? "null" : input);
|
|
58
|
+
process.stdout.write(JSON.stringify(${functionName}(payload) ?? null));
|
|
59
|
+
`,
|
|
60
|
+
files: (ctx) => [
|
|
61
|
+
packageJson(ctx.projectName, "node tests/runner.mjs"),
|
|
62
|
+
{
|
|
63
|
+
relative: "tsconfig.json",
|
|
64
|
+
contents:
|
|
65
|
+
JSON.stringify(
|
|
66
|
+
{
|
|
67
|
+
compilerOptions: {
|
|
68
|
+
target: "ES2022",
|
|
69
|
+
module: "ESNext",
|
|
70
|
+
moduleResolution: "bundler",
|
|
71
|
+
strict: true,
|
|
72
|
+
skipLibCheck: true,
|
|
73
|
+
types: ["node"],
|
|
74
|
+
noEmit: true,
|
|
75
|
+
},
|
|
76
|
+
include: ["src"],
|
|
77
|
+
},
|
|
78
|
+
null,
|
|
79
|
+
2,
|
|
80
|
+
) + "\n",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
build: () => null,
|
|
84
|
+
run: ({ functionName }) => `node --experimental-strip-types src/solution.ts`,
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
javascript: {
|
|
88
|
+
id: "javascript",
|
|
89
|
+
name: "JavaScript",
|
|
90
|
+
aliases: ["js", "node"],
|
|
91
|
+
extension: "mjs",
|
|
92
|
+
runtime: "Node.js 18+",
|
|
93
|
+
starter: ({ functionName }) => `function ${functionName}(payload) {
|
|
94
|
+
throw new Error("Not implemented: ${functionName}");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const chunks = [];
|
|
98
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
99
|
+
const input = Buffer.concat(chunks).toString("utf8");
|
|
100
|
+
const payload = JSON.parse(input.trim() === "" ? "null" : input);
|
|
101
|
+
process.stdout.write(JSON.stringify(${functionName}(payload) ?? null));
|
|
102
|
+
`,
|
|
103
|
+
files: (ctx) => [packageJson(ctx.projectName, "node tests/runner.mjs")],
|
|
104
|
+
build: () => null,
|
|
105
|
+
run: () => "node src/solution.mjs",
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
python: {
|
|
109
|
+
id: "python",
|
|
110
|
+
name: "Python",
|
|
111
|
+
aliases: ["py", "python3"],
|
|
112
|
+
extension: "py",
|
|
113
|
+
runtime: "Python 3.8+",
|
|
114
|
+
starter: ({ functionName }) => `import json
|
|
115
|
+
import sys
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ${functionName}(payload):
|
|
119
|
+
raise NotImplementedError("Not implemented: ${functionName}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main():
|
|
123
|
+
raw = sys.stdin.read().strip()
|
|
124
|
+
payload = json.loads(raw) if raw else None
|
|
125
|
+
print(json.dumps(${functionName}(payload)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
130
|
+
`,
|
|
131
|
+
files: () => [],
|
|
132
|
+
build: () => null,
|
|
133
|
+
run: () => "python3 src/solution.py",
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
ruby: {
|
|
137
|
+
id: "ruby",
|
|
138
|
+
name: "Ruby",
|
|
139
|
+
aliases: ["rb"],
|
|
140
|
+
extension: "rb",
|
|
141
|
+
runtime: "Ruby 2.6+ (json is in the standard library)",
|
|
142
|
+
starter: ({ functionName }) => `require "json"
|
|
143
|
+
|
|
144
|
+
def ${functionName}(payload)
|
|
145
|
+
raise NotImplementedError, "Not implemented: ${functionName}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raw = $stdin.read.strip
|
|
149
|
+
payload = raw.empty? ? nil : JSON.parse(raw)
|
|
150
|
+
puts JSON.generate(${functionName}(payload))
|
|
151
|
+
`,
|
|
152
|
+
files: () => [],
|
|
153
|
+
build: () => null,
|
|
154
|
+
run: () => "ruby src/solution.rb",
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
php: {
|
|
158
|
+
id: "php",
|
|
159
|
+
name: "PHP",
|
|
160
|
+
aliases: ["php8"],
|
|
161
|
+
extension: "php",
|
|
162
|
+
runtime: "PHP 7.4+",
|
|
163
|
+
starter: ({ functionName }) => `<?php
|
|
164
|
+
|
|
165
|
+
function ${functionName}($payload) {
|
|
166
|
+
throw new Exception("Not implemented: ${functionName}");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
$raw = trim(stream_get_contents(STDIN));
|
|
170
|
+
$payload = $raw === "" ? null : json_decode($raw, true);
|
|
171
|
+
echo json_encode(${functionName}($payload));
|
|
172
|
+
`,
|
|
173
|
+
files: () => [],
|
|
174
|
+
build: () => null,
|
|
175
|
+
run: () => "php src/solution.php",
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
go: {
|
|
179
|
+
id: "go",
|
|
180
|
+
name: "Go",
|
|
181
|
+
aliases: ["golang"],
|
|
182
|
+
extension: "go",
|
|
183
|
+
runtime: "Go 1.18+ (uses `any`)",
|
|
184
|
+
starter: ({ functionName }) => `package main
|
|
185
|
+
|
|
186
|
+
import (
|
|
187
|
+
"encoding/json"
|
|
188
|
+
"fmt"
|
|
189
|
+
"io"
|
|
190
|
+
"os"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
func ${functionName}(payload any) any {
|
|
194
|
+
panic("Not implemented: ${functionName}")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func main() {
|
|
198
|
+
raw, _ := io.ReadAll(os.Stdin)
|
|
199
|
+
var payload any
|
|
200
|
+
if len(raw) > 0 {
|
|
201
|
+
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
202
|
+
panic(err)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
out, err := json.Marshal(${functionName}(payload))
|
|
206
|
+
if err != nil {
|
|
207
|
+
panic(err)
|
|
208
|
+
}
|
|
209
|
+
fmt.Print(string(out))
|
|
210
|
+
}
|
|
211
|
+
`,
|
|
212
|
+
files: () => [
|
|
213
|
+
{ relative: "go.mod", contents: "module practice\n\ngo 1.21\n" },
|
|
214
|
+
],
|
|
215
|
+
build: () => "mkdir -p out && go build -o out/solution src/solution.go",
|
|
216
|
+
run: () => "./out/solution",
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
rust: {
|
|
220
|
+
id: "rust",
|
|
221
|
+
name: "Rust",
|
|
222
|
+
aliases: ["rs", "cargo"],
|
|
223
|
+
extension: "rs",
|
|
224
|
+
runtime: "Rust 1.70+ (uses serde_json)",
|
|
225
|
+
starter: ({ functionName }) => `use serde_json::Value;
|
|
226
|
+
use std::io::Read;
|
|
227
|
+
|
|
228
|
+
fn ${functionName}(payload: Value) -> Value {
|
|
229
|
+
let _ = payload;
|
|
230
|
+
panic!("Not implemented: ${functionName}");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn main() {
|
|
234
|
+
let mut raw = String::new();
|
|
235
|
+
std::io::stdin().read_to_string(&mut raw).expect("read stdin");
|
|
236
|
+
let trimmed = raw.trim();
|
|
237
|
+
let payload: Value = if trimmed.is_empty() {
|
|
238
|
+
Value::Null
|
|
239
|
+
} else {
|
|
240
|
+
serde_json::from_str(trimmed).expect("parse json")
|
|
241
|
+
};
|
|
242
|
+
let result = ${functionName}(payload);
|
|
243
|
+
println!("{}", serde_json::to_string(&result).expect("serialize json"));
|
|
244
|
+
}
|
|
245
|
+
`,
|
|
246
|
+
files: (ctx) => [
|
|
247
|
+
{
|
|
248
|
+
relative: "Cargo.toml",
|
|
249
|
+
contents: `[package]
|
|
250
|
+
name = "${ctx.projectName}"
|
|
251
|
+
version = "0.1.0"
|
|
252
|
+
edition = "2021"
|
|
253
|
+
|
|
254
|
+
[dependencies]
|
|
255
|
+
serde_json = "1"
|
|
256
|
+
|
|
257
|
+
[[bin]]
|
|
258
|
+
name = "solution"
|
|
259
|
+
path = "src/solution.rs"
|
|
260
|
+
`,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
build: () => "cargo build --quiet",
|
|
264
|
+
run: () => "./target/debug/solution",
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
csharp: {
|
|
268
|
+
id: "csharp",
|
|
269
|
+
name: "C#",
|
|
270
|
+
aliases: ["cs", "dotnet"],
|
|
271
|
+
extension: "cs",
|
|
272
|
+
runtime: ".NET 8 SDK",
|
|
273
|
+
starter: ({ functionName }) => `using System;
|
|
274
|
+
using System.Text.Json.Nodes;
|
|
275
|
+
|
|
276
|
+
class Program
|
|
277
|
+
{
|
|
278
|
+
static JsonNode? ${functionName}(JsonNode? payload)
|
|
279
|
+
{
|
|
280
|
+
throw new NotImplementedException("Not implemented: ${functionName}");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
static void Main()
|
|
284
|
+
{
|
|
285
|
+
var raw = Console.In.ReadToEnd().Trim();
|
|
286
|
+
var payload = raw.Length == 0 ? null : JsonNode.Parse(raw);
|
|
287
|
+
Console.Write(${functionName}(payload)?.ToJsonString() ?? "null");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
`,
|
|
291
|
+
files: (ctx) => [
|
|
292
|
+
{
|
|
293
|
+
relative: `${ctx.projectName}.csproj`,
|
|
294
|
+
contents: `<Project Sdk="Microsoft.NET.Sdk">
|
|
295
|
+
<PropertyGroup>
|
|
296
|
+
<OutputType>Exe</OutputType>
|
|
297
|
+
<TargetFramework>net8.0</TargetFramework>
|
|
298
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
299
|
+
<Nullable>enable</Nullable>
|
|
300
|
+
<AssemblyName>solution</AssemblyName>
|
|
301
|
+
</PropertyGroup>
|
|
302
|
+
</Project>
|
|
303
|
+
`,
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
build: () => "dotnet build -c Release -o out --nologo -v quiet",
|
|
307
|
+
run: () => "dotnet out/solution.dll",
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
swift: {
|
|
311
|
+
id: "swift",
|
|
312
|
+
name: "Swift",
|
|
313
|
+
aliases: [],
|
|
314
|
+
extension: "swift",
|
|
315
|
+
runtime: "Swift 5.5+ (Foundation JSONSerialization)",
|
|
316
|
+
starter: ({ functionName }) => `import Foundation
|
|
317
|
+
|
|
318
|
+
func ${functionName}(_ payload: Any) -> Any {
|
|
319
|
+
fatalError("Not implemented: ${functionName}")
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let raw = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
323
|
+
let data = raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
324
|
+
? Data("null".utf8)
|
|
325
|
+
: Data(raw.utf8)
|
|
326
|
+
let payload = try! JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
|
327
|
+
let result = ${functionName}(payload)
|
|
328
|
+
let out = try! JSONSerialization.data(withJSONObject: result, options: [.fragmentsAllowed])
|
|
329
|
+
FileHandle.standardOutput.write(out)
|
|
330
|
+
`,
|
|
331
|
+
files: () => [],
|
|
332
|
+
build: () => "mkdir -p out && swiftc -O src/solution.swift -o out/solution",
|
|
333
|
+
run: () => "./out/solution",
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export function getLanguage(id: LanguageId): LanguageAdapter {
|
|
338
|
+
return adapters[id];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function languageIds(): LanguageId[] {
|
|
342
|
+
return Object.keys(adapters) as LanguageId[];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function languageNames(): string {
|
|
346
|
+
return languageIds()
|
|
347
|
+
.map((id) => adapters[id].name)
|
|
348
|
+
.join(", ");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function normalizeLanguage(input: string | undefined): LanguageId | undefined {
|
|
352
|
+
if (!input) return undefined;
|
|
353
|
+
const key = input.trim().toLowerCase();
|
|
354
|
+
for (const adapter of Object.values(adapters)) {
|
|
355
|
+
if (adapter.id === key || adapter.aliases.includes(key)) return adapter.id;
|
|
356
|
+
}
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
package/src/leetcode.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { CodeSnippet, NormalizedProblem } from "./types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = "https://leetcode-api-pied.vercel.app";
|
|
4
|
+
|
|
5
|
+
export function apiBaseUrl(): string {
|
|
6
|
+
return (process.env.LEETCODE_API_BASE ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FetchOptions {
|
|
10
|
+
/** How to choose the problem. Defaults to `random`. */
|
|
11
|
+
mode?: "random" | "specific" | "daily" | "filter";
|
|
12
|
+
/** Required when `mode` is `specific`. Accepts an id ("1") or a slug ("two-sum"). */
|
|
13
|
+
idOrSlug?: string;
|
|
14
|
+
difficulty?: "Easy" | "Medium" | "Hard";
|
|
15
|
+
tags?: string[];
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
20
|
+
const url = `${apiBaseUrl()}${path}`;
|
|
21
|
+
let res: Response;
|
|
22
|
+
try {
|
|
23
|
+
res = await fetch(url, {
|
|
24
|
+
signal,
|
|
25
|
+
headers: { accept: "application/json", "user-agent": "opencode-leetcode-realworld" },
|
|
26
|
+
});
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw new Error(`Failed to reach LeetCode API at ${url}: ${(error as Error).message}`);
|
|
29
|
+
}
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new Error(`LeetCode API responded ${res.status} ${res.statusText} for ${url}`);
|
|
32
|
+
}
|
|
33
|
+
return (await res.json()) as T;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function htmlToText(html: string): string {
|
|
37
|
+
if (!html) return "";
|
|
38
|
+
return html
|
|
39
|
+
.replace(/<sup>\s*([^<]*)\s*<\/sup>/gi, "^$1")
|
|
40
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
41
|
+
.replace(/<\/(p|div|pre|li|ul|ol|h[1-6])>/gi, "\n")
|
|
42
|
+
.replace(/<[^>]+>/g, "")
|
|
43
|
+
.replace(/ /g, " ")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/'/g, "'")
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
50
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeSnippets(raw: unknown): CodeSnippet[] {
|
|
55
|
+
if (!Array.isArray(raw)) return [];
|
|
56
|
+
return raw
|
|
57
|
+
.map((entry) => {
|
|
58
|
+
const record = entry as Record<string, unknown>;
|
|
59
|
+
return {
|
|
60
|
+
lang: String(record.lang ?? record.langSlug ?? ""),
|
|
61
|
+
langSlug: String(record.langSlug ?? record.langsSlug ?? record.lang ?? ""),
|
|
62
|
+
code: String(record.code ?? ""),
|
|
63
|
+
};
|
|
64
|
+
})
|
|
65
|
+
.filter((snippet) => snippet.code.length > 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeTopics(raw: unknown): string[] {
|
|
69
|
+
if (!Array.isArray(raw)) return [];
|
|
70
|
+
return raw
|
|
71
|
+
.map((entry) => {
|
|
72
|
+
if (typeof entry === "string") return entry;
|
|
73
|
+
const record = entry as Record<string, unknown>;
|
|
74
|
+
return String(record.name ?? record.slug ?? "");
|
|
75
|
+
})
|
|
76
|
+
.filter((name) => name.length > 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractAcRate(raw: Record<string, unknown>): number | undefined {
|
|
80
|
+
if (typeof raw.acRate === "number") return raw.acRate;
|
|
81
|
+
if (typeof raw.stats === "string") {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(raw.stats) as { acRate?: string | number };
|
|
84
|
+
if (parsed.acRate !== undefined) return Number.parseFloat(String(parsed.acRate));
|
|
85
|
+
} catch {
|
|
86
|
+
/* ignore malformed stats */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function normalizeProblem(
|
|
93
|
+
raw: Record<string, unknown>,
|
|
94
|
+
fallbackSlug?: string,
|
|
95
|
+
): NormalizedProblem {
|
|
96
|
+
const nested = (raw.question ?? {}) as Record<string, unknown>;
|
|
97
|
+
const contentHtml = String(raw.content ?? nested.content ?? "");
|
|
98
|
+
const slug = String(
|
|
99
|
+
raw.titleSlug ?? raw.title_slug ?? raw.slug ?? nested.titleSlug ?? fallbackSlug ?? "",
|
|
100
|
+
);
|
|
101
|
+
const acRate = extractAcRate(raw);
|
|
102
|
+
return {
|
|
103
|
+
id: String(raw.questionId ?? nested.questionId ?? raw.id ?? ""),
|
|
104
|
+
frontendId: String(
|
|
105
|
+
raw.questionFrontendId ?? nested.questionFrontendId ?? raw.frontend_id ?? "",
|
|
106
|
+
),
|
|
107
|
+
slug,
|
|
108
|
+
title: String(raw.title ?? nested.title ?? ""),
|
|
109
|
+
difficulty: String(raw.difficulty ?? nested.difficulty ?? "Unknown"),
|
|
110
|
+
url: String(raw.url ?? (slug ? `https://leetcode.com/problems/${slug}/` : "")),
|
|
111
|
+
topics: normalizeTopics(raw.topicTags ?? nested.topicTags),
|
|
112
|
+
contentHtml,
|
|
113
|
+
contentText: htmlToText(contentHtml),
|
|
114
|
+
hints: Array.isArray(raw.hints)
|
|
115
|
+
? raw.hints.map((hint) => htmlToText(String(hint)))
|
|
116
|
+
: [],
|
|
117
|
+
snippets: normalizeSnippets(raw.codeSnippets ?? nested.codeSnippets),
|
|
118
|
+
acRate,
|
|
119
|
+
paidOnly: Boolean(raw.isPaidOnly ?? raw.paid_only ?? nested.isPaidOnly ?? false),
|
|
120
|
+
date: typeof raw.date === "string" ? raw.date : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function preferredSlug(idOrSlug: string | undefined): string | undefined {
|
|
125
|
+
if (!idOrSlug) return undefined;
|
|
126
|
+
return /^\d+$/.test(idOrSlug.trim()) ? undefined : idOrSlug.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A problem is usable for practice generation only if it is free and actually
|
|
131
|
+
* carries a statement. The API returns `content: null` (with `isPaidOnly: true`)
|
|
132
|
+
* for premium problems.
|
|
133
|
+
*/
|
|
134
|
+
export function isUsableProblem(problem: NormalizedProblem): boolean {
|
|
135
|
+
return !problem.paidOnly && problem.contentText.trim().length > 40;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function pickUsable(
|
|
139
|
+
make: () => Promise<NormalizedProblem>,
|
|
140
|
+
attempts = 6,
|
|
141
|
+
): Promise<NormalizedProblem> {
|
|
142
|
+
let last: NormalizedProblem | undefined;
|
|
143
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
144
|
+
const candidate = await make();
|
|
145
|
+
last = candidate;
|
|
146
|
+
if (isUsableProblem(candidate)) return candidate;
|
|
147
|
+
}
|
|
148
|
+
if (!last) throw new Error("LeetCode API returned no problems.");
|
|
149
|
+
return last;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function getProblem(idOrSlug: string, signal?: AbortSignal): Promise<NormalizedProblem> {
|
|
153
|
+
const raw = await getJson<Record<string, unknown>>(
|
|
154
|
+
`/problem/${encodeURIComponent(idOrSlug)}`,
|
|
155
|
+
signal,
|
|
156
|
+
);
|
|
157
|
+
return normalizeProblem(raw, preferredSlug(idOrSlug));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
interface RandomProblem {
|
|
161
|
+
title_slug?: string;
|
|
162
|
+
titleSlug?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function getRandom(
|
|
166
|
+
opts: { difficulty?: string; tags?: string[]; signal?: AbortSignal } = {},
|
|
167
|
+
): Promise<NormalizedProblem> {
|
|
168
|
+
const params = new URLSearchParams();
|
|
169
|
+
if (opts.difficulty) params.set("difficulty", opts.difficulty);
|
|
170
|
+
if (opts.tags?.length) params.set("tags", opts.tags.join(","));
|
|
171
|
+
const query = params.toString();
|
|
172
|
+
const raw = await getJson<RandomProblem>(`/random${query ? `?${query}` : ""}`, opts.signal);
|
|
173
|
+
const slug = raw.title_slug ?? raw.titleSlug;
|
|
174
|
+
if (!slug) throw new Error("LeetCode API /random did not return a problem slug.");
|
|
175
|
+
return getProblem(slug, opts.signal);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function getDaily(signal?: AbortSignal): Promise<NormalizedProblem> {
|
|
179
|
+
const raw = await getJson<Record<string, unknown>>("/daily", signal);
|
|
180
|
+
return normalizeProblem(raw);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface FilterResponse {
|
|
184
|
+
problems?: Array<Record<string, unknown>>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function getFiltered(
|
|
188
|
+
opts: { difficulty?: string; tags?: string[]; limit?: number; signal?: AbortSignal } = {},
|
|
189
|
+
): Promise<NormalizedProblem> {
|
|
190
|
+
const params = new URLSearchParams();
|
|
191
|
+
if (opts.difficulty) params.set("difficulty", opts.difficulty);
|
|
192
|
+
if (opts.tags?.length) params.set("tags", opts.tags.join(","));
|
|
193
|
+
params.set("limit", String(opts.limit ?? 20));
|
|
194
|
+
const raw = await getJson<FilterResponse>(`/problems/filter?${params.toString()}`, opts.signal);
|
|
195
|
+
const candidates = raw.problems ?? [];
|
|
196
|
+
if (candidates.length === 0) throw new Error("LeetCode API returned no problems for the given filter.");
|
|
197
|
+
const choice = candidates[Math.floor(Math.random() * candidates.length)] as Record<string, unknown>;
|
|
198
|
+
const slug = String(choice.title_slug ?? choice.titleSlug ?? "");
|
|
199
|
+
if (!slug) throw new Error("Filtered problem is missing a slug.");
|
|
200
|
+
return getProblem(slug, opts.signal);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function fetchProblem(options: FetchOptions = {}): Promise<NormalizedProblem> {
|
|
204
|
+
const mode = options.mode ?? "random";
|
|
205
|
+
switch (mode) {
|
|
206
|
+
case "specific": {
|
|
207
|
+
if (!options.idOrSlug) throw new Error("`idOrSlug` is required when mode is `specific`.");
|
|
208
|
+
return getProblem(options.idOrSlug, options.signal);
|
|
209
|
+
}
|
|
210
|
+
case "daily": {
|
|
211
|
+
const daily = await getDaily(options.signal);
|
|
212
|
+
if (isUsableProblem(daily)) return daily;
|
|
213
|
+
return pickUsable(() =>
|
|
214
|
+
getRandom({ difficulty: options.difficulty, tags: options.tags, signal: options.signal }),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
case "filter":
|
|
218
|
+
return pickUsable(() =>
|
|
219
|
+
getFiltered({
|
|
220
|
+
difficulty: options.difficulty,
|
|
221
|
+
tags: options.tags,
|
|
222
|
+
signal: options.signal,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
case "random":
|
|
226
|
+
default:
|
|
227
|
+
return pickUsable(() =>
|
|
228
|
+
getRandom({ difficulty: options.difficulty, tags: options.tags, signal: options.signal }),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface TagInfo {
|
|
234
|
+
name: string;
|
|
235
|
+
slug: string;
|
|
236
|
+
problem_count: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function getTags(signal?: AbortSignal): Promise<TagInfo[]> {
|
|
240
|
+
return getJson<TagInfo[]>("/tags", signal);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Render a compact, agent-friendly view of the problem. Never write this to disk. */
|
|
244
|
+
export function renderProblemForAgent(problem: NormalizedProblem): string {
|
|
245
|
+
const lines: string[] = [];
|
|
246
|
+
lines.push(`# ${problem.title} (${problem.difficulty})`);
|
|
247
|
+
if (problem.topics.length) lines.push(`Topics: ${problem.topics.join(", ")}`);
|
|
248
|
+
if (problem.acRate !== undefined) lines.push(`Acceptance: ${problem.acRate}%`);
|
|
249
|
+
lines.push("");
|
|
250
|
+
lines.push(problem.contentText || "(no statement returned)");
|
|
251
|
+
if (problem.hints.length) {
|
|
252
|
+
lines.push("");
|
|
253
|
+
lines.push("## Hints (do not reveal verbatim)");
|
|
254
|
+
for (const hint of problem.hints) lines.push(`- ${hint}`);
|
|
255
|
+
}
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|