Mass ruff auto-fix:
- ruff check --fix: 109 issues fixed (F401 unused imports,
I001 unsorted imports, UP037 quoted annotations, SIM105
suppressible exception, RUF100 unused-noqa)
- ruff check --fix --unsafe-fixes: 22 additional issues
- ruff format: 70 files reformatted
- Manual pass: fix 16 misplaced import httpx lines
- Manual pass: fix remaining E402 (import-after-docstring)
Result: 283 errors -> 30 errors.
The remaining 30 are real issues that need manual review:
5 F401 unused-import (likely auto-generated stubs)
5 F821 undefined-name (real bugs in code that references
redis/pydantic/LLMRegistry without imports)
3 BLE001 (the compliance LLM fallback is intentional; the
other two are real)
3 RUF012 mutable-class-default
3 SIM105, 3 SIM117, 2 E722, 2 E741
1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)
Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
140 lines
5.1 KiB
Python
140 lines
5.1 KiB
Python
"""Pry — WebSocket and Server-Sent Events (SSE) scraping.
|
|
Modern SPAs (Twitter, Discord, stock tickers) and real-time apps use WebSockets
|
|
and SSE for data delivery. This scraper connects to these endpoints and
|
|
captures the data stream."""
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WebSocketScraper:
|
|
"""Scrape data from WebSocket and SSE endpoints."""
|
|
|
|
def __init__(self, max_messages: int = 100, timeout: int = 30) -> None:
|
|
self.max_messages = max_messages
|
|
self.timeout = timeout
|
|
|
|
async def scrape_websocket(
|
|
self,
|
|
url: str,
|
|
headers: dict[str, str] | None = None,
|
|
message_filter: str = "",
|
|
protocols: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Connect to a WebSocket and capture messages.
|
|
|
|
Args:
|
|
url: ws:// or wss:// URL
|
|
headers: Optional HTTP headers for the upgrade request
|
|
message_filter: Only return messages containing this string
|
|
protocols: WebSocket subprotocols
|
|
"""
|
|
try:
|
|
import websockets
|
|
except ImportError:
|
|
return {
|
|
"success": False,
|
|
"error": "websockets not installed. Run: pip install websockets",
|
|
}
|
|
|
|
messages: list[dict[str, Any]] = []
|
|
try:
|
|
async with websockets.connect(
|
|
url,
|
|
extra_headers=headers or {},
|
|
subprotocols=protocols,
|
|
) as ws:
|
|
start = time.time()
|
|
while len(messages) < self.max_messages and (time.time() - start) < self.timeout:
|
|
try:
|
|
msg = await asyncio.wait_for(ws.recv(), timeout=2)
|
|
except TimeoutError:
|
|
break
|
|
if isinstance(msg, bytes):
|
|
msg = msg.decode("utf-8", errors="replace")
|
|
if message_filter and message_filter not in msg:
|
|
continue
|
|
try:
|
|
parsed: Any = json.loads(msg)
|
|
except (json.JSONDecodeError, ValueError):
|
|
parsed = msg
|
|
messages.append({"data": parsed, "raw": msg, "received_at": time.time()})
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
return {"success": False, "error": str(e)[:300]}
|
|
|
|
return {
|
|
"success": True,
|
|
"url": url,
|
|
"messages": messages,
|
|
"count": len(messages),
|
|
}
|
|
|
|
async def scrape_sse(
|
|
self,
|
|
url: str,
|
|
headers: dict[str, str] | None = None,
|
|
event_filter: str = "",
|
|
max_events: int = 50,
|
|
) -> dict[str, Any]:
|
|
"""Connect to a Server-Sent Events endpoint and capture events."""
|
|
from client import get_client
|
|
|
|
client = await get_client()
|
|
events: list[dict[str, Any]] = []
|
|
try:
|
|
async with client.stream(
|
|
"GET", url, headers=headers or {}, timeout=self.timeout
|
|
) as resp:
|
|
event_type = "message"
|
|
data_buf: list[str] = []
|
|
async for line in resp.aiter_lines():
|
|
if not line:
|
|
continue
|
|
if line.startswith("event:"):
|
|
event_type = line.split(":", 1)[1].strip()
|
|
elif line.startswith("data:"):
|
|
data_buf.append(line.split(":", 1)[1].strip())
|
|
elif line.startswith(":"):
|
|
# SSE comment line — ignore
|
|
continue
|
|
else:
|
|
if data_buf:
|
|
raw_data = "\n".join(data_buf)
|
|
try:
|
|
parsed: Any = json.loads(raw_data)
|
|
except (json.JSONDecodeError, ValueError):
|
|
parsed = raw_data
|
|
if event_filter and event_filter not in str(parsed):
|
|
data_buf = []
|
|
event_type = "message"
|
|
continue
|
|
events.append(
|
|
{
|
|
"event": event_type,
|
|
"data": parsed,
|
|
"raw": raw_data,
|
|
}
|
|
)
|
|
data_buf = []
|
|
event_type = "message"
|
|
if len(events) >= max_events:
|
|
break
|
|
return {
|
|
"success": True,
|
|
"url": url,
|
|
"events": events,
|
|
"count": len(events),
|
|
}
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
return {"success": False, "error": str(e)[:300]}
|