winx_prinx-api / local_worker /role_backfiller.py
Sasha
feat: add backfill resume support and session/retry fixes for VODs
e98099c
Raw
History Blame Contribute Delete
10.9 kB
import os
import sys
import time
import requests
from dotenv import load_dotenv
# Load local environment settings
load_dotenv()
API_URL = os.getenv("API_URL", "http://localhost:3000")
API_KEY = os.getenv("API_KEY", "")
if not API_KEY:
print("[Error] API_KEY is missing in .env! Cannot push data to backend.")
sys.exit(1)
headers = {
"x-api-key": API_KEY,
"Content-Type": "application/json"
}
def get_all_streams():
"""Fetch all streams from the backend"""
try:
res = requests.get(f"{API_URL}/api/streams", headers=headers, timeout=10)
if res.status_code == 200:
data = res.json()
# API returns { streams: [...] } or just [...]
if isinstance(data, list):
return data
return data.get("streams", [])
except Exception as e:
print(f"[Error] Не удалось получить список стримов: {e}")
return []
def find_vod_id_by_stream_id(stream_id):
"""Try to find a VOD ID for a given Twitch stream ID using GQL"""
url = "https://gql.twitch.tv/gql"
gql_headers = {
"Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"User-Agent": "Mozilla/5.0"
}
payload = {
"query": f"""
query {{
channel(name: "{os.getenv('TWITCH_CHANNEL', 'winx_prinx')}") {{
videos(first: 30, type: ARCHIVE) {{
edges {{
node {{
id
broadcastType
lengthSeconds
publishedAt
stream {{
id
}}
}}
}}
}}
}}
}}
"""
}
try:
res = requests.post(url, json=payload, headers=gql_headers, timeout=10)
if res.status_code == 200:
data = res.json()
edges = data.get("data", {}).get("channel", {}).get("videos", {}).get("edges", [])
for edge in edges:
node = edge.get("node", {})
stream_info = node.get("stream") or {}
if stream_info.get("id") == str(stream_id):
return node.get("id")
except Exception as e:
pass
return None
def extract_vod_id(stream):
"""Extract VOD ID from stream record"""
twitch_id = str(stream.get("twitch_stream_id", ""))
if twitch_id.startswith("vod-"):
return twitch_id[4:]
# Live stream ID — try to find corresponding VOD
print(f" -> Поиск VOD для live stream ID {twitch_id}...")
vod_id = find_vod_id_by_stream_id(twitch_id)
if vod_id:
print(f" -> Найден VOD ID: {vod_id}")
else:
print(f" -> VOD не найден (стрим мог быть удалён или VOD недоступен)")
return vod_id
def download_vod_roles(vod_id):
"""Download chat log of a past VOD and extract only roles, with retry on failure"""
roles_dict = {}
url = "https://gql.twitch.tv/gql"
gql_headers = {
"Client-Id": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"User-Agent": "Mozilla/5.0"
}
current_offset = 0
total_messages = 0
MAX_RETRIES = 5
with requests.Session() as session:
while True:
payload = {
"operationName": "VideoCommentsByOffsetOrCursor",
"variables": {
"videoID": str(vod_id),
"contentOffsetSeconds": current_offset
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "b70a3591ff0f4e0313d126c6a1502d79a1c02baebb288227c582044aa76adf6a"
}
}
}
success = False
for attempt in range(MAX_RETRIES):
try:
res = session.post(url, json=payload, headers=gql_headers, timeout=15)
if res.status_code == 429:
wait = 2 ** attempt
print(f"\n [!] Rate limited (429). Повтор через {wait}с...")
time.sleep(wait)
continue
if res.status_code != 200:
print(f"\n [!] Ошибка GQL статус {res.status_code}")
break
data = res.json()
if isinstance(data, list):
data = data[0]
video = data.get("data", {}).get("video", {})
if not video:
return list(roles_dict.values()) # VOD ended or not found
edges = (video.get("comments") or {}).get("edges") or []
if not edges:
return list(roles_dict.values()) # Reached end
for edge in edges:
if not edge:
continue
node = edge.get("node")
if not node:
continue
commenter = node.get("commenter")
if not commenter:
continue
user = commenter.get("login")
if not user:
continue
display_name = commenter.get("displayName", user)
message = node.get("message")
if not message:
continue
timestamp = node.get("createdAt")
user_badges = message.get("userBadges") or []
badges = [b.get("setID") for b in user_badges if b]
# Debug: print first badge encounter to verify GQL structure
if total_messages < 3 and user_badges:
print(f"\n [DEBUG] userBadges raw: {user_badges}")
print(f" [DEBUG] parsed badges: {badges}")
is_mod = "moderator" in badges or "broadcaster" in badges
is_sub = "subscriber" in badges or "founder" in badges
is_vip = "vip" in badges
# Only store users who have at least one badge
if is_mod or is_sub or is_vip:
roles_dict[user.lower()] = {
"username": user,
"displayName": display_name,
"isMod": is_mod,
"isSub": is_sub,
"isVip": is_vip,
"timestamp": timestamp
}
total_messages += 1
print(f" -> Обработано {total_messages} сообщений, найдено {len(roles_dict)} уникальных пользователей...", end="\r")
last_offset = edges[-1].get("node", {}).get("contentOffsetSeconds")
if last_offset is not None:
next_offset = last_offset + 1
if next_offset <= current_offset:
next_offset = current_offset + 30
current_offset = next_offset
else:
return list(roles_dict.values())
# Tiny sleep to avoid aggressive spamming
time.sleep(0.1)
success = True
break # Success — go to next page
except Exception as e:
wait = 2 ** attempt # 1, 2, 4, 8, 16 seconds
print(f"\n [!] Ошибка (попытка {attempt+1}/{MAX_RETRIES}): {e}")
if attempt < MAX_RETRIES - 1:
print(f" [!] Повтор через {wait}с...")
time.sleep(wait)
if not success:
print(f"\n [!] Не удалось получить данные после {MAX_RETRIES} попыток. Останавливаемся на offset={current_offset}.")
break
return list(roles_dict.values())
def upload_roles(roles, vod_id):
"""Send roles to backend in batches"""
batch_size = 500
for i in range(0, len(roles), batch_size):
batch = roles[i:i+batch_size]
try:
res = requests.post(f"{API_URL}/api/log/roles", json={"roles": batch}, headers=headers, timeout=10)
if res.status_code == 200:
print(f" -> Обновлено пользователей: {i + len(batch)} / {len(roles)}", end="\r")
else:
print(f"\n [!] Ошибка отправки: {res.status_code}")
except Exception as e:
print(f"\n [!] Ошибка сети: {e}")
print()
if __name__ == "__main__":
print("=========================================================")
print(" Ultra-Fast Role Backfiller (AUTO MODE) ")
print("=========================================================")
print(f"Backend: {API_URL}")
print()
# Fetch all streams
print("[1] Получение списка стримов с сервера...")
streams = get_all_streams()
if not streams:
print("[Error] Список стримов пустой или не удалось получить!")
sys.exit(1)
# Filter only VODs (twitch_stream_id starts with "vod-")
vod_streams = [s for s in streams if str(s.get("twitch_stream_id", "")).startswith("vod-")]
print(f"[+] Найдено стримов всего: {len(streams)}")
print()
# Process each stream
for idx, stream in enumerate(streams, 1):
vod_id = extract_vod_id(stream)
title = stream.get("title", "Без названия")
print(f"[{idx}/{len(streams)}] Stream — «{title[:50]}»")
if not vod_id:
print(" -> Пропуск: нет VOD ID")
continue
roles = download_vod_roles(vod_id)
print(f"\n -> Найдено пользователей с бейджами: {len(roles)}")
if roles:
upload_roles(roles, vod_id)
print(f" -> ✅ Готово!")
else:
print(f" -> Чат пустой или VOD недоступен, пропуск.")
print()
print("=========================================================")
print(" Восстановление ролей завершено! ")
print("=========================================================")