#!/usr/bin/env python3
"""
Rakuten Japan Review Crawler + Performance Marketing Dashboard — Google Colab v4
(requests 기반 크롤러 + 자동 대시보드 생성)

사용법: Google Colab에서 셀 8개를 순서대로 실행
  셀 1: 패키지 설치
  셀 2: 설정 (URL, 딜레이 등)
  셀 3: 공통 함수 (파싱, fetch, 정규화)
  셀 4: 성별/연령 파싱 검증
  셀 5: Canonical ID 교정 + Pagination 검증
  셀 6: 전체 크롤링 → Excel 저장
  셀 7: 대시보드 템플릿
  셀 8: 대시보드 생성 + xlsx/html 다운로드
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 1: 패키지 설치
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_1 = """
# 📦 패키지 설치
!pip install requests pandas openpyxl tqdm beautifulsoup4 lxml -q
print("✅ 설치 완료!")
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 2: 설정
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_2 = """
# ============================================================
# ⚙️ 설정 (여기만 수정)
# ============================================================

PRODUCT_URL = "https://review.rakuten.co.jp/item/1/429204_10000003"

DELAY                      = 1.5
HARD_CAP                   = 50
STOP_AFTER_DUPLICATE_PAGES = 2
BRAND_NAME                 = None  # None이면 shop_id 자동 사용
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 3: 공통 함수
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_3 = r"""
# ============================================================
# 🔧 셀 3 | 공통 함수
# ============================================================

import re, json, time, html as html_module
import requests
import pandas as pd
from tqdm.auto import tqdm
from datetime import datetime
from google.colab import files

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "ja,en;q=0.8",
}

AGE_MAP = {
    "10代": "10대", "20代": "20대", "30代": "30대",
    "40代": "40대", "50代": "50대", "60代": "60대", "70代": "70대",
    "10s":  "10대", "20s":  "20대", "30s":  "30대",
    "40s":  "40대", "50s":  "50대", "60s":  "60대",
    "10":   "10대", "20":   "20대", "30":   "30대",
    "40":   "40대", "50":   "50대", "60":   "60대",
}

GENDER_MAP = {
    "female": "여성", "女性": "여성", "女": "여성", "f": "여성", "1": "여성",
    "male":   "남성", "男性": "남성", "男": "남성", "m": "남성", "2": "남성",
}

# ── URL 파싱 ──────────────────────────────────────────────
def parse_product_url(url: str):
    url = url.strip()
    for pat in [
        r'review\.rakuten\.co\.jp/(?:review/)?item/\d+/([^/_]+)_([^/_.#?]+)',
        r'item\.rakuten\.co\.jp/([^/]+)/([^/?#]+)',
    ]:
        m = re.search(pat, url)
        if m:
            return m.group(1), m.group(2)
    raise ValueError(f"URL 파싱 실패: {url}")

# ── 페이지별 URL 생성 ─────────────────────────────────────
def make_review_url(shop_id: str, item_code: str, page: int) -> str:
    base = f"https://review.rakuten.co.jp/item/1/{shop_id}_{item_code}"
    return base if page == 1 else f"{base}?p={page}"

# ── fetch ─────────────────────────────────────────────────
def fetch_page(url: str, retries: int = 2) -> str:
    last_err = None
    for attempt in range(retries + 1):
        try:
            r = requests.get(url, headers=HEADERS, timeout=15)
            r.raise_for_status()
            r.encoding = "utf-8"
            return r.text
        except Exception as e:
            last_err = e
            if attempt < retries:
                time.sleep(2 ** attempt)
    raise last_err

# ── __INITIAL_STATE__ 추출 ────────────────────────────────
def extract_state(html: str) -> dict:
    for pat in [
        r'window\.__INITIAL_STATE__\s*=\s*(\{.+?\});\s*(?:window|</script)',
        r'window\.__INITIAL_STATE__\s*=\s*(\{.+?\});',
        r'__INITIAL_STATE__\s*=\s*(\{.+?\})\s*(?:;|</script)',
    ]:
        m = re.search(pat, html, re.DOTALL)
        if m:
            try:
                return json.loads(m.group(1))
            except json.JSONDecodeError:
                continue
    return {}

# ── HTML에서 성별/연령 추출 ───────────────────────────────
def parse_age_gender_from_html(html: str) -> list:
    """
    실제 Rakuten HTML 구조:
    <div class="...color-gray-dark--3Wllp...">女性</div>
    <div class="...color-gray-dark--3Wllp...">40代</div>
    → 순서대로 (gender, age) 튜플 리스트 반환
    """
    values = re.findall(
        r'class="[^"]*color-gray-dark--3Wllp[^"]*"[^>]*>\s*'
        r'(女性|男性|10代|20代|30代|40代|50代|60代|70代)\s*<',
        html
    )
    result = []
    i = 0
    while i < len(values):
        v = values[i]
        if v in ("女性", "男性"):
            gender = GENDER_MAP.get(v, v)
            if i + 1 < len(values) and re.match(r'\d{2}代', values[i + 1]):
                age = AGE_MAP.get(values[i + 1], values[i + 1])
                result.append((gender, age))
                i += 2
            else:
                result.append((gender, ""))
                i += 1
        elif re.match(r'\d{2}代', v):
            result.append(("", AGE_MAP.get(v, v)))
            i += 1
        else:
            i += 1
    return result

# ── 리뷰 정규화 ───────────────────────────────────────────
def normalize_review(r: dict) -> dict:
    try:
        sku_raw = r.get("skuInfo") or ""
        option  = re.sub(r'\s+', ' ', str(sku_raw).strip())
        return {
            "rating":     r.get("rating"),
            "reviewdate": r.get("postDate", ""),
            "nickname":   r.get("nickname", ""),
            "gender":     "",
            "age":        "",
            "option":     option,
            "body":       (r.get("body") or "").strip(),
            "orderdate":  r.get("orderDate", ""),
            "helpful":    r.get("helpfulCount", 0),
        }
    except Exception:
        return None

# ── 리뷰 파싱 (HTML 성별/연령 순서 매칭) ─────────────────
def parse_reviews_from_state(state: dict, html: str = "") -> list:
    rs = state.get("reviews", {})
    if not rs:
        return []
    data      = rs.get("data") or {}
    item_keys = rs.get("itemReviews", {}).get("keys")

    if not item_keys:
        items = rs.get("itemReviews", {}).get("items")
        if items and isinstance(items[0], dict):
            reviews = [normalize_review(r) for r in items if r]
        else:
            return []
    else:
        reviews = [n for k in item_keys
                   for r in [data.get(k)] if r
                   for n in [normalize_review(r)] if n]

    # HTML에서 성별/연령 추출 후 순서 매칭
    if html and reviews:
        ag_list = parse_age_gender_from_html(html)
        ag_idx  = 0
        for rv in reviews:
            nickname = rv.get("nickname", "")
            if nickname in ("購入者さん", "구매자", ""):
                rv["gender"] = ""
                rv["age"]    = ""
            else:
                if ag_idx < len(ag_list):
                    rv["gender"] = ag_list[ag_idx][0]
                    rv["age"]    = ag_list[ag_idx][1]
                    ag_idx += 1
                else:
                    rv["gender"] = ""
                    rv["age"]    = ""

    return reviews

def parse_any(html: str) -> list:
    state = extract_state(html)
    return parse_reviews_from_state(state, html) if state else []

def review_id(r: dict) -> tuple:
    return (
        str(r.get("reviewdate", "")),
        str(r.get("rating", "")),
        str(r.get("body", ""))[:40],
    )

# ── 총 리뷰 수 추출 ───────────────────────────────────────
def extract_total_count(state: dict, html: str) -> int:
    rs = state.get("reviews", {})
    for v in [
        rs.get("itemReviews", {}).get("totalCount"),
        rs.get("itemReviews", {}).get("total"),
        rs.get("itemInfo",    {}).get("reviewCount"),
        rs.get("totalCount"),
    ]:
        if isinstance(v, int) and v > 0:
            return v
    if html:
        m = re.search(r'([\d,]+)件', html)
        if m:
            return int(m.group(1).replace(",", ""))
    return 0

# ── 상품명 추출 ───────────────────────────────────────────
def extract_product_name(state: dict, html: str, fallback: str) -> str:
    for path in [
        ["item", "data", "name"],
        ["reviews", "itemInfo", "itemName"],
        ["itemInfo", "name"],
        ["item", "name"],
    ]:
        node = state
        for k in path:
            if not isinstance(node, dict):
                node = None; break
            node = node.get(k)
        if isinstance(node, str) and node:
            return node.strip()
    if html:
        m = re.search(r'<title>(.+?)</title>', html, re.DOTALL)
        if m:
            return re.sub(r'\s+', ' ', m.group(1).strip())
    return fallback

# ── Canonical ID 추출 ─────────────────────────────────────
def extract_canonical_ids(html: str) -> tuple:
    for pat in [
        r'shopId["\s:=]+(\w+)["\s,&]+itemId["\s:=]+(\w+)',
        r'shopId%3D(\w+).*?itemId%3D(\w+)',
        r'/item/\d+/(\w+)_(\w+)',
    ]:
        m = re.search(pat, html, re.DOTALL)
        if m:
            return m.group(1), m.group(2)
    return None, None

# ── 초기 파싱 ─────────────────────────────────────────────
shop_id, item_code = parse_product_url(PRODUCT_URL)
OUTPUT_PREFIX      = f"{shop_id}_{item_code}"

print(f"  shop_id  : {shop_id}")
print(f"  item_code: {item_code}")
print(f"  1페이지  : {make_review_url(shop_id, item_code, 1)}")
print(f"  2페이지  : {make_review_url(shop_id, item_code, 2)}")
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 4: 성별/연령 파싱 검증
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_4 = r"""
# ============================================================
# 🔍 셀 4 | 성별/연령 파싱 검증
# ============================================================

html_test = fetch_page(make_review_url(shop_id, item_code, 1))
ag_list   = parse_age_gender_from_html(html_test)

print(f"추출된 성별/연령 쌍: {len(ag_list)}개\n")
for i, (g, a) in enumerate(ag_list[:10]):
    print(f"  [{i+1}] 성별: {g or '미기재'} | 연령: {a or '미기재'}")
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 5: Canonical 교정 + Pagination 검증
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_5 = r"""
# ============================================================
# 🩺 Canonical 교정 + Pagination 검증
# ============================================================

print("📄 1페이지 fetch 중...")
html1  = fetch_page(make_review_url(shop_id, item_code, 1))
state1 = extract_state(html1)

# Canonical 교정
canonical_shop, canonical_item = extract_canonical_ids(html1)
if canonical_shop and canonical_item:
    print(f"🔍 Canonical ID: {canonical_shop}_{canonical_item}")
    if str(canonical_shop) != str(shop_id) or str(canonical_item) != str(item_code):
        print(f"⚠️  ID 불일치 → 교정")
        print(f"   이전: {shop_id}_{item_code}")
        print(f"   이후: {canonical_shop}_{canonical_item}")
        shop_id, item_code = canonical_shop, canonical_item
        OUTPUT_PREFIX = f"{shop_id}_{item_code}"
        time.sleep(DELAY)
        html1  = fetch_page(make_review_url(shop_id, item_code, 1))
        state1 = extract_state(html1)
else:
    print("ℹ️  Canonical ID 추출 생략")

reviews1            = parse_reviews_from_state(state1, html1)
total_count_claimed = extract_total_count(state1, html1)
product_name        = extract_product_name(
    state1, html1, fallback=f"{shop_id}/{item_code}"
)

print(f"\n✅ 1페이지 수집: {len(reviews1)}건")
print(f"✅ 총 리뷰 수  : {total_count_claimed}건")
print(f"✅ 상품명      : {product_name[:70]}")

if not reviews1:
    raise RuntimeError("❌ 1페이지 파싱 실패. URL을 다시 확인하세요.")

# Pagination 검증
print("\n📄 2페이지 fetch 중...")
time.sleep(DELAY)
try:
    html2    = fetch_page(make_review_url(shop_id, item_code, 2))
    reviews2 = parse_any(html2)
    ids1     = {review_id(r) for r in reviews1}
    ids2     = {review_id(r) for r in reviews2}
    new_in_2 = len(ids2 - ids1)
    overlap  = len(ids1 & ids2)
    print(f"✅ 2페이지 수집: {len(reviews2)}건")
    print(f"   └ 1↔2 중복: {overlap}건 | 신규: {new_in_2}건")
    if new_in_2 == 0:
        print("⚠️  1·2페이지 동일 → 1페이지 분량만 존재하거나 URL 패턴 문제")
    else:
        print(f"✅ Pagination 정상 → 전체 크롤링 진행 (예상 ~{total_count_claimed}건)")
except Exception as e:
    print(f"⚠️  2페이지 fetch 실패: {e}")
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 6: 전체 크롤링
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_6 = r"""
# ============================================================
# 🚀 전체 크롤링
# ============================================================

seen_ids      = set()
all_reviews   = []
dup_streak    = 0
page          = 1
max_page_seen = 0
target_hint   = total_count_claimed if total_count_claimed > 0 else None

pbar = tqdm(desc="크롤링", unit="p", total=None)

while page <= HARD_CAP:
    url = make_review_url(shop_id, item_code, page)
    try:
        html    = fetch_page(url, retries=2)
        reviews = parse_any(html)
    except Exception as e:
        tqdm.write(f"  ⚠️  {page}p fetch 실패: {type(e).__name__}")
        page += 1
        time.sleep(DELAY * 2)
        continue

    if not reviews:
        tqdm.write(f"  ⛔ {page}p: 파싱 0건 → 종료")
        break

    # ID 기반 중복 제거
    new = []
    for r in reviews:
        if r is None:
            continue
        rid = review_id(r)
        if rid not in seen_ids:
            seen_ids.add(rid)
            new.append(r)

    all_reviews.extend(new)
    max_page_seen = page

    postfix = {"page": page, "new": len(new), "total": len(all_reviews)}
    if target_hint:
        postfix["of"] = target_hint
    pbar.update(1)
    pbar.set_postfix(postfix)

    if len(new) == 0:
        dup_streak += 1
        tqdm.write(f"  ⚠️  {page}p: 신규 0건 ({dup_streak}/{STOP_AFTER_DUPLICATE_PAGES})")
        if dup_streak >= STOP_AFTER_DUPLICATE_PAGES:
            tqdm.write(f"  🏁 {STOP_AFTER_DUPLICATE_PAGES}회 연속 신규 없음 → 완료")
            break
    else:
        dup_streak = 0

    if target_hint and len(all_reviews) >= target_hint:
        tqdm.write(f"  🏁 목표 수량 달성 ({target_hint}건) → 종료")
        break

    page += 1
    time.sleep(DELAY)

pbar.close()

print(f"\n📦 최종 수집: {len(all_reviews)}건 / {max_page_seen}p")
if target_hint:
    print(f"   커버리지: {len(all_reviews)/target_hint*100:.1f}% ({len(all_reviews)}/{target_hint})")

# DataFrame 변환 + Excel 저장
df = pd.DataFrame(all_reviews)

print()
print(df["rating"].value_counts().sort_index(ascending=False).to_string())
print(f"기간: {df['reviewdate'].min()} ~ {df['reviewdate'].max()}")

# 연령/성별 분포 확인
print("\n=== age 분포 ===")
print(df["age"].value_counts(dropna=False).to_string())
print("\n=== gender 분포 ===")
print(df["gender"].value_counts(dropna=False).to_string())

xlsx_path = f"{OUTPUT_PREFIX}_reviews.xlsx"
df.to_excel(xlsx_path, index=False, engine="openpyxl")
print(f"\n✅ Excel 저장: {xlsx_path} ({len(df)}행)")
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 7: 대시보드 템플릿 (별도 파일 참조)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_7 = """
# ============================================================
# 📦 대시보드 템플릿 v4
# ============================================================
# 대시보드 HTML 템플릿이 매우 길어 별도 파일로 관리됩니다.
# rakuten_dashboard_template.html 파일을 참조하세요.
# 
# Colab에서는 아래와 같이 사용합니다:
# 1) 이 프로젝트의 rakuten_dashboard_template.html 파일 내용을 복사
# 2) DASHBOARD_TEMPLATE = r\"\"\"<복사한 내용>\"\"\" 형태로 셀에 붙여넣기
#
# 또는 직접 URL에서 로드:
import urllib.request
template_url = "https://raw.githubusercontent.com/your-repo/rakuten_dashboard_template.html"
# DASHBOARD_TEMPLATE = urllib.request.urlopen(template_url).read().decode('utf-8')

# 아래는 인라인 템플릿입니다 (셀에 붙여넣기 용):
print("💡 셀 7은 대시보드 HTML 템플릿입니다.")
print("   rakuten_dashboard_template.html 내용을 DASHBOARD_TEMPLATE 변수에 할당하세요.")
print("   자세한 내용은 프로젝트 README를 참조하세요.")
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 셀 8: 대시보드 생성 + 다운로드
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CELL_8 = r"""
# ============================================================
# 🎨 대시보드 생성 + 다운로드
# ============================================================
def normalize_date(s):
    if not s: return ""
    s = str(s).replace("/", "-").replace(".", "-")
    try: return datetime.strptime(s[:10], "%Y-%m-%d").strftime("%Y-%m-%d")
    except ValueError: return s[:10]

print("📋 df 컬럼:", df.columns.tolist(), f"({len(df)}행)")

dash_rows = []
for _, row in df.iterrows():
    rd = normalize_date(row.get("reviewdate", ""))
    od = normalize_date(row.get("orderdate", ""))
    try:
        ship = (datetime.strptime(rd, "%Y-%m-%d") - datetime.strptime(od, "%Y-%m-%d")).days
    except Exception:
        ship = -1
    body = str(row.get("body", "") or "")
    dash_rows.append({
        "rating":       int(row["rating"]) if pd.notna(row["rating"]) else 0,
        "reviewdate":   rd,
        "orderdate":    od,
        "nickname":     str(row.get("nickname", "")),
        "gender":       str(row.get("gender", "") or ""),
        "age":          str(row.get("age", "") or ""),
        "option":       str(row.get("option", "") or ""),
        "body":         body,
        "helpful":      int(row.get("helpful", 0) or 0),
        "shippingdays": int(ship),
        "month":        rd[:7] if rd else "",
        "length":       len(body),
    })

print(f"✅ dash_rows: {len(dash_rows)}건")
s = dash_rows[0] if dash_rows else {}
print(f"   샘플: gender='{s.get('gender')}' age='{s.get('age')}' month='{s.get('month')}'")

reviews_json = json.dumps(dash_rows, ensure_ascii=False)
brand = BRAND_NAME or str(shop_id).replace("-"," ").replace("_"," ").upper()

dashboard_html = (
    DASHBOARD_TEMPLATE
    .replace("__REVIEWS_JSON__", reviews_json)
    .replace("__BRAND_NAME__", html_module.escape(brand))
    .replace("__PRODUCT_NAME__", html_module.escape(product_name))
    .replace("__GENERATED_DATE__", datetime.now().strftime("%Y-%m-%d"))
    .replace("__REVIEW_COUNT__", str(len(dash_rows)))
)

dash_path = f"{OUTPUT_PREFIX}_dashboard.html"
with open(dash_path, "w", encoding="utf-8") as f:
    f.write(dashboard_html)

print(f"💾 {dash_path} ({len(dashboard_html):,}자)")
print("\n📥 다운로드 중...")
files.download(xlsx_path)
files.download(dash_path)
print(f"\n✨ 완료!  📄 {xlsx_path} ({len(df)}건)  🎨 {dash_path}")
"""

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 메인 (가이드 출력)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if __name__ == "__main__":
    print("=" * 60)
    print("  🛒 Rakuten Japan Review Crawler v4")
    print("  Performance Marketing Dashboard")
    print("=" * 60)
    print()
    print("Google Colab에서 아래 셀을 순서대로 실행하세요:")
    print()
    print("  셀 1: 패키지 설치")
    print("  셀 2: 설정 (URL, 딜레이 등)")
    print("  셀 3: 공통 함수 (파싱, fetch, 정규화)")
    print("  셀 4: 성별/연령 파싱 검증")
    print("  셀 5: Canonical ID 교정 + Pagination 검증")
    print("  셀 6: 전체 크롤링 → Excel 저장")
    print("  셀 7: 대시보드 템플릿 (DASHBOARD_TEMPLATE)")
    print("  셀 8: 대시보드 생성 + xlsx/html 다운로드")
    print()
    print("💡 대시보드 템플릿은 rakuten_dashboard_template.html을")
    print("   DASHBOARD_TEMPLATE 변수에 할당하세요.")
