#!/usr/bin/env python3 """ Room Reader - find the comments on a small YouTube channel that still need an answer. Single file. Local-first. No API keys, no accounts, nothing sent to a hosted AI worker. The only network calls read public data about a channel you own or are reviewing. The read runs offline with an open-weight model: Xenova/nli-deberta-v3-xsmall (DeBERTa-v3-xsmall, MNLI-fine-tuned, exported to ONNX) A cross-encoder NLI model gives zero-shot classification for free: for each comment we measure how strongly it entails the sentence "The viewer is asking a question." with no training data and no labels of our own. A second, regex pass flags complaints the question model misses (damaged, refund, never arrived). Then it opens each thread the model flagged: if the creator already replied, the comment drops out. What's left is the pile a channel owner never answers. Usage: python3 roomreader.py @handle python3 roomreader.py @handle --videos 5 --check-replies --json out.json python3 roomreader.py UCxxxxxxxxxxxxxxxxxxxxxx Download the model once (~87 MB model + ~9 MB tokenizer): mkdir -p model curl -L -o model/model_quantized.onnx \ https://huggingface.co/Xenova/nli-deberta-v3-xsmall/resolve/main/onnx/model_quantized.onnx curl -L -o model/tokenizer.json \ https://huggingface.co/Xenova/nli-deberta-v3-xsmall/resolve/main/tokenizer.json """ import argparse import json import os import re import time import urllib.parse import urllib.request import numpy as np UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36") YT_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" # public web client key INVIDIOUS = os.environ.get("INVIDIOUS", "https://inv.nadeko.net") MODEL_DIR = os.environ.get("MODEL_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "model")) MODEL_REPO = "Xenova/nli-deberta-v3-xsmall" QUESTION_HYPOTHESIS = "The viewer is asking a question." ENTAILMENT = 1 # id2label for these exports: 0=contradiction 1=entailment 2=neutral MAX_LEN = 256 QUESTION_THRESHOLD = 0.5 SPAM_RE = re.compile(r"(?i)(https?://|www\.|t\.me/|follow me|sub4sub|sub 4 sub|" r"check out my (channel|video|page)|free followers|link in bio|" r"dm me|whatsapp|\+?\d{9,})") CONCERN_RE = re.compile(r"(?i)(damag|broken|refund|never (arrived|received|got|came)|" r"didn'?t (arrive|receive|get|come)|wrong (item|order|size)|" r"scam|charged (me )?twice|customer service|no reply|unanswered)") # Surface cue for a question: a '?' or an interrogative word. Cuts the model's # false positives on long praise comments that happen to score high. INTERROG_RE = re.compile(r"(?i)\b(what|where|when|why|how|who|which|whom|can|could|" r"do|does|did|is|are|will|would|should|any|anyone|anybody|" r"help|link|recipe|recommend|available|ship|shipment)\b") def fetch(url, data=None, timeout=30): req = urllib.request.Request(url, data=data) req.add_header("User-Agent", UA) req.add_header("Accept-Language", "en-US,en;q=0.9") if data is not None: req.add_header("Content-Type", "application/json") return urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", "replace") def walk(obj, key): if isinstance(obj, dict): for k, v in obj.items(): if k == key: yield v yield from walk(v, key) elif isinstance(obj, list): for item in obj: yield from walk(item, key) # ---------------------------------------------------------------- channel data def resolve_channel(handle): if re.fullmatch(r"UC[\w-]{20,}", handle): return handle, handle h = handle.strip().split("youtube.com/")[-1].split("?")[0].strip("/") if not h.startswith("@"): h = "@" + h html = fetch(f"https://www.youtube.com/{h}") m = re.search(r'"externalId":"(UC[\w-]+)"', html) t = re.search(r'= n: break return out def player(vid): body = {"context": {"client": {"clientName": "WEB", "clientVersion": "2.20260807.00.00", "hl": "en", "gl": "US"}}, "videoId": vid} j = json.loads(fetch("https://www.youtube.com/youtubei/v1/player?key=" + YT_KEY, json.dumps(body).encode())) vd = j.get("videoDetails", {}) or {} return vd.get("title", ""), int(vd.get("viewCount") or 0) def comments_page(vid, continuation=None, tries=3): url = f"{INVIDIOUS}/api/v1/comments/{vid}?hl=en" if continuation: url += "&continuation=" + urllib.parse.quote(continuation) for a in range(tries): try: return json.loads(fetch(url)) except Exception as e: if a == tries - 1: return {"comments": [], "error": str(e)[:80]} time.sleep(1.5) def fetch_comments(vid, cap=60): first = comments_page(vid) out = list(first.get("comments", []) or []) total = first.get("commentCount") cont = first.get("continuation") while cont and len(out) < cap: page = comments_page(vid, cont) batch = page.get("comments", []) or [] if not batch: break out.extend(batch) cont = page.get("continuation") time.sleep(0.4) return out[:cap], total, first.get("error") def fetch_reply_authors(vid, continuation, cap=20): try: j = json.loads(fetch(f"{INVIDIOUS}/api/v1/comments/{vid}?hl=en&continuation=" + urllib.parse.quote(continuation))) except Exception: return [] return [(c.get("author"), bool(c.get("authorIsChannelOwner"))) for c in (j.get("comments") or [])[:cap]] # ---------------------------------------------------------------- local model class Reader: """Zero-shot 'does this comment ask something?' via an open-weight NLI model. CPU only.""" def __init__(self, model_dir=MODEL_DIR): import onnxruntime as ort from tokenizers import Tokenizer mp = os.path.join(model_dir, "model_quantized.onnx") tp = os.path.join(model_dir, "tokenizer.json") if not os.path.exists(mp) or not os.path.exists(tp): base = f"https://huggingface.co/{MODEL_REPO}/resolve/main" raise SystemExit( "model files missing. Download once:\n" f" mkdir -p {model_dir}\n" f" curl -L -o {mp} {base}/onnx/model_quantized.onnx\n" f" curl -L -o {tp} {base}/tokenizer.json") self.tok = Tokenizer.from_file(tp) self.tok.enable_truncation(max_length=MAX_LEN) self.sess = ort.InferenceSession(mp, providers=["CPUExecutionProvider"]) self.names = [i.name for i in self.sess.get_inputs()] def question_prob(self, comment): enc = self.tok.encode(comment or " ", QUESTION_HYPOTHESIS) n = min(len(enc.ids), MAX_LEN) ids = np.array([(enc.ids + [0] * (MAX_LEN - n))[:MAX_LEN]], dtype=np.int64) mask = np.array([([1] * n + [0] * (MAX_LEN - n))[:MAX_LEN]], dtype=np.int64) feed = {"input_ids": ids, "attention_mask": mask} if "token_type_ids" in self.names: feed["token_type_ids"] = np.zeros_like(ids) logits = self.sess.run(None, feed)[0][0] ex = np.exp(logits - logits.max()) return float((ex / ex.sum())[ENTAILMENT]) def label_comment(reader, text): if not text: return "empty", 0.0 if SPAM_RE.search(text): return "spam", 0.99 q = reader.question_prob(text) cue = ("?" in text) or bool(INTERROG_RE.search(text)) if q >= QUESTION_THRESHOLD and cue: return "question", q if CONCERN_RE.search(text): return "concern", q return "other", q # ---------------------------------------------------------------- report def analyze(handle, nvids, cap, check_replies, reader): cid, cname = resolve_channel(handle) vids = recent_videos(cid, nvids) report = {"channel": cname, "channelId": cid, "videos": [], "queue": [], "model": MODEL_REPO, "hypothesis": QUESTION_HYPOTHESIS, "generated": int(time.time())} for v in vids: vid = v["videoId"] title, views = player(vid) raw, total, err = fetch_comments(vid, cap) cleaned = [] for c in raw: if c.get("authorIsChannelOwner"): continue cleaned.append({ "author": c.get("author"), "content": (c.get("content") or "").strip(), "likes": c.get("likeCount") or 0, "hearted": bool(c.get("creatorHeart")), "replyCount": (c.get("replies") or {}).get("replyCount", 0), "published": c.get("publishedText"), "commentId": c.get("commentId"), "_cont": (c.get("replies") or {}).get("continuation"), }) for c in cleaned: c["intent"], c["score"] = label_comment(reader, c["content"]) c["answered"] = None if check_replies and c["intent"] in ("question", "concern") \ and c["replyCount"] > 0 and c["_cont"]: c["answered"] = any(own for _, own in fetch_reply_authors(vid, c["_cont"])) needed = [c for c in cleaned if c["intent"] in ("question", "concern")] vid_rec = {"videoId": vid, "title": title or v.get("title"), "views": views, "commentCount": total, "scanned": len(cleaned), "replyNeeded": len(needed)} for c in needed: if c["answered"] is True: continue report["queue"].append({ "videoId": vid, "videoTitle": title or v.get("title"), "author": c["author"], "content": c["content"][:300], "kind": c["intent"], "score": round(c["score"], 3), "likes": c["likes"], "hearted": c["hearted"], "answered": c["answered"], "published": c["published"], "commentId": c["commentId"]}) report["videos"].append(vid_rec) time.sleep(1.0) report["queue"].sort(key=lambda x: -x["score"]) report["summary"] = { "videos": len(report["videos"]), "commentsSeen": sum(v["commentCount"] or 0 for v in report["videos"]), "scanned": sum(v["scanned"] for v in report["videos"]), "queue": len(report["queue"]), } return report def print_report(r): s = r["summary"] print(f"\nROOM READER - {r['channel']} ({r['channelId']})") print(f"{s['videos']} videos | {s['commentsSeen']} comments on them | scanned {s['scanned']} | " f"{s['queue']} still want a reply\n") for v in r["videos"]: print(f" {v['videoId']} views={v['views']} comments={v['commentCount']} " f"reply-needed={v['replyNeeded']} {(v['title'] or '')[:48]}") if not r["queue"]: print("\n REPLY QUEUE: empty. Nobody is waiting on you here.") print() return print(f"\n REPLY QUEUE (open-weight model, offline; hypothesis: \"{r['hypothesis']}\")") for i, c in enumerate(r["queue"][:15], 1): ans = "?" if c["answered"] is None else ("owner-answered" if c["answered"] else "unanswered") heart = " hearted" if c["hearted"] else "" print(f" {i:>2}. [{c['kind']} {c['score']:.2f}{heart} {ans}] {c['author']} ({c['likes']}l): " f"{c['content'][:108].replace(chr(10), ' ')}") print() def main(): ap = argparse.ArgumentParser(description="Find the comments a small channel still needs to answer.") ap.add_argument("channel", help="@handle, channel URL, or UC... id") ap.add_argument("--videos", type=int, default=5) ap.add_argument("--comments", type=int, default=60, help="max top-level comments scanned per video") ap.add_argument("--check-replies", action="store_true", help="open reply threads to confirm whether the owner already answered") ap.add_argument("--json", metavar="PATH", help="also write the full report as JSON") args = ap.parse_args() reader = Reader() report = analyze(args.channel, args.videos, args.comments, args.check_replies, reader) print_report(report) if args.json: with open(args.json, "w") as f: json.dump(report, f, indent=1) print(f"wrote {args.json}") if __name__ == "__main__": main()