컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다. .gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와 비밀키(.gemini_key)를 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
100 lines
4.0 KiB
Python
100 lines
4.0 KiB
Python
"""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
|
|
|
|
H_LAB = "https://h-lab.tolag.shop"
|
|
|
|
# h-lab comment-cards.js 의 TS_RE 와 동일 규칙
|
|
TS_RE = re.compile(r"(?<!\d)(\d{1,2}):([0-5]\d)(?::([0-5]\d))?(?!\d)")
|
|
_BR_RE = re.compile(r"<br\s*/?>", re.I)
|
|
_TAG_RE = re.compile(r"<[^>]+>")
|
|
|
|
# 분:초를 이만큼 넘게 나열한 댓글 = '목차 댓글'(하이라이트 모음). 카드로 못 쓴다.
|
|
# ⚠ 이게 없으면 모든 구간에 매칭돼서 **어느 ID를 열어도 맨 위에 이 댓글이 뜬다.**
|
|
# 후보(candidates)는 분:초가 아예 없는 댓글만 쓰므로, 여기만 막으면 화면에서 완전히 빠진다.
|
|
MAX_TIMES = 3
|
|
|
|
|
|
def plain_text(html: str) -> str:
|
|
"""YouTube textDisplay(HTML) → 평문. <br>→줄바꿈, 나머지 태그 제거."""
|
|
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 i, c in enumerate(data.get("data") or []):
|
|
out.append({
|
|
"idx": i,
|
|
"authorName": str(c.get("authorName") or ""),
|
|
"text": str(c.get("text") or ""),
|
|
"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]]
|