"""h-lab 댓글 수집 + 타임스탬프 매칭 — 자동 탭용. h-lab(https://h-lab.tolag.shop)의 comment-cards API에서 영상 전체 댓글을 받아, 본문 속 mm:ss / h:mm:ss 언급을 초로 파싱한다. 정규식·평문 변환 규칙은 h-lab comment-cards.js(TS_RE, toPlainText)와 동일하게 맞춘다. """ from __future__ import annotations import json import re import urllib.request from typing import Dict, List, Optional H_LAB = "https://h-lab.tolag.shop" # h-lab comment-cards.js 의 TS_RE 와 동일 규칙 TS_RE = re.compile(r"(?", re.I) _TAG_RE = re.compile(r"<[^>]+>") # 분:초를 이만큼 넘게 나열한 댓글 = '목차 댓글'(하이라이트 모음). 카드로 못 쓴다. # ⚠ 이게 없으면 모든 구간에 매칭돼서 **어느 ID를 열어도 맨 위에 이 댓글이 뜬다.** # 후보(candidates)는 분:초가 아예 없는 댓글만 쓰므로, 여기만 막으면 화면에서 완전히 빠진다. MAX_TIMES = 3 # 광고/홍보 댓글 판별 — 좋아요를 조작한 광고가 ➕좋아요 채우기·후보 상위에 올라와 # 드래프트 카드로 뽑히는 사고 방지. 오탐이 더 아프므로 보수적으로: # URL, "n% 할인", "최저가"만 본다("할인"·"%" 단독인 일상 댓글은 통과). _AD_RE = re.compile( r"https?://|www\.|tinyurl\.|bit\.ly|" r"\d+\s*%\s*할인|최저가" ) def _is_ad(text: str) -> bool: """광고/홍보 댓글이면 True — fetch_comments 가 수집 단계에서 걸러낸다.""" return bool(_AD_RE.search(plain_text(text))) def plain_text(html: str) -> str: """YouTube textDisplay(HTML) → 평문.
→줄바꿈, 나머지 태그 제거.""" s = _BR_RE.sub("\n", str(html or "")) return _TAG_RE.sub("", s) def parse_times(text: str) -> List[float]: """댓글 본문 속 mm:ss / h:mm:ss → 초 리스트(중복 제거, 등장 순).""" out: List[float] = [] seen = set() for m in TS_RE.finditer(plain_text(text)): if m.group(3) is not None: sec = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) else: sec = int(m.group(1)) * 60 + int(m.group(2)) if sec not in seen: seen.add(sec) out.append(float(sec)) return out def fetch_comments(url: str, *, timeout: float = 180.0) -> List[Dict]: """h-lab에서 전체 댓글 수집. 원본 순서 유지, idx = 배열 인덱스(식별자). 실패는 예외 그대로 던진다(URLError/RuntimeError) — 호출부(자동 탭 스트림)가 "댓글 없이 진행" 폴백을 담당한다. """ req = urllib.request.Request( f"{H_LAB}/api/comment-cards/fetch", data=json.dumps({"url": url}).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) if not data.get("success"): raise RuntimeError(f"h-lab 응답 실패: {data.get('message')}") out: List[Dict] = [] for c in data.get("data") or []: text = str(c.get("text") or "") if _is_ad(text): # 광고는 수집 단계에서 제외 — 어떤 추천 경로로도 카드가 못 된다 continue out.append({ "idx": len(out), "authorName": str(c.get("authorName") or ""), "text": text, "likeCount": int(c.get("likeCount") or 0), "replyCount": int(c.get("replyCount") or 0), "publishedAt": str(c.get("publishedAt") or ""), "profileImageUrl": str(c.get("profileImageUrl") or ""), "times": parse_times(c.get("text") or ""), }) return out def match_window(comments: List[Dict], start: float, end: float) -> List[int]: """[start, end] 안의 시각을 하나라도 언급한 댓글 idx — 좋아요 내림차순.""" return match_ranges(comments, [(start, end)]) def match_ranges(comments: List[Dict], ranges) -> List[int]: """여러 구간 중 어느 하나라도 언급한 댓글 idx — 좋아요 내림차순 (유튜브 구간 탭용). 분:초를 MAX_TIMES 개보다 많이 나열한 목차 댓글은 제외(§MAX_TIMES 주석 참고). """ hit = [c for c in comments if len(c["times"]) <= MAX_TIMES and any(any(s <= t <= e for s, e in ranges) for t in c["times"])] hit.sort(key=lambda c: -c["likeCount"]) return [c["idx"] for c in hit] def top_liked(comments: List[Dict], exclude: set, n: int = 20) -> List[int]: """exclude(idx 집합) 제외 좋아요 상위 n개 idx.""" rest = [c for c in comments if c["idx"] not in exclude] rest.sort(key=lambda c: -c["likeCount"]) return [c["idx"] for c in rest[:n]] def match_slots(comments: List[Dict], start: float, total: float, n: int, exclude=None) -> List[Optional[int]]: """구간을 n개 슬롯으로 잘라 슬롯마다 댓글 1개씩 배정 — 통짜 모드용. 슬롯 k = 원본 [start + k·total/n, start + (k+1)·total/n). 통짜 모드는 구간을 그대로 이어붙여 **타임라인 시각 = 원본 시각**이라 이 계산이 성립한다 (컷 있는 모드는 컷 순서를 섞어 재배치하므로 쓰면 안 된다 — 내용 기반 추천을 쓸 것). 각 슬롯은 그 시간대를 언급한 댓글 중 좋아요 최다 1개. 없으면 None. 한 댓글이 두 슬롯에 겹치지 않는다. exclude(idx 집합)는 처음부터 제외. Returns: 길이 n 리스트 (n<=0 이거나 total<=0 이면 빈 리스트) """ if n <= 0 or total <= 0: return [] used = set(exclude or ()) step = total / n out: List[Optional[int]] = [] for k in range(n): s = start + k * step pick = None for idx in match_ranges(comments, [(s, s + step)]): if idx not in used: pick = idx used.add(idx) break out.append(pick) return out