diff --git a/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt b/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt index 486f472..1b1be34 100644 --- a/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt +++ b/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt @@ -9,6 +9,30 @@ object CodeforcesCrawler { private const val BASE_URL = "https://codeforces.com/problemset/problem/" + private const val MATHJAX_ARTIFACT_SELECTOR = + ".MathJax, .MathJax_Preview, .MathJax_Display, .MathJax_Error, " + + ".MathJax_CHTML, .mjx-chtml, .MathJax_SVG, .MathJax_SVG_Display, #MathJax_Message" + + /** + * 저장된 문제 본문에서 MathJax 렌더링 잔여물을 제거하고 원본 TeX를 + * 최종 구분자($ / $$)로 복원 (이슈 #25). + * + * 수정 이전 버전에서 JCEF 폴백으로 가져와 problem.json에 오염된 채 + * 저장된 문제들을 위한 로드 시점 정리 — 재가져오기 없이도 표시·번역이 + * 정상화된다. 오염되지 않은 본문은 빠른 경로로 그대로 반환. + */ + fun stripMathJaxArtifacts(html: String): String { + if (!html.contains("MathJax") && !html.contains("math/tex")) return html + val body = Jsoup.parseBodyFragment(html).body() + body.select("script[type~=math/tex]").forEach { script -> + val delim = if (script.attr("type").contains("display")) "\$\$" else "\$" + script.before(org.jsoup.nodes.TextNode("$delim${script.data()}$delim")) + script.remove() + } + body.select(MATHJAX_ARTIFACT_SELECTOR).remove() + return body.html() + } + /** * "1234A" → ("1234", "A"), "1234/A" → ("1234", "A") */ @@ -62,6 +86,19 @@ object CodeforcesCrawler { } private fun parseProblemDoc(doc: org.jsoup.nodes.Document, contestId: String, letter: String): Problem { + // JCEF 폴백은 MathJax가 이미 렌더링한 DOM을 가져오므로 수식마다 + // 렌더링 결과(span)와 원본 TeX(script)가 공존한다 (이슈 #25). + // 원본 TeX를 $$$ 구분자 텍스트로 복원하고 렌더링 잔여물은 제거해 + // Jsoup 경로(원본 HTML)와 동일한 형태로 맞춘다. 원본 HTML에는 + // 해당 노드가 없어 no-op. + doc.select("script[type~=math/tex]").forEach { script -> + val display = script.attr("type").contains("display") + val delim = if (display) "\$\$\$\$\$\$" else "\$\$\$" // 아래 정규화($$$→$)를 그대로 타는 원본 구분자 + script.before(org.jsoup.nodes.TextNode("$delim${script.data()}$delim")) + script.remove() + } + doc.select(MATHJAX_ARTIFACT_SELECTOR).remove() + // 이미지: 상대 경로 → 절대 경로, 고정 크기 제거 doc.select("img[src]").forEach { img -> val src = img.attr("src") diff --git a/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt b/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt index 4dedde3..35bb9fa 100644 --- a/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt +++ b/src/main/kotlin/com/codingtestkit/service/ProblemFileManager.kt @@ -242,11 +242,16 @@ object ProblemFileManager { } } val paramNames = json.getAsJsonArray("parameterNames")?.map { it.asString } ?: emptyList() + // 수정 이전 버전에서 JCEF 폴백으로 저장된 코드포스 문제는 MathJax + // 렌더링 잔여물이 본문에 남아 있어 로드 시점에 정리 (이슈 #25) + val rawDescription = json.get("description")?.asString ?: "" + val description = if (source == ProblemSource.CODEFORCES) + CodeforcesCrawler.stripMathJaxArtifacts(rawDescription) else rawDescription Problem( source = source, id = json.get("id")?.asString ?: "", title = json.get("title")?.asString ?: "", - description = json.get("description")?.asString ?: "", + description = description, testCases = testCases, timeLimit = json.get("timeLimit")?.asString ?: "", memoryLimit = json.get("memoryLimit")?.asString ?: "", diff --git a/src/main/kotlin/com/codingtestkit/service/TranslateService.kt b/src/main/kotlin/com/codingtestkit/service/TranslateService.kt index 9d0d89c..9a877e5 100644 --- a/src/main/kotlin/com/codingtestkit/service/TranslateService.kt +++ b/src/main/kotlin/com/codingtestkit/service/TranslateService.kt @@ -13,10 +13,43 @@ object TranslateService { fun translate(text: String, sourceLang: String = "auto", targetLang: String = "ko"): String { if (text.isBlank()) return text - //
 블록을 번역에서 보호: 분리 → 나머지만 번역 → 재조립
+        // 보호 구간(
, 수식)을 플레이스홀더로 치환해 한 번에 번역한 뒤 복원.
+        // 구간별로 API를 호출하면 수식이 많은 문제에서 호출이 수십 회로 폭증함 (이슈 #25).
+        val protectedContents = mutableListOf()
+        val masked = buildString {
+            for ((content, isProtected) in splitProtected(text)) {
+                if (isProtected) {
+                    append(" __CTK${protectedContents.size}__ ")
+                    protectedContents.add(content)
+                } else {
+                    append(content)
+                }
+            }
+        }
+
+        val translated = translateText(masked, sourceLang, targetLang)
+
+        // 번역기가 플레이스홀더 주변에 공백을 끼워넣거나 대소문자를 바꿀 수 있어 유연하게 복원
+        return Regex("__\\s*CTK\\s*(\\d+)\\s*__", RegexOption.IGNORE_CASE).replace(translated) { m ->
+            protectedContents.getOrNull(m.groupValues[1].toIntOrNull() ?: -1) ?: m.value
+        }
+    }
+
+    /**
+     * 번역에서 보호할 구간을 분리해 (내용, 보호 여부) 리스트로 반환.
+     * - 
 블록: 테스트 케이스 입출력이 번역되며 깨지는 것 방지
+     * - 수식 구간 ($$...$$, $...$): KaTeX 소스가 번역되며 깨지는 것 방지 (이슈 #25)
+     */
+    internal fun splitProtected(text: String): List> {
+        val protectedPattern = Regex(
+            "]*>[\\s\\S]*?
" + //
 블록
+                "|\\$\\$[^$]+\\$\\$" +             // display 수식 $$...$$
+                "|\\$[^$\\n]+\\$",                  // inline 수식 $...$
+            RegexOption.IGNORE_CASE
+        )
         val parts = mutableListOf>() // (content, isProtected)
         var lastEnd = 0
-        for (match in Regex("]*>[\\s\\S]*?
", RegexOption.IGNORE_CASE).findAll(text)) { + for (match in protectedPattern.findAll(text)) { if (match.range.first > lastEnd) { parts.add(Pair(text.substring(lastEnd, match.range.first), false)) } @@ -26,18 +59,7 @@ object TranslateService { if (lastEnd < text.length) { parts.add(Pair(text.substring(lastEnd), false)) } - - return buildString { - for ((i, pair) in parts.withIndex()) { - val (content, isProtected) = pair - if (isProtected) { - append(content) - } else { - if (i > 0 && !parts[i - 1].second) Thread.sleep(300) - append(translateText(content, sourceLang, targetLang)) - } - } - } + return parts } private fun translateText(text: String, sourceLang: String, targetLang: String): String { diff --git a/src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt b/src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt index c9d7517..c70c01e 100644 --- a/src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt +++ b/src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt @@ -69,4 +69,89 @@ class CodeforcesCrawlerTest { CodeforcesCrawler.mergeCookieHeaders("token=a=b=c", "") ) } + + // ── MathJax 렌더링 잔여물 정리 (이슈 #25: JCEF 폴백 경로) ── + + @Test + fun `MathJax rendered DOM is restored to TeX source`() { + // JCEF가 가져오는 렌더링된 DOM 구조: 렌더링 span + 원본 TeX script 공존 + val html = """ +
+
A. Test
+

You are given + n + n + + vertices.

+
1
2
+
+ """.trimIndent() + + val problem = CodeforcesCrawler.parseProblemHtml(html, "1A") + + // 렌더링 잔여물이 제거되어 수식이 중복 표시되지 않고, TeX가 $ 구분자로 복원됨 + assertFalse(problem.description.contains("role=\"presentation\""), "MathJax span should be removed") + assertFalse(problem.description.contains("MathJax"), "MathJax preview should be removed") + assertTrue(problem.description.contains("${'$'}n${'$'}"), "TeX source should be restored with dollar delimiters") + } + + @Test + fun `display math script is restored with double dollar delimiters`() { + val html = """ +
+
A. Test
+

Formula:

+
rendered
+ +
+
+ """.trimIndent() + + val problem = CodeforcesCrawler.parseProblemHtml(html, "1A") + + assertTrue( + problem.description.contains("${'$'}${'$'}a_i \\le 10^6${'$'}${'$'}"), + "Display TeX should use double dollar delimiters" + ) + assertFalse(problem.description.contains("rendered"), "Rendered display div should be removed") + } + + @Test + fun `stripMathJaxArtifacts cleans legacy saved description`() { + // 수정 이전 버전에서 저장된 problem.json의 description 형태 + val legacy = """

Problem

You are given + n + n + + vertices and .

""" + + val cleaned = CodeforcesCrawler.stripMathJaxArtifacts(legacy) + + assertFalse(cleaned.contains("role=\"presentation\"")) + assertFalse(cleaned.contains("MathJax")) + assertFalse(cleaned.contains("math/tex")) + assertTrue(cleaned.contains("${'$'}n${'$'}"), "inline TeX restored: $cleaned") + assertTrue(cleaned.contains("${'$'}${'$'}a_i \\le 10^6${'$'}${'$'}"), "display TeX restored: $cleaned") + } + + @Test + fun `stripMathJaxArtifacts is no-op for clean description`() { + val clean = "

Problem

Given ${'$'}n${'$'} vertices.

" + assertEquals(clean, CodeforcesCrawler.stripMathJaxArtifacts(clean)) + } + + @Test + fun `plain HTML without MathJax is unaffected`() { + val html = """ +
+
A. Test
+

Given ${'$'}${'$'}${'$'}n${'$'}${'$'}${'$'} vertices.

+
+ """.trimIndent() + + val problem = CodeforcesCrawler.parseProblemHtml(html, "1A") + + // Jsoup 경로의 원본 $$$ 구분자는 기존 정규화대로 $ 하나로 변환됨 + assertTrue(problem.description.contains("${'$'}n${'$'}")) + } } diff --git a/src/test/kotlin/com/codingtestkit/service/TranslateServiceTest.kt b/src/test/kotlin/com/codingtestkit/service/TranslateServiceTest.kt index 83491a5..97ddbf5 100644 --- a/src/test/kotlin/com/codingtestkit/service/TranslateServiceTest.kt +++ b/src/test/kotlin/com/codingtestkit/service/TranslateServiceTest.kt @@ -48,6 +48,42 @@ class TranslateServiceTest { assertEquals("ko", TranslateService.detectLanguage("ㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎ")) } + // ── splitProtected (이슈 #25: 수식·pre 번역 보호) ── + + @Test + fun `splitProtected protects pre blocks`() { + val parts = TranslateService.splitProtected("before
1 2\n3
after") + assertEquals(listOf("before " to false, "
1 2\n3
" to true, " after" to false), parts) + } + + @Test + fun `splitProtected protects inline and display math`() { + val parts = TranslateService.splitProtected("Given \$n\$ vertices and \$\$a_i \\le 10^6\$\$ done") + assertEquals( + listOf( + "Given " to false, + "\$n\$" to true, + " vertices and " to false, + "\$\$a_i \\le 10^6\$\$" to true, + " done" to false + ), + parts + ) + } + + @Test + fun `splitProtected returns whole text when nothing to protect`() { + val parts = TranslateService.splitProtected("plain text only") + assertEquals(listOf("plain text only" to false), parts) + } + + @Test + fun `splitProtected does not treat lone dollar as math`() { + // 짝이 없는 $ 하나는 수식이 아님 (개행을 넘어 매칭하지 않음) + val parts = TranslateService.splitProtected("price is \$5 and\nthat is all") + assertTrue(parts.all { !it.second }) + } + // ── splitHtml (리플렉션 테스트) ── @Test