"""Scrape de tendencias del TikTok Creative Center via su API JSON interna. El TikTok Creative Center (https://ads.tiktok.com/business/creativecenter/) es una SPA JS-rendered, pero alimenta sus rankings desde una API interna documentada de facto bajo `https://ads.tiktok.com/creative_radar_api/v1/popular_trend/...`. Esta funcion habla DIRECTAMENTE con ese endpoint usando `requests` con headers realistas, evitando el coste de un navegador headless cuando el endpoint responde. ADVERTENCIA: el endpoint interno cambia sin aviso, puede exigir token anti-bot y desde IPs de datacenter/headless suele devolver 403 o listas vacias. La funcion falla con una excepcion clara cuando el endpoint no responde como se espera. La alternativa robusta para entornos bloqueados es el browser MCP/CDP del ecosistema navegando el Creative Center con una sesion real (ver `## Gotchas` del .md). """ from __future__ import annotations import requests # Endpoints internos del Creative Center por tipo de tendencia. Son APIs de facto # (no publicas ni versionadas como contrato) y pueden romperse en cualquier deploy # de TikTok. Se mantienen aqui en un solo sitio para facilitar el parcheo. _BASE = "https://ads.tiktok.com/creative_radar_api/v1/popular_trend" _ENDPOINTS: dict[str, str] = { "hashtag": f"{_BASE}/hashtag/list", "song": f"{_BASE}/song/list", "creator": f"{_BASE}/creator/list", "video": f"{_BASE}/list", } # Periodos validos del Creative Center (en dias). El endpoint rechaza otros valores. _VALID_PERIODS = {7, 30, 120} _HEADERS = { "User-Agent": ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" ), "Accept": "application/json, text/plain, */*", "Accept-Language": "en-US,en;q=0.9,es;q=0.8", "Referer": "https://ads.tiktok.com/business/creativecenter/inspiration/popular/hashtag/pc/en", "Origin": "https://ads.tiktok.com", # El Creative Center exige este header para servir JSON; sin el devuelve HTML. "anonymous-user-id": "", "timestamp": "", "user-sign": "", } def _to_int(value: object) -> int | None: """Convierte un valor numerico del payload a int, o None si no es parseable.""" if value is None: return None try: # Algunos campos vienen como string ("1234567") o float (1234567.0). return int(float(value)) except (TypeError, ValueError): return None def _to_float(value: object) -> float | None: """Convierte un valor numerico del payload a float, o None si no es parseable.""" if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _extract_items(payload: dict) -> list[dict]: """Localiza la lista de items dentro del JSON, tolerando variaciones del schema. El Creative Center ha servido la lista bajo distintas rutas a lo largo del tiempo (`data.list`, `data.hashtags`, `data.items`, ...). Se prueban en orden. """ data = payload.get("data") if not isinstance(data, dict): return [] for key in ("list", "hashtags", "songs", "creators", "videos", "items"): candidate = data.get(key) if isinstance(candidate, list): return candidate # Fallback: la primera lista no vacia que aparezca dentro de data. for value in data.values(): if isinstance(value, list) and value: return value return [] def _row_from_item(item: dict, country: str, kind: str, fallback_rank: int) -> dict: """Normaliza un item crudo del payload a la fila canonica de `tiktok_trends`. Claves de salida (1:1 con la tabla Postgres): country, kind, name, rank, views, growth_pct, industry, url. Tolera nombres de campo distintos por tipo de kind. """ name = ( item.get("hashtag_name") or item.get("title") or item.get("name") or item.get("nickname") or item.get("song_title") or item.get("music_name") or item.get("keyword") ) rank = _to_int(item.get("rank")) or _to_int(item.get("trend_rank")) if rank is None: rank = fallback_rank # Volumen de visualizaciones / publicaciones segun el tipo de tendencia. views = ( _to_int(item.get("video_views")) or _to_int(item.get("views")) or _to_int(item.get("publish_cnt")) or _to_int(item.get("post_count")) or _to_int(item.get("play_count")) ) # El Creative Center expresa el crecimiento como ratio (0.42) o porcentaje (42). growth_raw = item.get("trend") or item.get("rank_diff") or item.get("growth") growth_pct = _to_float(growth_raw) if growth_pct is not None and -1.0 <= growth_pct <= 1.0: # Heuristica: si viene como ratio en [-1,1], normalizar a porcentaje. growth_pct = round(growth_pct * 100.0, 2) industry = None industries = item.get("industry_info") or item.get("industry") if isinstance(industries, dict): industry = industries.get("value") or industries.get("label") elif isinstance(industries, list) and industries: first = industries[0] industry = first.get("value") if isinstance(first, dict) else str(first) elif isinstance(industries, str): industry = industries url = item.get("url") or item.get("link") if not url and kind == "hashtag" and name: slug = str(name).lstrip("#") url = ( "https://ads.tiktok.com/business/creativecenter/hashtag/" f"{slug}/pc/en" ) return { "country": country, "kind": kind, "name": str(name) if name is not None else None, "rank": rank, "views": views, "growth_pct": growth_pct, "industry": industry, "url": url, } def scrape_tiktok_creative( country: str = "ES", kind: str = "hashtag", limit: int = 50, period: int = 7, ) -> list[dict]: """Capta tendencias del TikTok Creative Center via su API JSON interna. Args: country: codigo ISO de pais del ranking (ej. "ES", "US", "MX"). El Creative Center segmenta las tendencias por mercado. kind: tipo de tendencia. Uno de: "hashtag" (default, el mas estable), "song", "creator", "video". limit: numero maximo de filas a devolver (el endpoint pagina de 50 en 50). period: ventana temporal en dias. Validos: 7 (default), 30, 120. Returns: Lista de dicts con EXACTAMENTE las claves: country, kind, name, rank, views, growth_pct, industry, url. Mapea 1:1 con la tabla Postgres `tiktok_trends` (sin id/snapshot_date/scraped_at). `views` es int|None, `growth_pct` es float|None, `rank` es int|None. Devuelve [] si el endpoint responde OK pero sin items para el segmento solicitado. Raises: ValueError: si `kind` o `period` no son validos. RuntimeError: si el endpoint interno no responde como JSON util (HTTP de error, anti-bot, cambio de schema, bloqueo desde datacenter/headless). El mensaje indica el codigo HTTP o la causa para diagnostico. """ if kind not in _ENDPOINTS: raise ValueError( f"kind invalido: {kind!r}. Validos: {sorted(_ENDPOINTS)}" ) if period not in _VALID_PERIODS: raise ValueError( f"period invalido: {period}. Validos: {sorted(_VALID_PERIODS)}" ) endpoint = _ENDPOINTS[kind] rows: list[dict] = [] page = 1 page_size = 50 session = requests.Session() session.headers.update(_HEADERS) while len(rows) < limit: params = { "page": page, "limit": page_size, "period": period, "country_code": country, "sort_by": "popular", } try: resp = session.get(endpoint, params=params, timeout=15) except requests.RequestException as exc: raise RuntimeError( "TikTok Creative Center: fallo de red contactando el endpoint " f"interno {endpoint!r}: {exc}. Alternativa: usar el browser " "MCP/CDP del ecosistema con sesion real (ver .md ## Gotchas)." ) from exc if resp.status_code == 403: raise RuntimeError( "TikTok Creative Center devolvio 403 (anti-bot / IP de " "datacenter bloqueada). El endpoint JSON interno requiere " "tokens de sesion (anonymous-user-id/user-sign) que no se " "pueden falsear desde headless. Alternativa robusta: browser " "MCP/CDP navegando el Creative Center con sesion real." ) if resp.status_code != 200: raise RuntimeError( f"TikTok Creative Center devolvio HTTP {resp.status_code} para " f"{endpoint!r}. El endpoint interno pudo cambiar de ruta o de " "contrato (no es una API publica versionada)." ) try: payload = resp.json() except ValueError as exc: raise RuntimeError( "TikTok Creative Center no devolvio JSON (probable HTML de " "challenge o pagina de login). El endpoint interno cambio o " "exige sesion real. Alternativa: browser MCP/CDP." ) from exc # TikTok envuelve la respuesta en {code, msg, data}. code != 0 = error logico. code = payload.get("code") if code not in (0, None): raise RuntimeError( f"TikTok Creative Center respondio code={code} " f"({payload.get('msg', 'sin mensaje')}). El endpoint interno " "rechazo la peticion (parametros o anti-bot)." ) items = _extract_items(payload) if not items: break for offset, item in enumerate(items): if not isinstance(item, dict): continue rank_fallback = (page - 1) * page_size + offset + 1 rows.append(_row_from_item(item, country, kind, rank_fallback)) if len(rows) >= limit: break # Si la pagina vino incompleta, no hay mas resultados. if len(items) < page_size: break page += 1 return rows[:limit] if __name__ == "__main__": # Self-test honesto: import OK obligatorio + UN intento de fetch real que NO # falla la build por la red. Reporta si TikTok respondio o bloqueo/cambio. print("import OK: scrape_tiktok_creative cargado") try: sample = scrape_tiktok_creative(country="ES", kind="hashtag", limit=10, period=7) if sample: print(f"FETCH REAL OK: {len(sample)} filas. Primera: {sample[0]}") else: print( "FETCH REAL: el endpoint respondio pero sin items " "(segmento vacio o anti-bot silencioso)." ) except Exception as exc: # noqa: BLE001 -- self-test honesto, no propaga print(f"FETCH REAL FALLO (esperable desde headless/datacenter): {exc}")