Skip to content

Commit d3782b9

Browse files
trz-21claude
andcommitted
feat: 실데이터 크롤링 우회 + Gemini thinking 비활성으로 실분석 동작
쿠팡 Akamai 차단으로 실제 리뷰 수집이 막혀 비교 분석을 실데이터로 검증할 수 없던 문제를 해결한다. 크롤러(coupang): - COUPANG_COOKIE/COUPANG_UA 로 실제 브라우저 검증 쿠키(_abck 등) 주입 지원 - COUPANG_USE_CURL 시 curl 로 위임(reqwest TLS 핑거프린트가 Akamai에 차단됨) - 쿠팡 리뷰 API 의 페이지당 size 제한(~30)에 맞춘 페이지네이션 (build_review_api_url 에 page 인자 추가, limit 까지 누적 수집) 분석기(gemini): - gemini-2.5-flash thinking 토큰이 출력 예산을 잠식해 JSON 이 잘리던 문제 (thinkingConfig.thinkingBudget=0 + maxOutputTokens 8192) 모두 env 게이트/기본 동작 변경 없음. 코드에 시크릿 없음. Refs #34 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 66629ba commit d3782b9

2 files changed

Lines changed: 177 additions & 52 deletions

File tree

backend/src/adapters/coupang/crawler.rs

Lines changed: 172 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,18 @@ impl HttpCoupangCrawler {
9191
/// 쿠팡 리뷰 JSON API(next-api/review) URL 을 만든다.
9292
///
9393
/// 쿠팡 상품 상세에서 실제로 호출하는 엔드포인트로, 평점 요약 포함 정렬 리뷰를 반환한다.
94-
pub fn build_review_api_url(product_id: &str, limit: u32) -> String {
94+
/// 쿠팡은 페이지당 size 를 ~30 이하로 제한하므로(초과 시 빈 결과), 호출부에서
95+
/// page 를 증가시키며 페이지네이션한다.
96+
pub fn build_review_api_url(product_id: &str, page: u32, size: u32) -> String {
9597
format!(
9698
"https://www.coupang.com/next-api/review?productId={product_id}\
97-
&page=1&size={limit}&sortBy=ORDER_SCORE_ASC&ratingSummary=true&ratings=&market="
99+
&page={page}&size={size}&sortBy=ORDER_SCORE_ASC&ratingSummary=true&ratings=&market="
98100
)
99101
}
100102

103+
/// 쿠팡 리뷰 API 의 페이지당 최대 size. 초과 시 빈 contents 가 반환된다.
104+
const MAX_PAGE_SIZE: u32 = 30;
105+
101106
/// 프록시 템플릿이 있으면 타겟 URL 을 URL-encode 해 `{url}` 에 치환한다.
102107
/// 없으면 타겟 URL 을 그대로 반환(직접 호출).
103108
pub fn resolve_request_url(&self, target: &str) -> String {
@@ -230,6 +235,66 @@ impl HttpCoupangCrawler {
230235
}
231236
0
232237
}
238+
239+
/// curl 로 리뷰 API 를 호출한다(reqwest TLS 핑거프린트 우회용 로컬 경로).
240+
/// 반환: (HTTP 상태코드, 응답 본문). COUPANG_USE_CURL 설정 시에만 사용된다.
241+
async fn fetch_via_curl(
242+
url: &str,
243+
ua: &str,
244+
referer: &str,
245+
cookie: Option<&str>,
246+
) -> AppResult<(u16, String)> {
247+
const MARKER: &str = "\n__HTTP_STATUS__";
248+
let url = url.to_string();
249+
let ua = ua.to_string();
250+
let referer = referer.to_string();
251+
let cookie = cookie.map(|c| c.to_string());
252+
253+
tokio::task::spawn_blocking(move || {
254+
let mut cmd = std::process::Command::new("curl");
255+
cmd.arg("-s")
256+
.arg("--max-time")
257+
.arg("30")
258+
.arg("-w")
259+
.arg(format!("{MARKER}%{{http_code}}"))
260+
.arg("-H")
261+
.arg(format!("User-Agent: {ua}"))
262+
.arg("-H")
263+
.arg(format!("Referer: {referer}"))
264+
.arg("-H")
265+
.arg("Accept: application/json, text/plain, */*")
266+
.arg("-H")
267+
.arg("Accept-Language: ko-KR,ko;q=0.9")
268+
.arg("-H")
269+
.arg("sec-ch-ua: \"Chromium\";v=\"149\", \"Google Chrome\";v=\"149\", \"Not.A/Brand\";v=\"24\"")
270+
.arg("-H")
271+
.arg("sec-ch-ua-mobile: ?0")
272+
.arg("-H")
273+
.arg("sec-ch-ua-platform: \"macOS\"")
274+
.arg("-H")
275+
.arg("sec-fetch-dest: empty")
276+
.arg("-H")
277+
.arg("sec-fetch-mode: cors")
278+
.arg("-H")
279+
.arg("sec-fetch-site: same-origin");
280+
if let Some(c) = &cookie {
281+
cmd.arg("-H").arg(format!("Cookie: {c}"));
282+
}
283+
cmd.arg(&url);
284+
285+
let out = cmd.output().map_err(|e| AppError::Internal(e.into()))?;
286+
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
287+
if let Some(idx) = stdout.rfind(MARKER) {
288+
let body = stdout[..idx].to_string();
289+
let status: u16 = stdout[idx + MARKER.len()..].trim().parse().unwrap_or(0);
290+
Ok((status, body))
291+
} else {
292+
Ok((0, stdout))
293+
}
294+
})
295+
.await
296+
.map_err(|e| AppError::Internal(anyhow::anyhow!("curl join error: {e}")))?
297+
}
233298
}
234299

235300
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
@@ -256,63 +321,118 @@ impl CoupangCrawler for HttpCoupangCrawler {
256321
let product_id = Self::extract_product_id(url)
257322
.ok_or_else(|| AppError::BadRequest("유효하지 않은 쿠팡 상품 URL".into()))?;
258323

259-
// 쿠팡 상품 상세가 실제로 호출하는 리뷰 JSON API.
260-
let target = Self::build_review_api_url(&product_id, limit);
261-
// 프록시(스크래핑 API)가 설정돼 있으면 경유, 아니면 직접 호출.
262-
let request_url = self.resolve_request_url(&target);
324+
// 인증/우회 옵션(공통):
325+
// - 데이터센터 IP 는 Akamai 가 IP 단에서 403 → 프록시/가정용 IP 필요.
326+
// - 또는 실제 브라우저에서 검증된 Akamai 쿠키(_abck 등)를 COUPANG_COOKIE 로
327+
// 주입(쿠키 만료 전까지). UA 는 그 쿠키를 만든 브라우저와 일치해야 하므로
328+
// COUPANG_UA 로 덮어쓸 수 있다.
329+
// - reqwest TLS/HTTP2 핑거프린트는 쿠키가 유효해도 차단되므로,
330+
// COUPANG_USE_CURL 설정 시 curl 로 위임한다.
331+
let ua = std::env::var("COUPANG_UA").unwrap_or_else(|_| USER_AGENT.to_string());
332+
let cookie = std::env::var("COUPANG_COOKIE")
333+
.ok()
334+
.filter(|c| !c.trim().is_empty());
335+
let use_curl = std::env::var("COUPANG_USE_CURL")
336+
.map(|v| v != "0" && !v.trim().is_empty())
337+
.unwrap_or(false);
338+
let referer = format!("https://www.coupang.com/vp/products/{product_id}");
339+
340+
// 쿠팡은 페이지당 size 를 ~30 으로 제한하므로 페이지네이션으로 limit 까지 모은다.
341+
let page_size = limit.clamp(1, Self::MAX_PAGE_SIZE);
342+
let max_pages = limit.div_ceil(page_size).max(1) + 1; // 여유 1페이지
343+
let mut all_reviews: Vec<Review> = Vec::with_capacity(limit as usize);
344+
let mut product_name: Option<String> = None;
345+
346+
for page in 1..=max_pages {
347+
if all_reviews.len() >= limit as usize {
348+
break;
349+
}
350+
let target = Self::build_review_api_url(&product_id, page, page_size);
351+
let request_url = self.resolve_request_url(&target);
352+
353+
let (status_code, body) = self
354+
.fetch_body(&request_url, &ua, &referer, cookie.as_deref(), use_curl)
355+
.await?;
356+
357+
if !(200..300).contains(&status_code) {
358+
// 첫 페이지 실패는 에러로 보고, 이후 페이지 실패는 수집 중단.
359+
if page == 1 {
360+
let snippet: String = body.chars().take(200).collect();
361+
return Err(AppError::Internal(anyhow::anyhow!(
362+
"쿠팡 리뷰 API 응답 실패: HTTP {status_code} — {snippet}"
363+
)));
364+
}
365+
break;
366+
}
263367

264-
// 실제 브라우저처럼 보이도록 헤더 구성(직접 호출 시 최선의 노력).
265-
// 주: 데이터센터 IP 는 Akamai 가 IP 단에서 403 처리하므로, 안정적 수집에는
266-
// 프록시(COUPANG_PROXY_URL) 또는 가정용 IP/헤드리스 환경이 필요하다.
267-
let resp = self
368+
let json: serde_json::Value = match serde_json::from_str(&body) {
369+
Ok(v) => v,
370+
Err(e) => {
371+
if page == 1 {
372+
let snippet: String = body.chars().take(200).collect();
373+
return Err(AppError::Internal(anyhow::anyhow!(
374+
"쿠팡 리뷰 응답이 JSON 이 아님({e}): {snippet}"
375+
)));
376+
}
377+
break;
378+
}
379+
};
380+
381+
if product_name.is_none() {
382+
product_name = Self::find_product_name(&json);
383+
}
384+
let page_reviews = Self::parse_reviews(&json);
385+
if page_reviews.is_empty() {
386+
break; // 더 이상 리뷰 없음(마지막 페이지).
387+
}
388+
all_reviews.extend(page_reviews);
389+
}
390+
391+
all_reviews.truncate(limit as usize);
392+
393+
Ok(ProductReviews {
394+
url: url.to_string(),
395+
product_name: product_name.unwrap_or_else(|| format!("쿠팡 상품 {product_id}")),
396+
reviews: all_reviews,
397+
is_mine: false,
398+
})
399+
}
400+
}
401+
402+
impl HttpCoupangCrawler {
403+
/// 단일 페이지 본문을 가져온다. use_curl 이면 curl, 아니면 reqwest 를 쓴다.
404+
/// 반환: (HTTP 상태코드, 응답 본문).
405+
async fn fetch_body(
406+
&self,
407+
request_url: &str,
408+
ua: &str,
409+
referer: &str,
410+
cookie: Option<&str>,
411+
use_curl: bool,
412+
) -> AppResult<(u16, String)> {
413+
if use_curl {
414+
return Self::fetch_via_curl(request_url, ua, referer, cookie).await;
415+
}
416+
let mut req = self
268417
.client
269-
.get(&request_url)
270-
.header("User-Agent", USER_AGENT)
271-
.header(
272-
"Referer",
273-
format!("https://www.coupang.com/vp/products/{product_id}"),
274-
)
418+
.get(request_url)
419+
.header("User-Agent", ua)
420+
.header("Referer", referer)
275421
.header("Accept", "application/json, text/plain, */*")
276422
.header("Accept-Language", "ko-KR,ko;q=0.9,en;q=0.8")
277423
.header("sec-fetch-dest", "empty")
278424
.header("sec-fetch-mode", "cors")
279-
.header("sec-fetch-site", "same-origin")
280-
.send()
281-
.await
282-
.map_err(|e| AppError::Internal(e.into()))?;
283-
284-
// 상태코드를 먼저 검사해 실패 원인이 보이도록 한다(403 anti-bot 등).
285-
let status = resp.status();
425+
.header("sec-fetch-site", "same-origin");
426+
if let Some(c) = cookie {
427+
req = req.header("Cookie", c);
428+
}
429+
let resp = req.send().await.map_err(|e| AppError::Internal(e.into()))?;
430+
let status = resp.status().as_u16();
286431
let body = resp
287432
.text()
288433
.await
289434
.map_err(|e| AppError::Internal(e.into()))?;
290-
if !status.is_success() {
291-
let snippet: String = body.chars().take(200).collect();
292-
return Err(AppError::Internal(anyhow::anyhow!(
293-
"쿠팡 리뷰 API 응답 실패: HTTP {status} — {snippet}"
294-
)));
295-
}
296-
297-
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
298-
let snippet: String = body.chars().take(200).collect();
299-
AppError::Internal(anyhow::anyhow!(
300-
"쿠팡 리뷰 응답이 JSON 이 아님({e}): {snippet}"
301-
))
302-
})?;
303-
304-
let mut reviews = Self::parse_reviews(&json);
305-
reviews.truncate(limit as usize);
306-
307-
let product_name =
308-
Self::find_product_name(&json).unwrap_or_else(|| format!("쿠팡 상품 {product_id}"));
309-
310-
Ok(ProductReviews {
311-
url: url.to_string(),
312-
product_name,
313-
reviews,
314-
is_mine: false,
315-
})
435+
Ok((status, body))
316436
}
317437
}
318438

@@ -402,10 +522,11 @@ mod tests {
402522

403523
#[test]
404524
fn build_review_api_url_uses_next_api() {
405-
let url = HttpCoupangCrawler::build_review_api_url("7297090513", 50);
525+
let url = HttpCoupangCrawler::build_review_api_url("7297090513", 2, 30);
406526
assert!(url.contains("www.coupang.com/next-api/review"));
407527
assert!(url.contains("productId=7297090513"));
408-
assert!(url.contains("size=50"));
528+
assert!(url.contains("page=2"));
529+
assert!(url.contains("size=30"));
409530
assert!(url.contains("ratingSummary=true"));
410531
}
411532

backend/src/adapters/gemini/analyzer.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,12 @@ impl AiAnalyzer for GeminiAiAnalyzer {
282282
"parts": [{ "text": prompt }],
283283
}],
284284
"generationConfig": {
285-
"maxOutputTokens": 4096,
285+
// gemini-2.5-flash 는 thinking 모델이라 thinking 토큰이 출력 예산을
286+
// 잠식해 JSON 이 잘릴 수 있다(EOF while parsing). 구조화 추출 작업이므로
287+
// thinkingBudget=0 으로 thinking 을 끄고 출력 예산을 충분히 확보한다.
288+
"maxOutputTokens": 8192,
286289
"responseMimeType": "application/json",
290+
"thinkingConfig": { "thinkingBudget": 0 },
287291
},
288292
});
289293

0 commit comments

Comments
 (0)