@@ -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
235300const 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
0 commit comments