CoolFace
Apppublic

Igg0nk/cleaning

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
app.py394 linesDownload Raw Back to root
1"""2NODE 2 — DATA CLEANING API3===========================4Tích hợp từ: xulycrawl.py5Endpoint: POST /clean6Input:  { "metadata": [...], "heatmap": [...], "comments": [...] }   ← output từ Node 17Output: { "tables": { "combined": [...], "heatmap_detail": [...], "comments_detail": [...] } }8 9Tương đương chạy xulycrawl.py nhưng:10  - Nhận JSON thay vì đọc từ 4 file CSV11  - Trả JSON thay vì xuất 3 file CSV12"""13 14import os15import io16import json17import logging18from datetime import datetime, timezone19from typing import Optional20 21import numpy as np22import pandas as pd23from fastapi import FastAPI, HTTPException, Security, Depends, UploadFile, File24from fastapi.security import APIKeyHeader25from fastapi.middleware.cors import CORSMiddleware26from pydantic import BaseModel, Field27 28logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")29logger = logging.getLogger(__name__)30 31CLEANER_API_TOKEN = os.environ.get("CLEANER_API_TOKEN", "")32GDRIVE_FOLDER_ID  = os.environ.get("GDRIVE_FOLDER_ID", "")33 34app = FastAPI(title="Node 2 — Data Cleaning API", version="1.0.0")35app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])36 37api_key_header = APIKeyHeader(name="X-API-Token", auto_error=False)38 39def verify_token(token: str = Security(api_key_header)):40    if CLEANER_API_TOKEN and token != CLEANER_API_TOKEN:41        raise HTTPException(status_code=403, detail="Invalid API token")42    return token43 44 45# ── Schemas ───────────────────────────────────────────────────────46class CleanRequest(BaseModel):47    # Dữ liệu thô từ Node 1 (tương ứng 4 file CSV mà xulycrawl.py đọc)48    metadata:      list[dict] = Field(...,    description="result_metadata_subtitles")49    heatmap:       list[dict] = Field(default=[], description="result_heatmap")50    comments:      list[dict] = Field(default=[], description="result_comments")51    desc:          list[dict] = Field(default=[], description="youtube_data_video Sheet1 (nếu có)")52    output_mode:   str        = Field("json", description="'json' | 'gdrive'")53 54class CleanResponse(BaseModel):55    status:      str56    cleaned_at:  str57    stats:       dict58    tables:      Optional[dict] = None59    files:       Optional[dict] = None60    log:         list = []61 62 63# ═══════════════════════════════════════════════════════════════════64# BƯỚC 2: Chuẩn hóa tên cột  ← xulycrawl.py Bước 265# ═══════════════════════════════════════════════════════════════════66def normalize_video_id(df: pd.DataFrame) -> pd.DataFrame:67    """68    Đổi tên cột về chuẩn 'video_id' và xóa khoảng trắng thừa.69    Giống hệt xulycrawl.py Bước 2.70    """71    df = df.copy()72    df.rename(columns={"Video ID": "video_id"}, inplace=True, errors="ignore")73    if "video_id" in df.columns:74        df["video_id"] = df["video_id"].astype(str).str.strip()75    return df76 77 78# ═══════════════════════════════════════════════════════════════════79# BƯỚC 2.5: Làm sạch dữ liệu  ← xulycrawl.py Bước 2.580# ═══════════════════════════════════════════════════════════════════81def clean_comments(df_comm: pd.DataFrame) -> tuple[pd.DataFrame, list]:82    log = []83 84    # Xóa trùng lặp (giống xulycrawl.py)85    before = len(df_comm)86    if "id" in df_comm.columns:87        df_comm.drop_duplicates(subset=["id"], inplace=True)88    else:89        df_comm.drop_duplicates(inplace=True)90    df_comm.reset_index(drop=True, inplace=True)91    log.append(f"[Comments] Duplicates: {before} → {len(df_comm)} (xóa {before - len(df_comm)})")92 93    # Chuẩn hóa published_at94    if "published_at" in df_comm.columns:95        df_comm["published_at"] = pd.to_datetime(df_comm["published_at"], errors="coerce")96        log.append(f"[Comments] published_at → datetime")97 98    # likes → số99    if "likes" in df_comm.columns:100        df_comm["likes"] = pd.to_numeric(df_comm["likes"], errors="coerce").fillna(0).astype(int)101 102    return df_comm, log103 104 105def clean_heatmap(df_map: pd.DataFrame) -> tuple[pd.DataFrame, list]:106    log = []107    before = len(df_map)108    df_map.drop_duplicates(inplace=True)109    df_map.reset_index(drop=True, inplace=True)110    log.append(f"[Heatmap] Duplicates: {before} → {len(df_map)} (xóa {before - len(df_map)})")111    return df_map, log112 113 114def clean_desc(df_desc: pd.DataFrame) -> tuple[pd.DataFrame, list]:115    log = []116    df_desc.drop_duplicates(subset=["video_id"], inplace=True)117    df_desc.reset_index(drop=True, inplace=True)118 119    # Chuẩn hóa cột số (Views, Likes, Comments, Duration) — giống xulycrawl.py120    cols_to_numeric = ["Views", "Likes", "Comments", "Duration"]121    for col in cols_to_numeric:122        if col in df_desc.columns:123            df_desc[col] = (124                pd.to_numeric(125                    df_desc[col].astype(str).str.replace(",", ""),126                    errors="coerce"127                ).fillna(0)128            )129            log.append(f"[Desc] '{col}' → numeric")130 131    return df_desc, log132 133 134# ═══════════════════════════════════════════════════════════════════135# BƯỚC 3+4: Merge + Thống kê  ← xulycrawl.py Bước 3 & 4136# ═══════════════════════════════════════════════════════════════════137def merge_and_aggregate(138    df_meta:  pd.DataFrame,139    df_map:   pd.DataFrame,140    df_comm:  pd.DataFrame,141    df_desc:  pd.DataFrame,142    log:      list,143) -> pd.DataFrame:144    """145    Tái hiện đúng logic merge của xulycrawl.py Bước 3 & 4.146    """147    # Base = df_desc (nếu có), nếu không thì df_meta148    if not df_desc.empty:149        df_combined = df_desc.copy()150        df_combined = pd.merge(df_combined, df_meta, on="video_id", how="left",151                               suffixes=("", "_meta"))152        log.append(f"[Merge] Base=desc ({len(df_desc)}) + meta ({len(df_meta)})")153    else:154        df_combined = df_meta.copy()155        log.append(f"[Merge] Base=meta ({len(df_meta)}) — không có desc")156 157    # Thống kê heatmap (Bước 4 xulycrawl.py)158    if not df_map.empty:159        heatmap_stats = df_map.groupby("video_id").agg(160            heatmap_avg_retention = ("value", "mean"),161            heatmap_max_retention = ("value", "max"),162            heatmap_min_retention = ("value", "min"),163            heatmap_volatility    = ("value", "std"),164            heatmap_duration      = ("start_time", "max"),165        ).reset_index()166        df_combined = pd.merge(df_combined, heatmap_stats, on="video_id", how="left")167        log.append(f"[Merge] + heatmap_stats ({len(heatmap_stats)} videos)")168 169    # Thống kê comments (Bước 4 xulycrawl.py)170    if not df_comm.empty:171        comment_stats = df_comm.groupby("video_id").agg(172            crawled_comment_count = ("text",   "count"),173            total_comment_likes   = ("likes",  "sum"),174            unique_commenters     = ("author", pd.Series.nunique),175        ).reset_index()176        df_combined = pd.merge(df_combined, comment_stats, on="video_id", how="left")177        log.append(f"[Merge] + comment_stats ({len(comment_stats)} videos)")178 179    # EDA summary (Bước 2.5 xulycrawl.py)180    num_videos_crawled = df_meta["video_id"].nunique() if not df_meta.empty else 0181    num_videos_desc    = df_desc["video_id"].nunique() if not df_desc.empty else num_videos_crawled182 183    if num_videos_desc > 0:184        rate = num_videos_crawled / num_videos_desc * 100185        log.append(f"[EDA] Crawl thành công: {num_videos_crawled}/{num_videos_desc} ({rate:.1f}%)")186 187    if not df_map.empty and "start_time" in df_map.columns:188        avg_dur = df_map.groupby("video_id")["start_time"].max().mean()189        log.append(f"[EDA] TB thời lượng video (heatmap): {avg_dur:.0f}s")190 191    if not df_comm.empty and "author" in df_comm.columns:192        top_commenter = df_comm["author"].value_counts().index[0]193        log.append(f"[EDA] Top commenter: {top_commenter} ({df_comm['author'].value_counts().iloc[0]} comments)")194 195    return df_combined196 197 198# ═══════════════════════════════════════════════════════════════════199# BƯỚC 5: Sắp xếp chi tiết  ← xulycrawl.py Bước 5200# ═══════════════════════════════════════════════════════════════════201def prepare_detail_tables(202    df_map:  pd.DataFrame,203    df_comm: pd.DataFrame,204) -> tuple[pd.DataFrame, pd.DataFrame]:205    """206    Sắp xếp chi tiết giống xulycrawl.py Bước 5.207    df_map_sorted  →  youtube_heatmap_detail.csv208    df_comm_sorted →  youtube_comments_detail.csv209    """210    df_map_sorted = df_map.sort_values(211        by=["video_id", "start_time"]212    ).reset_index(drop=True) if not df_map.empty else df_map213 214    sort_cols = ["video_id"] + (["published_at"] if "published_at" in df_comm.columns else [])215    df_comm_sorted = df_comm.sort_values(216        by=sort_cols,217        ascending=[True] + [False] * (len(sort_cols) - 1)218    ).reset_index(drop=True) if not df_comm.empty else df_comm219 220    return df_map_sorted, df_comm_sorted221 222 223# ═══════════════════════════════════════════════════════════════════224# PIPELINE TỔNG  (tái hiện toàn bộ xulycrawl.py)225# ═══════════════════════════════════════════════════════════════════226def run_clean_pipeline(req: CleanRequest) -> tuple[dict, dict, list]:227    log = []228 229    # Build DataFrames230    df_meta  = normalize_video_id(pd.DataFrame(req.metadata))231    df_map   = normalize_video_id(pd.DataFrame(req.heatmap))   if req.heatmap   else pd.DataFrame()232    df_comm  = normalize_video_id(pd.DataFrame(req.comments))  if req.comments  else pd.DataFrame()233    df_desc  = normalize_video_id(pd.DataFrame(req.desc))      if req.desc      else pd.DataFrame()234 235    # Drop duplicates metadata236    df_meta.drop_duplicates(subset=["video_id"], inplace=True)237 238    # Làm sạch từng bảng239    if not df_comm.empty:240        df_comm, clog = clean_comments(df_comm)241        log.extend(clog)242 243    if not df_map.empty:244        df_map, mlog = clean_heatmap(df_map)245        log.extend(mlog)246 247    if not df_desc.empty:248        df_desc, dlog = clean_desc(df_desc)249        log.extend(dlog)250 251    # Merge + aggregate252    df_combined = merge_and_aggregate(df_meta, df_map, df_comm, df_desc, log)253 254    # Detail tables (sorted)255    df_map_sorted, df_comm_sorted = prepare_detail_tables(df_map, df_comm)256 257    log.append(f"[Output] combined={len(df_combined)} | heatmap_detail={len(df_map_sorted)} | comments_detail={len(df_comm_sorted)}")258 259    tables = {260        # 3 bảng tương ứng 3 file CSV của xulycrawl.py:261        "combined":          df_combined,     # youtube_all_data_combined.csv262        "heatmap_detail":    df_map_sorted,   # youtube_heatmap_detail.csv263        "comments_detail":   df_comm_sorted,  # youtube_comments_detail.csv264    }265 266    stats = {267        "combined_rows":        len(df_combined),268        "heatmap_detail_rows":  len(df_map_sorted),269        "comments_detail_rows": len(df_comm_sorted),270        "unique_videos":        df_meta["video_id"].nunique(),271        "unique_commenters":    int(df_comm["author"].nunique()) if not df_comm.empty and "author" in df_comm.columns else 0,272    }273 274    return tables, stats, log275 276 277# ═══════════════════════════════════════════════════════════════════278# GDRIVE UPLOAD279# ═══════════════════════════════════════════════════════════════════280def upload_tables_to_gdrive(tables: dict) -> dict:281    import io282    from googleapiclient.discovery import build283    from googleapiclient.http import MediaIoBaseUpload284    from google.oauth2 import service_account285 286    creds_json = os.environ.get("GDRIVE_CREDENTIALS", "{}")287    try:288        creds = service_account.Credentials.from_service_account_info(289            json.loads(creds_json),290            scopes=["https://www.googleapis.com/auth/drive"]291        )292        drive = build("drive", "v3", credentials=creds)293    except Exception as e:294        return {"error": f"Drive auth failed: {e}"}295 296    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")297    links = {}298    name_map = {299        "combined":        "youtube_all_data_combined",300        "heatmap_detail":  "youtube_heatmap_detail",301        "comments_detail": "youtube_comments_detail",302    }303    for key, df in tables.items():304        if df.empty:305            continue306        buf = io.BytesIO(df.to_csv(index=False).encode("utf-8-sig"))307        meta = {308            "name": f"{name_map.get(key, key)}_{timestamp}.csv",309            "mimeType": "text/csv",310            "parents": [GDRIVE_FOLDER_ID] if GDRIVE_FOLDER_ID else [],311        }312        f = drive.files().create(313            body=meta,314            media_body=MediaIoBaseUpload(buf, mimetype="text/csv"),315            fields="id,webViewLink"316        ).execute()317        links[key] = f.get("webViewLink", "")318    return links319 320 321# ═══════════════════════════════════════════════════════════════════322# ENDPOINTS323# ═══════════════════════════════════════════════════════════════════324@app.get("/health")325def health():326    return {"status": "ok", "service": "Node 2 — Cleaner",327            "timestamp": datetime.now(timezone.utc).isoformat()}328 329 330@app.post("/clean", response_model=CleanResponse)331def clean(req: CleanRequest, _token=Depends(verify_token)):332    """333    Tái hiện toàn bộ xulycrawl.py nhưng nhận JSON và trả JSON.334 335    Output tables:336      - combined        →  youtube_all_data_combined.csv337      - heatmap_detail  →  youtube_heatmap_detail.csv338      - comments_detail →  youtube_comments_detail.csv339    """340    if not req.metadata:341        raise HTTPException(400, "metadata không được rỗng")342 343    tables, stats, log = run_clean_pipeline(req)344    now = datetime.now(timezone.utc).isoformat()345 346    if req.output_mode == "json":347        return CleanResponse(348            status="ok", cleaned_at=now, stats=stats,349            tables={350                k: df.replace({np.nan: None}).to_dict(orient="records")351                for k, df in tables.items()352            },353            log=log,354        )355 356    elif req.output_mode == "gdrive":357        links = upload_tables_to_gdrive(tables)358        return CleanResponse(359            status="ok" if "error" not in links else "partial",360            cleaned_at=now, stats=stats,361            files=links, log=log,362        )363 364    else:365        raise HTTPException(400, f"output_mode '{req.output_mode}' chưa hỗ trợ")366 367 368@app.post("/clean/upload-csv")369async def clean_from_csv(370    metadata_csv:  UploadFile = File(...),371    heatmap_csv:   UploadFile = File(None),372    comments_csv:  UploadFile = File(None),373    desc_csv:      UploadFile = File(None),374    _token=Depends(verify_token),375):376    """Upload trực tiếp 4 file CSV như xulycrawl.py đọc — tiện để test."""377    async def read_csv(f):378        if f is None: return []379        b = await f.read()380        return pd.read_csv(io.BytesIO(b)).to_dict(orient="records")381 382    req = CleanRequest(383        metadata  = await read_csv(metadata_csv),384        heatmap   = await read_csv(heatmap_csv),385        comments  = await read_csv(comments_csv),386        desc      = await read_csv(desc_csv),387    )388    return clean(req, _token=None)389 390 391if __name__ == "__main__":392    import uvicorn393    uvicorn.run(app, host="0.0.0.0", port=7860)394