"""Evidence collectors: where community bug reports actually come from. Verified availability as of 2026-09-08: DuckDuckGo HTML works, no key. Soft-blocks with an empty HTTP 202 after roughly two rapid queries, so the shared HttpClient holds a 6 s/host floor and backs off on 202. Reddit JSON unauthenticated .json endpoints now return 403. A free "script" app at reddit.com/prefs/apps gives 100 req/min via client-credentials OAuth -- strongly recommended, because r/livesound, r/VIDEOENGINEERING and r/churchtech are the highest-signal sources for this fleet. GitHub issues works, best signal available for the open-source items. HN Algolia works, no key. Low yield for AV gear; off by default. Gearspace and most vendor forums sit behind Cloudflare and are not fetchable from a script; they are reachable through the DuckDuckGo path instead. """ from __future__ import annotations import logging import re import time import urllib.parse from datetime import datetime, timedelta, timezone from ..http import FetchError, HttpClient from ..models import Evidence from .classify import classify, relevance log = logging.getLogger(__name__) # -------------------------------------------------------------------------- # DuckDuckGo # -------------------------------------------------------------------------- _DDG_RESULT = re.compile( r']+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)', re.S ) _DDG_SNIPPET = re.compile( r']+class="result__snippet"[^>]*>(.*?)', re.S ) _TAGS = re.compile(r"<[^>]+>") def _clean(fragment: str) -> str: text = _TAGS.sub("", fragment) for a, b in (("&", "&"), (""", '"'), ("'", "'"), ("'", "'"), ("<", "<"), (">", ">"), (" ", " ")): text = text.replace(a, b) return re.sub(r"\s+", " ", text).strip() def _undirect(href: str) -> str: m = re.search(r"[?&]uddg=([^&]+)", href) return urllib.parse.unquote(m.group(1)) if m else href # GitHub issue-template boilerplate. Without this, every snippet in the # report is "### Operating System Info ### Other OS _No response_ ###..." # instead of what the person actually reported. _TEMPLATE_NOISE = re.compile( r"(?:^|\s)###\s*[^#\n]{0,60}?(?=\s|$)" # markdown headings r"|_No response_" r"|" # HTML comments r"|https?://\S*obsproject\.com/logs/\S*" # pasted log links r"|\|[-: ]+\|", # table rules re.S, ) def clean_body(text: str, limit: int = 600) -> str: """Turn an issue or post body into something readable in a digest.""" if not text: return "" out = _TEMPLATE_NOISE.sub(" ", text) out = re.sub(r"```.*?```", " [code] ", out, flags=re.S) out = re.sub(r"[*_`>#]+", "", out) out = re.sub(r"\s+", " ", out).strip() return out[:limit] def duckduckgo(http: HttpClient, query: str, limit: int = 6) -> list[Evidence]: url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query) try: resp = http.get(url, ttl=12 * 3600) except FetchError as exc: log.debug("ddg offline: %s", exc) return [] if resp.status == 202 or not resp.text.strip(): log.info("DuckDuckGo throttled us (status %s) for %r", resp.status, query) return [] if not resp.ok: log.info("DuckDuckGo HTTP %s for %r", resp.status, query) return [] titles = _DDG_RESULT.findall(resp.text) snippets = [_clean(s) for s in _DDG_SNIPPET.findall(resp.text)] out: list[Evidence] = [] for i, (href, raw_title) in enumerate(titles[:limit]): title = _clean(raw_title) if not title: continue link = _undirect(href) snippet = snippets[i] if i < len(snippets) else "" sev, sent, terms = classify(title, snippet) source = "reddit" if "reddit.com" in link else "web" out.append(Evidence( source=source, title=title, url=link, snippet=snippet[:600], sentiment=sent, severity=sev, matched_terms=terms, )) return out # -------------------------------------------------------------------------- # Reddit (OAuth client-credentials) # -------------------------------------------------------------------------- class RedditClient: """Client-credentials OAuth. Needs a free 'script' app: reddit.com/prefs/apps -> create app -> type 'script'. Set AVWATCH_REDDIT_CLIENT_ID and AVWATCH_REDDIT_CLIENT_SECRET. """ TOKEN_URL = "https://www.reddit.com/api/v1/access_token" API = "https://oauth.reddit.com" def __init__(self, http: HttpClient, client_id: str, client_secret: str): self.http = http self.client_id = client_id self.client_secret = client_secret self._token: str | None = None self._expires: float = 0.0 @property def configured(self) -> bool: return bool(self.client_id and self.client_secret) def _ensure_token(self) -> str | None: if not self.configured: return None if self._token and time.time() < self._expires - 60: return self._token try: resp = self.http.post_form( self.TOKEN_URL, data={"grant_type": "client_credentials"}, auth=(self.client_id, self.client_secret), ) except FetchError as exc: log.warning("reddit token request failed: %s", exc) return None if not resp.ok: log.warning("reddit token HTTP %s -- check your app credentials", resp.status) return None try: blob = resp.json() except ValueError: return None self._token = blob.get("access_token") self._expires = time.time() + float(blob.get("expires_in", 3600)) return self._token def search(self, query: str, subreddits: list[str], limit: int = 8, lookback_days: int = 400) -> list[Evidence]: token = self._ensure_token() if not token: return [] # One multireddit request beats N per-subreddit requests. multi = "+".join(s.strip() for s in subreddits if s.strip()) if not multi: return [] params = urllib.parse.urlencode({ "q": query, "restrict_sr": "1", "sort": "relevance", "t": "year", "limit": str(limit), "type": "link", }) url = f"{self.API}/r/{multi}/search?{params}" resp = self.http.get( url, headers={"Authorization": f"Bearer {token}"}, ttl=12 * 3600 ) if not resp.ok: log.info("reddit search HTTP %s for %r", resp.status, query) return [] try: children = resp.json().get("data", {}).get("children", []) except (ValueError, AttributeError): return [] cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days) out: list[Evidence] = [] for child in children: d = child.get("data") or {} title = (d.get("title") or "").strip() if not title: continue created = d.get("created_utc") published = ( datetime.fromtimestamp(created, tz=timezone.utc) if isinstance(created, (int, float)) else None ) if published and published < cutoff: continue raw = d.get("selftext") or "" body = clean_body(raw) sev, sent, terms = classify(title, raw[:1500]) out.append(Evidence( source="reddit", title=title, url="https://www.reddit.com" + (d.get("permalink") or ""), snippet=body, published=published, score=d.get("score"), sentiment=sent, severity=sev, matched_terms=terms, )) return out # -------------------------------------------------------------------------- # GitHub issues # -------------------------------------------------------------------------- def github_issues(http: HttpClient, repo: str, version: str | None, limit: int = 8, lookback_days: int = 400) -> list[Evidence]: """Open issues mentioning trouble, newest first. For OBS, Companion and RustDesk this is the most direct bug signal that exists.""" from ..sources.github_releases import auth_headers since = (datetime.now(timezone.utc) - timedelta(days=lookback_days)).date().isoformat() terms = "crash OR freeze OR regression OR broken OR bug" if version: terms = f'"{version}" OR ({terms})' q = f"repo:{repo} is:issue created:>{since} {terms}" url = ( "https://api.github.com/search/issues?q=" + urllib.parse.quote(q) + f"&sort=created&order=desc&per_page={limit}" ) resp = http.get(url, headers=auth_headers(), ttl=6 * 3600) if not resp.ok: log.info("github issue search HTTP %s for %s", resp.status, repo) return [] try: items = resp.json().get("items", []) except (ValueError, AttributeError): return [] out: list[Evidence] = [] for it in items: title = (it.get("title") or "").strip() if not title: continue raw_body = it.get("body") or "" # Classify against the raw body so template fields like # "OBS Studio Version 32.2.2" still drive the version match. body = clean_body(raw_body) sev, sent, terms_hit = classify(title, raw_body[:1500]) published = None if raw := it.get("created_at"): try: published = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: pass out.append(Evidence( source="github-issues", title=title, url=it.get("html_url", ""), snippet=body, published=published, score=(it.get("reactions") or {}).get("total_count"), sentiment=sent, severity=sev, matched_terms=terms_hit, )) return out # -------------------------------------------------------------------------- # Hacker News # -------------------------------------------------------------------------- def hackernews(http: HttpClient, query: str, limit: int = 5) -> list[Evidence]: url = ( "https://hn.algolia.com/api/v1/search_by_date?query=" + urllib.parse.quote(query) + f"&tags=(story,comment)&hitsPerPage={limit}" ) resp = http.get(url, ttl=12 * 3600) if not resp.ok: return [] try: hits = resp.json().get("hits", []) except (ValueError, AttributeError): return [] out: list[Evidence] = [] for h in hits: title = (h.get("title") or h.get("story_title") or "").strip() text = (h.get("comment_text") or h.get("story_text") or "")[:600] if not title and not text: continue sev, sent, terms = classify(title, text) published = None if raw := h.get("created_at"): try: published = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: pass oid = h.get("objectID") out.append(Evidence( source="hackernews", title=title or text[:80], url=f"https://news.ycombinator.com/item?id={oid}" if oid else "", snippet=_clean(text), published=published, score=h.get("points"), sentiment=sent, severity=sev, matched_terms=terms, )) return out # -------------------------------------------------------------------------- # Ranking # -------------------------------------------------------------------------- _SEV_WEIGHT = {"brick": 5.0, "crash": 3.0, "regression": 2.0, "annoyance": 0.6, "info": 0.2} _SOURCE_WEIGHT = {"reddit": 1.2, "github-issues": 1.3, "web": 1.0, "hackernews": 0.7} def rank_and_filter( evidence: list[Evidence], item_terms: list[str], version: str | None, limit: int, min_relevance: float = 0.34, ) -> list[Evidence]: """Drop off-topic hits, de-duplicate by URL, keep the most alarming and most relevant.""" seen: set[str] = set() scored: list[tuple[float, Evidence]] = [] for e in evidence: key = (e.url or e.title).lower().rstrip("/") if key in seen: continue seen.add(key) blob = f"{e.title} {e.snippet}" rel = relevance(blob, item_terms, version) if rel < min_relevance: continue e.mentions_version = bool(version and version.lower() in blob.lower()) weight = _SEV_WEIGHT.get(e.severity, 0.2) * _SOURCE_WEIGHT.get(e.source, 1.0) if e.sentiment == "negative": weight *= 1.6 elif e.sentiment == "positive": weight *= 0.5 if e.score and e.score > 25: weight *= 1.25 # Prefer posts that actually name the release being considered, so # they surface above a project's permanent background of open bugs. if e.mentions_version: weight *= 2.0 scored.append((weight * (0.5 + rel), e)) scored.sort(key=lambda t: t[0], reverse=True) return [e for _, e in scored[:limit]]