From f705791e9c1bf300617a71a5aa7e6e5cd705bdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B2=94=EC=88=98?= Date: Tue, 7 Jul 2026 00:41:57 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B4=EC=8A=88=20#21=20-=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=ED=8F=AC=EC=8A=A4=20JCEF=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=20=EC=BF=A0=ED=82=A4(cf=5Fclearance)=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EA=B0=80=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JCEF 폴백이 Cloudflare 챌린지를 통과하면 cf_clearance 쿠키가 JCEF 전역 쿠키 저장소에 남는다. 이후 Jsoup 요청에 이 쿠키를 실어 보내 챌린지 없이 빠른 경로로 통과 — 차단 환경에서도 첫 문제만 느리고(5~10초) 다음 문제부터 ~1초에 가져온다. - CodeforcesJcefFetcher.getCloudflareCookies(): 전역 쿠키 저장소에서 codeforces.com 쿠키 추출. CEF 미기동 시 빈 값 (쿠키를 읽으려고 CEF를 기동하지 않음) - CodeforcesCrawler.fetchProblem(): JCEF 쿠키를 로그인 쿠키와 병합해 전송 (같은 이름이면 로그인 쿠키 우선) - 쿠키가 만료·거부되면 403 → 기존 JCEF 폴백이 다시 돌며 새 쿠키가 저장되는 자가 회복 구조 - 쿠키 병합·ID 파싱 단위 테스트 추가 --- .../service/CodeforcesCrawler.kt | 20 +++++- .../service/CodeforcesJcefFetcher.kt | 40 +++++++++++ .../service/CodeforcesCrawlerTest.kt | 72 +++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt diff --git a/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt b/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt index efc1c6f..486f472 100644 --- a/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt +++ b/src/main/kotlin/com/codingtestkit/service/CodeforcesCrawler.kt @@ -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() + 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 지문을 차단할 때 사용된다. diff --git a/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt b/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt index be24a19..f4829c9 100644 --- a/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt +++ b/src/main/kotlin/com/codingtestkit/service/CodeforcesJcefFetcher.kt @@ -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). */ diff --git a/src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt b/src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt new file mode 100644 index 0000000..c9d7517 --- /dev/null +++ b/src/test/kotlin/com/codingtestkit/service/CodeforcesCrawlerTest.kt @@ -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", "") + ) + } +}