This commit is contained in:
Yuriy Yuriev
2026-05-26 18:01:07 +07:00
parent 795b2e928c
commit 493c67cb1d
+116 -177
View File
@@ -1,13 +1,11 @@
""" """
GraphQL WebSocket сервис для мониторинга Twitch чата. GraphQL HTTP polling сервис для мониторинга Twitch чата.
Замена IRC — не требует OAuth токена.
""" """
import asyncio import asyncio
import json
import logging import logging
import re import re
from typing import Optional, Callable, Awaitable, List from typing import Optional, Callable, Awaitable, List, Set
from datetime import datetime from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -15,24 +13,30 @@ import aiohttp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_GQL_WS_URL = "wss://gql.twitch.tv/gql" _GQL_URL = "https://gql.twitch.tv/gql"
_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko" _HEADERS = {
"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"Content-Type": "application/json",
}
_POLL_INTERVAL = 2.0 # секунд между запросами
_CHAT_SUBSCRIPTION = """\ _QUERY = """\
subscription ChatMessages($channelLogin: String!) { query ChannelChatMessages($login: String!, $after: ID) {
chatEvents(input: { channelLogin: $channelLogin }) { user(login: $login) {
... on ChatMessageEvent { chatRoom {
message { chatMessages(first: 50, after: $after) {
sender { pageInfo {
displayName endCursor
login hasNextPage
} }
content { nodes {
text id
fragments { sender {
... on TextFragment { displayName
text login
} }
content {
text
} }
} }
} }
@@ -43,76 +47,19 @@ subscription ChatMessages($channelLogin: String!) {
class TwitchGQLChatClient: class TwitchGQLChatClient:
""" """
Twitch чат клиент через GQL WebSocket. Twitch чат клиент через GQL HTTP polling.
Интерфейс совместим с TwitchIRCClient. Интерфейс совместим с TwitchIRCClient.
""" """
def __init__(self, channel: str, target_username: str): def __init__(self, channel: str, target_username: str):
self.channel = channel.lower() self.channel = channel.lower()
self.target_username = target_username.lower() self.target_username = target_username.lower()
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
self._session: Optional[aiohttp.ClientSession] = None
self._connected = False
self._url_pattern = re.compile(r'https?://[^\s<>"]+') self._url_pattern = re.compile(r'https?://[^\s<>"]+')
self._seen_ids: Set[str] = set()
self._cursor: Optional[str] = None
async def connect(self) -> bool: async def connect(self) -> bool:
try: return True
self._session = aiohttp.ClientSession()
self._ws = await self._session.ws_connect(
_GQL_WS_URL,
protocols=["graphql-ws"],
headers={"Client-ID": _CLIENT_ID},
timeout=aiohttp.ClientTimeout(total=15),
)
await self._ws.send_str(json.dumps({
"type": "connection_init",
"payload": {
"Authorization": "undefined",
"Client-ID": _CLIENT_ID,
}
}))
ack = await asyncio.wait_for(self._ws.receive(), timeout=10)
msg = json.loads(ack.data)
if msg.get("type") != "connection_ack":
logger.error(f"GQL WS: unexpected init response: {msg}")
await self._cleanup()
return False
await self._ws.send_str(json.dumps({
"type": "start",
"id": "chat",
"payload": {
"query": _CHAT_SUBSCRIPTION,
"variables": {"channelLogin": self.channel},
"operationName": "ChatMessages",
}
}))
self._connected = True
logger.info(f"✅ GQL подключен к #{self.channel}")
return True
except Exception as e:
logger.error(f"❌ GQL ошибка подключения: {e}")
await self._cleanup()
return False
async def _cleanup(self) -> None:
self._connected = False
try:
if self._ws and not self._ws.closed:
await self._ws.close()
except Exception:
pass
try:
if self._session and not self._session.closed:
await self._session.close()
except Exception:
pass
self._ws = None
self._session = None
def _extract_urls(self, text: str) -> List[str]: def _extract_urls(self, text: str) -> List[str]:
urls = [] urls = []
@@ -136,6 +83,46 @@ class TwitchGQLChatClient:
except Exception: except Exception:
return False return False
async def _fetch_messages(self, session: aiohttp.ClientSession) -> List[dict]:
payload = [{
"operationName": "ChannelChatMessages",
"variables": {"login": self.channel, "after": self._cursor},
"query": _QUERY,
}]
try:
async with session.post(
_GQL_URL,
json=payload,
headers=_HEADERS,
timeout=aiohttp.ClientTimeout(total=8),
) as resp:
data = await resp.json()
result = data[0] if isinstance(data, list) else data
errors = result.get("errors")
if errors:
logger.error(f"GQL schema errors: {errors}")
return []
chat_data = (
result.get("data", {})
.get("user", {})
.get("chatRoom", {})
.get("chatMessages", {})
)
if not chat_data:
return []
end_cursor = chat_data.get("pageInfo", {}).get("endCursor")
if end_cursor:
self._cursor = end_cursor
return chat_data.get("nodes", [])
except Exception as e:
logger.warning(f"GQL fetch error: {e}")
return []
async def listen_for_messages( async def listen_for_messages(
self, self,
on_url_found: Callable[[str, str], Awaitable[None]], on_url_found: Callable[[str, str], Awaitable[None]],
@@ -146,118 +133,70 @@ class TwitchGQLChatClient:
) -> dict: ) -> dict:
stats = {"links_found": 0, "messages": 0, "reconnects": 0} stats = {"links_found": 0, "messages": 0, "reconnects": 0}
start_time = datetime.now() start_time = datetime.now()
reconnect_errors = 0 fail_streak = 0
if is_active and not is_active(): if is_active and not is_active():
logger.info(f"⏸️ #{self.channel} ждём resume перед подключением...") logger.info(f"⏸️ #{self.channel} ждём resume...")
while is_active and not is_active(): while is_active and not is_active():
await asyncio.sleep(2) await asyncio.sleep(2)
if not await self.connect(): logger.info(f"🚀 GQL chat polling: #{self.channel}")
raise ConnectionError(f"GQL connect failed for #{self.channel}")
while duration == 0 or (datetime.now() - start_time).seconds < duration: async with aiohttp.ClientSession() as session:
if is_active and not is_active(): while duration == 0 or (datetime.now() - start_time).seconds < duration:
logger.info(f"⏸️ #{self.channel} пауза...") if is_active and not is_active():
await self._cleanup() logger.info(f"⏸️ #{self.channel} пауза...")
while is_active and not is_active(): while is_active and not is_active():
await asyncio.sleep(2) await asyncio.sleep(2)
logger.info(f"▶️ #{self.channel} resume, переподключение...") logger.info(f"▶️ #{self.channel} resume")
if not await self.connect():
reconnect_errors += 1 messages = await self._fetch_messages(session)
if reconnect_errors >= 3 and on_drain_fail:
if messages is not None and len(messages) == 0 and self._cursor is None:
# Первый запрос — cursor ещё не установлен, это норма
fail_streak = 0
elif not messages:
fail_streak += 1
if fail_streak >= 10 and on_drain_fail:
await on_drain_fail() await on_drain_fail()
return stats return stats
else: else:
reconnect_errors = 0 fail_streak = 0
stats["reconnects"] += 1
continue
if not self._connected or self._ws is None or self._ws.closed: for msg in messages:
await asyncio.sleep(5) msg_id = msg.get("id")
if not await self.connect(): if not msg_id or msg_id in self._seen_ids:
reconnect_errors += 1 continue
if reconnect_errors >= 3 and on_drain_fail: self._seen_ids.add(msg_id)
await on_drain_fail() if len(self._seen_ids) > 10000:
return stats self._seen_ids = set(list(self._seen_ids)[-5000:])
else:
reconnect_errors = 0
stats["reconnects"] += 1
continue
try: sender = msg.get("sender", {})
raw = await asyncio.wait_for(self._ws.receive(), timeout=30.0) display_name = sender.get("displayName", "unknown")
except asyncio.CancelledError: text = (msg.get("content") or {}).get("text", "")
raise
except asyncio.TimeoutError:
continue
except Exception as e:
logger.warning(f"GQL receive error: {e}")
self._connected = False
continue
if raw.type in ( if not text.strip():
aiohttp.WSMsgType.CLOSING, continue
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
logger.warning(f"WS закрыт: {raw.type}")
self._connected = False
continue
if raw.type != aiohttp.WSMsgType.TEXT: stats["messages"] += 1
continue logger.debug(f"GQL msg from {display_name}: {text[:50]}...")
try: for url in self._extract_urls(text):
msg = json.loads(raw.data) if self._is_domain_allowed(url, allowed_domains):
except Exception: stats["links_found"] += 1
continue try:
await on_url_found(url, display_name)
except asyncio.CancelledError:
raise
except Exception:
pass
msg_type = msg.get("type") try:
await asyncio.sleep(_POLL_INTERVAL)
if msg_type == "ka": # keep-alive от Twitch except asyncio.CancelledError:
continue raise
if msg_type == "connection_error":
logger.error(f"GQL connection error: {msg}")
self._connected = False
continue
if msg_type != "data":
continue
payload = msg.get("payload", {})
data = payload.get("data", {})
chat_event = data.get("chatEvents")
if not chat_event:
continue
message_obj = chat_event.get("message", {})
if not message_obj:
continue
sender = message_obj.get("sender", {})
display_name = sender.get("displayName", "unknown")
content = message_obj.get("content", {})
text = content.get("text", "")
if not text.strip():
continue
stats["messages"] += 1
logger.debug(f"GQL message from {display_name}: {text[:50]}...")
for url in self._extract_urls(text):
if self._is_domain_allowed(url, allowed_domains):
stats["links_found"] += 1
try:
await on_url_found(url, display_name)
except asyncio.CancelledError:
raise
except Exception:
pass
return stats return stats
async def disconnect(self) -> None: async def disconnect(self) -> None:
await self._cleanup() pass