@rhseung/ps-cli 1.8.0 → 1.9.1

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.
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getProblem,
4
+ scrapeProblem
5
+ } from "./chunk-LR64BN3N.js";
6
+ import {
7
+ Command,
8
+ CommandBuilder,
9
+ CommandDef,
10
+ __decorateClass,
11
+ defineFlags,
12
+ getAutoOpenEditor,
13
+ getEditor,
14
+ getIncludeTag,
15
+ getLanguageConfig,
16
+ getSolvingDirPath,
17
+ getSupportedLanguages,
18
+ getSupportedLanguagesString,
19
+ getTierColor,
20
+ getTierImageUrl,
21
+ getTierName,
22
+ logger,
23
+ parseTimeLimitToMs,
24
+ resolveProblemContext
25
+ } from "./chunk-Q5NECGFA.js";
26
+
27
+ // src/commands/fetch.tsx
28
+ import { StatusMessage, Alert, Spinner } from "@inkjs/ui";
29
+ import { Box as Box2 } from "ink";
30
+
31
+ // src/components/problem-dashboard.tsx
32
+ import { Box, Text } from "ink";
33
+ import { jsx, jsxs } from "react/jsx-runtime";
34
+ function ProblemDashboard({ problem }) {
35
+ const tierName = getTierName(problem.level);
36
+ const tierColor = getTierColor(problem.level);
37
+ const borderColorString = typeof tierColor === "string" ? tierColor : "#ff7ca8";
38
+ const textColorString = borderColorString;
39
+ return /* @__PURE__ */ jsx(
40
+ Box,
41
+ {
42
+ flexDirection: "column",
43
+ borderStyle: "round",
44
+ borderColor: borderColorString,
45
+ paddingX: 1,
46
+ alignSelf: "flex-start",
47
+ children: /* @__PURE__ */ jsxs(Text, { bold: true, color: textColorString, children: [
48
+ tierName,
49
+ " ",
50
+ /* @__PURE__ */ jsxs(Text, { color: "white", children: [
51
+ "#",
52
+ problem.id,
53
+ ": ",
54
+ problem.title
55
+ ] })
56
+ ] })
57
+ }
58
+ );
59
+ }
60
+
61
+ // src/hooks/use-fetch-problem.ts
62
+ import { execaCommand } from "execa";
63
+ import { useEffect, useState } from "react";
64
+
65
+ // src/services/file-generator.ts
66
+ import { existsSync } from "fs";
67
+ import { mkdir, writeFile, readFile } from "fs/promises";
68
+ import { join, dirname } from "path";
69
+ import { fileURLToPath } from "url";
70
+ function ensureTrailingNewline(content) {
71
+ if (!content || content.trim().length === 0) {
72
+ return content;
73
+ }
74
+ const trimmed = content.trimEnd();
75
+ if (trimmed.length === 0) {
76
+ return content;
77
+ }
78
+ const lines = trimmed.split("\n");
79
+ const lastLine = lines[lines.length - 1];
80
+ const isListItem = /^[\s]*[-*]\s/.test(lastLine) || /^[\s]*\d+[.)]\s/.test(lastLine);
81
+ const isTableRow = /^\s*\|.+\|\s*$/.test(lastLine);
82
+ const isCodeBlock = trimmed.endsWith("```");
83
+ const isImage = /!\[.*\]\(.*\)$/.test(trimmed);
84
+ if (isListItem || isTableRow || isCodeBlock || isImage) {
85
+ return trimmed + "\n\n";
86
+ }
87
+ if (!content.endsWith("\n")) {
88
+ return content + "\n\n";
89
+ }
90
+ if (!content.endsWith("\n\n")) {
91
+ return content + "\n";
92
+ }
93
+ return content;
94
+ }
95
+ function getProjectRoot() {
96
+ const __filename = fileURLToPath(import.meta.url);
97
+ const __dirname = dirname(__filename);
98
+ let current = __dirname;
99
+ while (current !== dirname(current)) {
100
+ if (existsSync(join(current, "templates"))) {
101
+ return current;
102
+ }
103
+ current = dirname(current);
104
+ }
105
+ return join(__dirname, "../..");
106
+ }
107
+ async function generateProblemFiles(problem, language = "python") {
108
+ const problemDir = getSolvingDirPath(problem.id, process.cwd(), problem);
109
+ await mkdir(problemDir, { recursive: true });
110
+ const langConfig = getLanguageConfig(language);
111
+ const projectRoot = getProjectRoot();
112
+ const templatePath = join(projectRoot, "templates", langConfig.templateFile);
113
+ const solutionPath = join(problemDir, `solution.${langConfig.extension}`);
114
+ try {
115
+ const templateContent = await readFile(templatePath, "utf-8");
116
+ await writeFile(solutionPath, templateContent, "utf-8");
117
+ } catch {
118
+ await writeFile(
119
+ solutionPath,
120
+ `// Problem ${problem.id}: ${problem.title}
121
+ `,
122
+ "utf-8"
123
+ );
124
+ }
125
+ const testcasesDir = join(problemDir, "testcases");
126
+ for (let i = 0; i < problem.testCases.length; i++) {
127
+ const testCase = problem.testCases[i];
128
+ const caseDir = join(testcasesDir, String(i + 1));
129
+ await mkdir(caseDir, { recursive: true });
130
+ const inputPath = join(caseDir, "input.txt");
131
+ const outputPath = join(caseDir, "output.txt");
132
+ await writeFile(inputPath, testCase.input, "utf-8");
133
+ await writeFile(outputPath, testCase.output, "utf-8");
134
+ }
135
+ const tierName = getTierName(problem.level);
136
+ const tierImageUrl = getTierImageUrl(problem.level);
137
+ const tags = problem.tags.length > 0 ? problem.tags.join(", ") : "\uC5C6\uC74C";
138
+ const includeTag = getIncludeTag();
139
+ const headers = [];
140
+ const values = [];
141
+ headers.push("\uB09C\uC774\uB3C4");
142
+ values.push(`<img src="${tierImageUrl}" alt="${tierName}" width="20" />`);
143
+ if (problem.timeLimit) {
144
+ headers.push("\uC2DC\uAC04 \uC81C\uD55C");
145
+ values.push(problem.timeLimit);
146
+ }
147
+ if (problem.memoryLimit) {
148
+ headers.push("\uBA54\uBAA8\uB9AC \uC81C\uD55C");
149
+ values.push(problem.memoryLimit);
150
+ }
151
+ if (problem.submissions) {
152
+ headers.push("\uC81C\uCD9C");
153
+ values.push(problem.submissions);
154
+ }
155
+ if (problem.accepted) {
156
+ headers.push("\uC815\uB2F5");
157
+ values.push(problem.accepted);
158
+ }
159
+ if (problem.acceptedUsers) {
160
+ headers.push("\uB9DE\uD78C \uC0AC\uB78C");
161
+ values.push(problem.acceptedUsers);
162
+ }
163
+ if (problem.acceptedRate) {
164
+ headers.push("\uC815\uB2F5 \uBE44\uC728");
165
+ values.push(problem.acceptedRate);
166
+ }
167
+ let infoTable = "";
168
+ if (headers.length > 0) {
169
+ const headerRow = `| ${headers.join(" | ")} |`;
170
+ const separatorRow = `|${headers.map(() => "---").join("|")}|`;
171
+ const valueRow = `| ${values.join(" | ")} |`;
172
+ infoTable = `
173
+ ${headerRow}
174
+ ${separatorRow}
175
+ ${valueRow}
176
+ `;
177
+ }
178
+ const description = ensureTrailingNewline(problem.description || "\uC124\uBA85 \uC5C6\uC74C");
179
+ const inputFormat = ensureTrailingNewline(
180
+ problem.inputFormat || "\uC785\uB825 \uD615\uC2DD \uC5C6\uC74C"
181
+ );
182
+ const outputFormat = ensureTrailingNewline(
183
+ problem.outputFormat || "\uCD9C\uB825 \uD615\uC2DD \uC5C6\uC74C"
184
+ );
185
+ const readmeContent = `
186
+ # [${problem.id}: ${problem.title}](https://www.acmicpc.net/problem/${problem.id})
187
+
188
+ ${infoTable.trim()}
189
+
190
+ ## \uBB38\uC81C \uC124\uBA85
191
+
192
+ ${description.trim()}
193
+
194
+ ## \uC785\uB825
195
+
196
+ ${inputFormat.trim()}
197
+
198
+ ## \uCD9C\uB825
199
+
200
+ ${outputFormat.trim()}
201
+
202
+ ## \uC608\uC81C
203
+
204
+ ${problem.testCases.map(
205
+ (tc, i) => `
206
+ ### \uC608\uC81C ${i + 1}
207
+
208
+ **\uC785\uB825:**
209
+
210
+ \`\`\`text
211
+ ${tc.input.trim()}
212
+ \`\`\`
213
+
214
+ **\uCD9C\uB825:**
215
+
216
+ \`\`\`text
217
+ ${tc.output.trim()}
218
+ \`\`\`
219
+ `.trim()
220
+ ).join("\n\n")}
221
+ ${includeTag ? `
222
+ ## \uD0DC\uADF8
223
+
224
+ ${tags.trim()}
225
+ ` : ""}`.trim() + "\n";
226
+ const readmePath = join(problemDir, "README.md");
227
+ await writeFile(readmePath, readmeContent, "utf-8");
228
+ const meta = {
229
+ id: problem.id,
230
+ title: problem.title,
231
+ level: problem.level,
232
+ tags: problem.tags,
233
+ timeLimit: problem.timeLimit,
234
+ timeLimitMs: parseTimeLimitToMs(problem.timeLimit),
235
+ memoryLimit: problem.memoryLimit
236
+ };
237
+ const metaPath = join(problemDir, "meta.json");
238
+ await writeFile(metaPath, JSON.stringify(meta, null, 2), "utf-8");
239
+ return problemDir;
240
+ }
241
+
242
+ // src/hooks/use-fetch-problem.ts
243
+ function useFetchProblem({
244
+ problemId,
245
+ language,
246
+ onComplete
247
+ }) {
248
+ const [status, setStatus] = useState(
249
+ "loading"
250
+ );
251
+ const [problem, setProblem] = useState(null);
252
+ const [error, setError] = useState(null);
253
+ const [message, setMessage] = useState("\uBB38\uC81C \uC815\uBCF4\uB97C \uAC00\uC838\uC624\uB294 \uC911...");
254
+ useEffect(() => {
255
+ async function fetchProblem() {
256
+ try {
257
+ setMessage("Solved.ac\uC5D0\uC11C \uBB38\uC81C \uC815\uBCF4\uB97C \uAC00\uC838\uC624\uB294 \uC911...");
258
+ const solvedAcData = await getProblem(problemId);
259
+ setMessage("BOJ\uC5D0\uC11C \uBB38\uC81C \uC0C1\uC138 \uC815\uBCF4\uB97C \uAC00\uC838\uC624\uB294 \uC911...");
260
+ const scrapedData = await scrapeProblem(problemId);
261
+ if (!scrapedData.title && !solvedAcData.titleKo) {
262
+ throw new Error(
263
+ `\uBB38\uC81C ${problemId}\uC758 \uC81C\uBAA9\uC744 \uAC00\uC838\uC62C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uBB38\uC81C\uAC00 \uC874\uC7AC\uD558\uC9C0 \uC54A\uAC70\uB098 \uC811\uADFC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
264
+ );
265
+ }
266
+ const combinedProblem = {
267
+ id: problemId,
268
+ title: solvedAcData.titleKo || scrapedData.title,
269
+ level: solvedAcData.level,
270
+ tier: getTierName(solvedAcData.level),
271
+ tags: solvedAcData.tags.map(
272
+ (tag) => tag.displayNames.find((d) => d.language === "ko")?.name || tag.displayNames[0]?.name || tag.key
273
+ ),
274
+ timeLimit: scrapedData.timeLimit,
275
+ memoryLimit: scrapedData.memoryLimit,
276
+ submissions: scrapedData.submissions,
277
+ accepted: scrapedData.accepted,
278
+ acceptedUsers: scrapedData.acceptedUsers,
279
+ acceptedRate: scrapedData.acceptedRate,
280
+ description: scrapedData.description,
281
+ inputFormat: scrapedData.inputFormat,
282
+ outputFormat: scrapedData.outputFormat,
283
+ testCases: scrapedData.testCases
284
+ };
285
+ setProblem(combinedProblem);
286
+ setMessage("\uD30C\uC77C\uC744 \uC0DD\uC131\uD558\uB294 \uC911...");
287
+ const problemDir = await generateProblemFiles(
288
+ combinedProblem,
289
+ language
290
+ );
291
+ setStatus("success");
292
+ setMessage(`\uBB38\uC81C \uD30C\uC77C\uC774 \uC0DD\uC131\uB418\uC5C8\uC2B5\uB2C8\uB2E4: ${problemDir}`);
293
+ if (getAutoOpenEditor()) {
294
+ try {
295
+ const editor = getEditor();
296
+ await execaCommand(`${editor} ${problemDir}`, {
297
+ shell: true,
298
+ detached: true,
299
+ stdio: "ignore"
300
+ });
301
+ setMessage(
302
+ `\uBB38\uC81C \uD30C\uC77C\uC774 \uC0DD\uC131\uB418\uC5C8\uC2B5\uB2C8\uB2E4: ${problemDir}
303
+ ${editor}\uB85C \uC5F4\uC5C8\uC2B5\uB2C8\uB2E4.`
304
+ );
305
+ } catch (err) {
306
+ console.warn(
307
+ `\uC5D0\uB514\uD130\uB97C \uC5F4 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${err instanceof Error ? err.message : String(err)}`
308
+ );
309
+ }
310
+ }
311
+ setTimeout(() => {
312
+ onComplete?.();
313
+ }, 2e3);
314
+ } catch (err) {
315
+ setStatus("error");
316
+ setError(err instanceof Error ? err.message : String(err));
317
+ setTimeout(() => {
318
+ onComplete?.();
319
+ }, 2e3);
320
+ }
321
+ }
322
+ void fetchProblem();
323
+ }, [problemId, language, onComplete]);
324
+ return {
325
+ status,
326
+ problem,
327
+ error,
328
+ message
329
+ };
330
+ }
331
+
332
+ // src/commands/fetch.tsx
333
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
334
+ var fetchFlagsSchema = {
335
+ language: {
336
+ type: "string",
337
+ shortFlag: "l",
338
+ description: `\uC5B8\uC5B4 \uC120\uD0DD (${getSupportedLanguagesString()})
339
+ \uAE30\uBCF8\uAC12: python`
340
+ }
341
+ };
342
+ function FetchView({
343
+ problemId,
344
+ language = "python",
345
+ onComplete
346
+ }) {
347
+ const { status, problem, error, message } = useFetchProblem({
348
+ problemId,
349
+ language,
350
+ onComplete
351
+ });
352
+ if (status === "loading") {
353
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
354
+ /* @__PURE__ */ jsx2(Spinner, { label: message }),
355
+ problem && /* @__PURE__ */ jsx2(ProblemDashboard, { problem })
356
+ ] });
357
+ }
358
+ if (status === "error") {
359
+ return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsxs2(Alert, { variant: "error", children: [
360
+ "\uC624\uB958 \uBC1C\uC0DD: ",
361
+ error
362
+ ] }) });
363
+ }
364
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", width: "100%", children: [
365
+ problem && /* @__PURE__ */ jsx2(Box2, { alignSelf: "flex-start", children: /* @__PURE__ */ jsx2(ProblemDashboard, { problem }) }),
366
+ /* @__PURE__ */ jsx2(StatusMessage, { variant: "success", children: message })
367
+ ] });
368
+ }
369
+ var FetchCommand = class extends Command {
370
+ async execute(args, flags) {
371
+ const context = await resolveProblemContext(args, { requireId: true });
372
+ if (context.problemId === null) {
373
+ logger.error("\uBB38\uC81C \uBC88\uD638\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694.");
374
+ console.log(`\uB3C4\uC6C0\uB9D0: ps fetch --help`);
375
+ process.exit(1);
376
+ return;
377
+ }
378
+ const validLanguages = getSupportedLanguages();
379
+ const language = flags.language;
380
+ if (language && !validLanguages.includes(language)) {
381
+ console.error(
382
+ `\uC624\uB958: \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uC5B8\uC5B4\uC785\uB2C8\uB2E4. (${getSupportedLanguagesString()})`
383
+ );
384
+ process.exit(1);
385
+ return;
386
+ }
387
+ await this.renderView(FetchView, {
388
+ problemId: context.problemId,
389
+ language: language || "python"
390
+ });
391
+ }
392
+ };
393
+ FetchCommand = __decorateClass([
394
+ CommandDef({
395
+ name: "fetch",
396
+ description: `\uBC31\uC900 \uBB38\uC81C\uB97C \uAC00\uC838\uC640\uC11C \uB85C\uCEEC\uC5D0 \uD30C\uC77C\uC744 \uC0DD\uC131\uD569\uB2C8\uB2E4.
397
+ - Solved.ac API\uC640 BOJ \uD06C\uB864\uB9C1\uC744 \uD1B5\uD574 \uBB38\uC81C \uC815\uBCF4 \uC218\uC9D1
398
+ - \uBB38\uC81C \uC124\uBA85, \uC785\uCD9C\uB825 \uD615\uC2DD, \uC608\uC81C \uC785\uCD9C\uB825 \uD30C\uC77C \uC790\uB3D9 \uC0DD\uC131
399
+ - \uC120\uD0DD\uD55C \uC5B8\uC5B4\uC758 \uC194\uB8E8\uC158 \uD15C\uD50C\uB9BF \uD30C\uC77C \uC0DD\uC131
400
+ - README.md\uC5D0 \uBB38\uC81C \uC815\uBCF4, \uD1B5\uACC4, \uD0DC\uADF8(\uC124\uC815 \uC2DC) \uB4F1 \uD3EC\uD568
401
+ - \uAE30\uBCF8 \uC5B8\uC5B4, \uC5D0\uB514\uD130 \uC124\uC815 \uB4F1\uC740 ps config\uC5D0\uC11C \uC124\uC815 \uAC00\uB2A5\uD569\uB2C8\uB2E4.`,
402
+ flags: defineFlags(fetchFlagsSchema),
403
+ autoDetectProblemId: false,
404
+ requireProblemId: true,
405
+ examples: ["fetch 1000", "fetch 1000 --language python", "fetch 1000 -l cpp"]
406
+ })
407
+ ], FetchCommand);
408
+ var fetch_default = CommandBuilder.fromClass(FetchCommand);
409
+
410
+ export {
411
+ FetchView,
412
+ FetchCommand,
413
+ fetch_default
414
+ };
@@ -1,7 +1,62 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ logger
4
+ } from "./chunk-Q5NECGFA.js";
2
5
 
3
6
  // src/services/scraper.ts
4
7
  import * as cheerio from "cheerio";
8
+
9
+ // src/utils/http.ts
10
+ var MAX_RETRIES = 3;
11
+ var RETRY_DELAY = 1e3;
12
+ var DEFAULT_HEADERS = {
13
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
14
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
15
+ "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7"
16
+ };
17
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
+ async function fetchWithRetry(url, context) {
19
+ let lastError = null;
20
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
21
+ try {
22
+ const response = await fetch(url, {
23
+ headers: DEFAULT_HEADERS
24
+ });
25
+ if (!response.ok) {
26
+ if (response.status === 403 || response.status === 429) {
27
+ throw new Error(
28
+ `\uBC31\uC900 \uC0AC\uC774\uD2B8 \uC811\uADFC\uC774 \uAC70\uBD80\uB418\uC5C8\uC2B5\uB2C8\uB2E4 (HTTP ${response.status}). \uC7A0\uC2DC \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uC8FC\uC138\uC694.`
29
+ );
30
+ }
31
+ throw new Error(
32
+ `\uD398\uC774\uC9C0\uB97C \uAC00\uC838\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: HTTP ${response.status}`
33
+ );
34
+ }
35
+ const html = await response.text();
36
+ if (!html || html.trim().length === 0) {
37
+ throw new Error("\uC751\uB2F5 \uBCF8\uBB38\uC774 \uBE44\uC5B4\uC788\uC2B5\uB2C8\uB2E4.");
38
+ }
39
+ if (html.includes("Access Denied") || html.includes("CAPTCHA")) {
40
+ throw new Error(
41
+ "\uBC31\uC900\uC758 \uBD07 \uBC29\uC9C0 \uC2DC\uC2A4\uD15C\uC5D0 \uC758\uD574 \uC811\uADFC\uC774 \uCC28\uB2E8\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C \uC9C1\uC811 \uC811\uC18D\uD558\uC5EC \uD655\uC778\uC774 \uD544\uC694\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4."
42
+ );
43
+ }
44
+ return html;
45
+ } catch (error) {
46
+ lastError = error;
47
+ if (attempt < MAX_RETRIES) {
48
+ const delay = RETRY_DELAY * Math.pow(2, attempt - 1);
49
+ logger.warn(
50
+ `${context} \uB370\uC774\uD130\uB97C \uAC00\uC838\uC624\uB294 \uB370 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4 (${attempt}/${MAX_RETRIES}). ${delay}ms \uD6C4 \uC7AC\uC2DC\uB3C4\uD569\uB2C8\uB2E4...`
51
+ );
52
+ await sleep(delay);
53
+ }
54
+ }
55
+ }
56
+ throw lastError || new Error(`${context} \uB370\uC774\uD130\uB97C \uAC00\uC838\uC62C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`);
57
+ }
58
+
59
+ // src/services/scraper.ts
5
60
  var BOJ_BASE_URL = "https://www.acmicpc.net";
6
61
  var SOLVED_AC_BASE_URL = "https://solved.ac";
7
62
  function htmlToMarkdown($, element) {
@@ -48,7 +103,7 @@ function htmlToMarkdown($, element) {
48
103
  case "div": {
49
104
  const divContent = htmlToMarkdown($, $node);
50
105
  if (divContent) {
51
- result += divContent + "\n";
106
+ result += divContent + "\n\n";
52
107
  }
53
108
  break;
54
109
  }
@@ -65,6 +120,7 @@ function htmlToMarkdown($, element) {
65
120
  \`\`\`
66
121
  ${preContent}
67
122
  \`\`\`
123
+
68
124
  `;
69
125
  }
70
126
  break;
@@ -78,13 +134,14 @@ ${preContent}
78
134
  `;
79
135
  }
80
136
  });
137
+ result += "\n";
81
138
  break;
82
139
  case "li":
83
140
  result += htmlToMarkdown($, $node);
84
141
  break;
85
142
  case "img": {
86
143
  const imgSrc = $node.attr("src") || "";
87
- const imgAlt = $node.attr("alt") || "";
144
+ const imgAlt = $node.attr("alt") || "\uC774\uBBF8\uC9C0";
88
145
  if (imgSrc) {
89
146
  let imageUrl = imgSrc;
90
147
  if (imgSrc.startsWith("/")) {
@@ -92,7 +149,9 @@ ${preContent}
92
149
  } else if (!imgSrc.startsWith("http") && !imgSrc.startsWith("data:")) {
93
150
  imageUrl = `${BOJ_BASE_URL}/${imgSrc}`;
94
151
  }
95
- result += `![${imgAlt}](${imageUrl})`;
152
+ result += `![${imgAlt}](${imageUrl})
153
+
154
+ `;
96
155
  }
97
156
  break;
98
157
  }
@@ -105,15 +164,7 @@ ${preContent}
105
164
  }
106
165
  async function scrapeProblem(problemId) {
107
166
  const url = `${BOJ_BASE_URL}/problem/${problemId}`;
108
- const response = await fetch(url, {
109
- headers: {
110
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
111
- }
112
- });
113
- if (!response.ok) {
114
- throw new Error(`Failed to fetch problem page: HTTP ${response.status}`);
115
- }
116
- const html = await response.text();
167
+ const html = await fetchWithRetry(url, `\uBB38\uC81C ${problemId}`);
117
168
  const $ = cheerio.load(html);
118
169
  const title = $("#problem_title").text().trim();
119
170
  const descriptionEl = $("#problem_description");
@@ -224,12 +275,12 @@ async function scrapeProblem(problemId) {
224
275
  }
225
276
  if (!title) {
226
277
  throw new Error(
227
- `\uBB38\uC81C ${problemId}\uC758 \uC81C\uBAA9\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. BOJ \uD398\uC774\uC9C0 \uAD6C\uC870\uAC00 \uBCC0\uACBD\uB418\uC5C8\uAC70\uB098 \uBB38\uC81C\uAC00 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC744 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`
278
+ `\uBB38\uC81C ${problemId}\uC758 \uC81C\uBAA9\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. BOJ \uD398\uC774\uC9C0 \uAD6C\uC870\uAC00 \uBCC0\uACBD\uB418\uC5C8\uAC70\uB098, \uB85C\uADF8\uC778\uC774 \uD544\uC694\uD55C \uBB38\uC81C\uC774\uAC70\uB098, \uC874\uC7AC\uD558\uC9C0 \uC54A\uB294 \uBB38\uC81C\uC77C \uC218 \uC788\uC2B5\uB2C8\uB2E4.`
228
279
  );
229
280
  }
230
281
  if (!description && !inputFormat && !outputFormat) {
231
282
  throw new Error(
232
- `\uBB38\uC81C ${problemId}\uC758 \uB0B4\uC6A9\uC744 \uAC00\uC838\uC62C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. BOJ \uD398\uC774\uC9C0 \uAD6C\uC870\uAC00 \uBCC0\uACBD\uB418\uC5C8\uAC70\uB098 API \uC81C\uD55C\uC5D0 \uAC78\uB838\uC744 \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uC7A0\uC2DC \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uC8FC\uC138\uC694.`
283
+ `\uBB38\uC81C ${problemId}\uC758 \uB0B4\uC6A9\uC744 \uAC00\uC838\uC62C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uBC31\uC900\uC758 \uBD07 \uBC29\uC9C0 \uC2DC\uC2A4\uD15C\uC5D0 \uC758\uD574 \uCC28\uB2E8\uB418\uC5C8\uAC70\uB098, \uD398\uC774\uC9C0 \uAD6C\uC870\uAC00 \uBCC0\uACBD\uB418\uC5C8\uC744 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`
233
284
  );
234
285
  }
235
286
  return {
@@ -375,8 +426,115 @@ async function searchProblems(query, page = 1) {
375
426
  totalPages
376
427
  };
377
428
  }
429
+ async function scrapeUserStats(handle) {
430
+ const url = `${BOJ_BASE_URL}/user/${encodeURIComponent(handle)}`;
431
+ const html = await fetchWithRetry(url, `\uC0AC\uC6A9\uC790 ${handle}`);
432
+ const $ = cheerio.load(html);
433
+ const stats = {
434
+ submissions: 0,
435
+ accepted: 0,
436
+ wrong: 0,
437
+ timeout: 0,
438
+ memory: 0,
439
+ runtimeError: 0,
440
+ compileError: 0
441
+ };
442
+ const statTable = $("#stat-table");
443
+ if (statTable.length > 0) {
444
+ statTable.find("tr").each((_, row) => {
445
+ const th = $(row).find("th").text().trim();
446
+ const td = $(row).find("td").text().trim();
447
+ const value = parseInt(td.replace(/,/g, ""), 10) || 0;
448
+ if (th.includes("\uC81C\uCD9C")) stats.submissions = value;
449
+ else if (th.includes("\uC815\uB2F5")) stats.accepted = value;
450
+ else if (th.includes("\uD2C0\uB9B0 \uACB0\uACFC")) stats.wrong = value;
451
+ else if (th.includes("\uC2DC\uAC04 \uCD08\uACFC")) stats.timeout = value;
452
+ else if (th.includes("\uBA54\uBAA8\uB9AC \uCD08\uACFC")) stats.memory = value;
453
+ else if (th.includes("\uB7F0\uD0C0\uC784 \uC5D0\uB7EC")) stats.runtimeError = value;
454
+ else if (th.includes("\uCEF4\uD30C\uC77C \uC5D0\uB7EC")) stats.compileError = value;
455
+ });
456
+ }
457
+ return stats;
458
+ }
459
+
460
+ // src/services/solved-api.ts
461
+ var BASE_URL = "https://solved.ac/api/v3";
462
+ var USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
463
+ async function fetchWithRetry2(url, options = {}, maxRetries = 3) {
464
+ let lastError = null;
465
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
466
+ try {
467
+ const response = await fetch(url, {
468
+ ...options,
469
+ headers: {
470
+ "User-Agent": USER_AGENT,
471
+ ...options.headers
472
+ }
473
+ });
474
+ if (response.status === 429) {
475
+ const retryAfter = response.headers.get("Retry-After");
476
+ const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1e3 : (attempt + 1) * 1e3;
477
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
478
+ continue;
479
+ }
480
+ if (!response.ok) {
481
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
482
+ }
483
+ return response;
484
+ } catch (error) {
485
+ lastError = error instanceof Error ? error : new Error(String(error));
486
+ if (attempt < maxRetries - 1) {
487
+ await new Promise(
488
+ (resolve) => setTimeout(resolve, (attempt + 1) * 1e3)
489
+ );
490
+ }
491
+ }
492
+ }
493
+ throw lastError || new Error("Request failed after retries");
494
+ }
495
+ async function getProblem(problemId) {
496
+ const url = `${BASE_URL}/problem/show?problemId=${problemId}`;
497
+ const response = await fetchWithRetry2(url);
498
+ const data = await response.json();
499
+ return data;
500
+ }
501
+ async function getUserStats(handle) {
502
+ const url = `${BASE_URL}/user/show?handle=${encodeURIComponent(handle)}`;
503
+ const response = await fetchWithRetry2(url);
504
+ const data = await response.json();
505
+ return data;
506
+ }
507
+ async function getUserTop100(handle) {
508
+ const url = `${BASE_URL}/user/top_100?handle=${encodeURIComponent(handle)}`;
509
+ const response = await fetchWithRetry2(url);
510
+ const data = await response.json();
511
+ return data.items || [];
512
+ }
513
+ async function getUserProblemStats(handle) {
514
+ const url = `${BASE_URL}/user/problem_stats?handle=${encodeURIComponent(
515
+ handle
516
+ )}`;
517
+ const response = await fetchWithRetry2(url);
518
+ const data = await response.json();
519
+ return data;
520
+ }
521
+ async function getUserTagRatings(handle) {
522
+ const url = `${BASE_URL}/user/tag_ratings?handle=${encodeURIComponent(
523
+ handle
524
+ )}`;
525
+ const response = await fetchWithRetry2(url);
526
+ const data = await response.json();
527
+ return data.items || [];
528
+ }
378
529
 
379
530
  export {
531
+ fetchWithRetry,
380
532
  scrapeProblem,
381
- searchProblems
533
+ searchProblems,
534
+ scrapeUserStats,
535
+ getProblem,
536
+ getUserStats,
537
+ getUserTop100,
538
+ getUserProblemStats,
539
+ getUserTagRatings
382
540
  };