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
37 changes: 37 additions & 0 deletions src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
*/
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?: "",
Expand Down
50 changes: 36 additions & 14 deletions src/main/kotlin/com/codingtestkit/service/TranslateService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,43 @@ object TranslateService {
fun translate(text: String, sourceLang: String = "auto", targetLang: String = "ko"): String {
if (text.isBlank()) return text

// <pre> 블록을 번역에서 보호: 분리 → 나머지만 번역 → 재조립
// 보호 구간(<pre>, 수식)을 플레이스홀더로 치환해 한 번에 번역한 뒤 복원.
// 구간별로 API를 호출하면 수식이 많은 문제에서 호출이 수십 회로 폭증함 (이슈 #25).
val protectedContents = mutableListOf<String>()
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
}
}

/**
* 번역에서 보호할 구간을 분리해 (내용, 보호 여부) 리스트로 반환.
* - <pre> 블록: 테스트 케이스 입출력이 번역되며 깨지는 것 방지
* - 수식 구간 ($$...$$, $...$): KaTeX 소스가 번역되며 깨지는 것 방지 (이슈 #25)
*/
internal fun splitProtected(text: String): List<Pair<String, Boolean>> {
val protectedPattern = Regex(
"<pre[^>]*>[\\s\\S]*?</pre>" + // <pre> 블록
"|\\$\\$[^$]+\\$\\$" + // display 수식 $$...$$
"|\\$[^$\\n]+\\$", // inline 수식 $...$
RegexOption.IGNORE_CASE
)
val parts = mutableListOf<Pair<String, Boolean>>() // (content, isProtected)
var lastEnd = 0
for (match in Regex("<pre[^>]*>[\\s\\S]*?</pre>", 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))
}
Expand All @@ -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 {
Expand Down
85 changes: 85 additions & 0 deletions src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<html><body><div class="problem-statement">
<div class="header"><div class="title">A. Test</div></div>
<div><p>You are given
<span class="MathJax_Preview">n</span>
<span class="MathJax" role="presentation" style="position:relative;"><span>n</span></span>
<script type="math/tex">n</script>
vertices.</p></div>
<div class="sample-tests"><div class="input"><pre>1</pre></div><div class="output"><pre>2</pre></div></div>
</div></body></html>
""".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 = """
<html><body><div class="problem-statement">
<div class="header"><div class="title">A. Test</div></div>
<div><p>Formula:</p>
<div class="MathJax_Display">rendered</div>
<script type="math/tex; mode=display">a_i \le 10^6</script>
</div>
</div></body></html>
""".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 = """<h2>Problem</h2><p>You are given
<span class="MathJax_Preview">n</span>
<span class="MathJax" role="presentation" style="position:relative;"><span>n</span></span>
<script type="math/tex">n</script>
vertices and <script type="math/tex; mode=display">a_i \le 10^6</script>.</p>"""

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 = "<h2>Problem</h2><p>Given ${'$'}n${'$'} vertices.</p>"
assertEquals(clean, CodeforcesCrawler.stripMathJaxArtifacts(clean))
}

@Test
fun `plain HTML without MathJax is unaffected`() {
val html = """
<html><body><div class="problem-statement">
<div class="header"><div class="title">A. Test</div></div>
<div><p>Given ${'$'}${'$'}${'$'}n${'$'}${'$'}${'$'} vertices.</p></div>
</div></body></html>
""".trimIndent()

val problem = CodeforcesCrawler.parseProblemHtml(html, "1A")

// Jsoup 경로의 원본 $$$ 구분자는 기존 정규화대로 $ 하나로 변환됨
assertTrue(problem.description.contains("${'$'}n${'$'}"))
}
}
36 changes: 36 additions & 0 deletions src/test/kotlin/com/codingtestkit/service/TranslateServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,42 @@ class TranslateServiceTest {
assertEquals("ko", TranslateService.detectLanguage("ㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎ"))
}

// ── splitProtected (이슈 #25: 수식·pre 번역 보호) ──

@Test
fun `splitProtected protects pre blocks`() {
val parts = TranslateService.splitProtected("before <pre>1 2\n3</pre> after")
assertEquals(listOf("before " to false, "<pre>1 2\n3</pre>" 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
Expand Down
Loading