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
20 changes: 19 additions & 1 deletion src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,32 @@ object CodeforcesCrawler {

// 로그인 쿠키(cf_clearance 포함)를 함께 보내면 Cloudflare 챌린지를 통과할 수 있음.
// cf_clearance는 User-Agent에 바인딩되므로 JCEF(Chrome)와 유사한 UA를 사용.
// 이전 JCEF 폴백 세션이 챌린지를 통과했다면 그 쿠키도 재사용 (이슈 #21).
// 같은 이름의 쿠키는 호출자(로그인) 쿠키가 우선.
val merged = mergeCookieHeaders(CodeforcesJcefFetcher.getCloudflareCookies(), cookies)
val connection = Jsoup.connect("${BASE_URL}$contestId/$letter?locale=en")
.userAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
.timeout(30000)
if (cookies.isNotBlank()) connection.header("Cookie", cookies)
if (merged.isNotBlank()) connection.header("Cookie", merged)
val doc = connection.get()
return parseProblemDoc(doc, contestId, letter)
}

/**
* "a=1; b=2" 형태의 Cookie 헤더 두 개를 병합. 같은 이름이면 override 쪽이 우선.
*/
internal fun mergeCookieHeaders(base: String, override: String): String {
val map = LinkedHashMap<String, String>()
for (part in base.split(";") + override.split(";")) {
val p = part.trim()
if (p.isEmpty()) continue
val i = p.indexOf('=')
if (i <= 0) continue
map[p.substring(0, i).trim()] = p.substring(i + 1).trim()
}
return map.entries.joinToString("; ") { "${it.key}=${it.value}" }
}

/**
* JCEF 폴백 경로: 브라우저가 렌더링한 HTML을 동일한 파서로 처리.
* Cloudflare가 Jsoup의 TLS 지문을 차단할 때 사용된다.
Expand Down
40 changes: 40 additions & 0 deletions src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,46 @@ object CodeforcesJcefFetcher {
false
}

/**
* JCEF 전역 쿠키 저장소에서 codeforces.com 쿠키(cf_clearance 포함)를 꺼낸다 (이슈 #21).
*
* JCEF 폴백이 Cloudflare 챌린지를 한 번 통과하면 그 쿠키가 전역 컨텍스트에 남는다.
* 이후 Jsoup 요청의 Cookie 헤더에 실으면 챌린지 없이 빠른 경로로 통과하므로,
* 차단 환경에서도 첫 문제만 느리고 다음 문제부터는 ~1초에 가져온다.
* 쿠키가 만료·거부되면 403 → JCEF 폴백이 다시 돌며 새 쿠키가 저장되는 자가 회복 구조.
*
* CEF가 아직 시작되지 않았으면 빈 문자열을 반환한다 (쿠키를 읽으려고 CEF를 기동하지 않음).
* 백그라운드 스레드에서 호출할 것.
*/
fun getCloudflareCookies(timeoutMs: Long = 2000): String {
try {
if (!JBCefApp.isSupported() || !JBCefApp.isStarted()) return ""
} catch (_: Exception) {
return ""
}
return try {
val latch = java.util.concurrent.CountDownLatch(1)
val sb = StringBuilder()
val started = org.cef.network.CefCookieManager.getGlobalManager().visitUrlCookies(
"https://codeforces.com/", true
) { cookie, count, total, _ ->
synchronized(sb) {
if (sb.isNotEmpty()) sb.append("; ")
sb.append(cookie.name).append('=').append(cookie.value)
}
if (count == total - 1) latch.countDown()
true
}
if (!started) return ""
// 쿠키가 하나도 없으면 visitor가 불리지 않아 타임아웃까지 대기
latch.await(timeoutMs, TimeUnit.MILLISECONDS)
synchronized(sb) { sb.toString() }
} catch (e: Exception) {
LOG.info("[CodingTestKit] Codeforces cookie read failed: ${e.message}")
""
}
}

/**
* 백그라운드 스레드에서 호출할 것 (EDT에서 호출하면 deadlock).
*/
Expand Down
72 changes: 72 additions & 0 deletions src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.codingtestkit.service

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class CodeforcesCrawlerTest {

// ── 문제 ID 파싱 ──

@Test
fun `parse plain id`() {
assertEquals("1234" to "A", CodeforcesCrawler.parseProblemId("1234A"))
}

@Test
fun `parse id with slash and lowercase`() {
assertEquals("1234" to "B", CodeforcesCrawler.parseProblemId("1234/b"))
}

@Test
fun `parse id with digit suffix like C1`() {
assertEquals("2042" to "C1", CodeforcesCrawler.parseProblemId("2042C1"))
}

@Test
fun `invalid id throws`() {
assertThrows(IllegalArgumentException::class.java) {
CodeforcesCrawler.parseProblemId("abc")
}
}

// ── 쿠키 헤더 병합 (이슈 #21: JCEF 쿠키 + 로그인 쿠키) ──

@Test
fun `merge disjoint cookies`() {
assertEquals(
"cf_clearance=abc; JSESSIONID=xyz",
CodeforcesCrawler.mergeCookieHeaders("cf_clearance=abc", "JSESSIONID=xyz")
)
}

@Test
fun `override side wins on same name`() {
assertEquals(
"cf_clearance=login",
CodeforcesCrawler.mergeCookieHeaders("cf_clearance=jcef", "cf_clearance=login")
)
}

@Test
fun `blank sides are handled`() {
assertEquals("a=1", CodeforcesCrawler.mergeCookieHeaders("a=1", ""))
assertEquals("a=1", CodeforcesCrawler.mergeCookieHeaders("", "a=1"))
assertEquals("", CodeforcesCrawler.mergeCookieHeaders("", ""))
}

@Test
fun `whitespace and malformed parts are ignored`() {
assertEquals(
"a=1; b=2",
CodeforcesCrawler.mergeCookieHeaders(" a=1 ; ; noequals ", " b=2 ")
)
}

@Test
fun `cookie value containing equals sign is preserved`() {
assertEquals(
"token=a=b=c",
CodeforcesCrawler.mergeCookieHeaders("token=a=b=c", "")
)
}
}
Loading