Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)

This commit is contained in:
Yun Chan 2026-08-15 11:59:25 +09:00
commit 21b7c4d8bd
60 changed files with 6581 additions and 0 deletions

477
downloader.py Normal file
View file

@ -0,0 +1,477 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HLS / MP4 비디오 다운로더 (maccms 계열 스트리밍 사이트 대응)
==========================================================
- 페이지 URL 입력 시 영상 주소(m3u8/mp4) 자동 추출 (maccms player_aaaa, 정규식, iframe)
- HLS 완전 지원: 마스터 재생목록(최고 화질 자동 선택), AES-128 복호화, fMP4(#EXT-X-MAP) 초기화 세그먼트
- 세그먼트 병렬 다운로드 + 이어받기(중단 후 재실행 시 받은 분할 건너뜀)
- ffmpeg 자동 리먹스(TS/fMP4 -> MP4)
- Referer 등 커스텀 헤더 지원 (CDN 토큰 인증 대응)
의존성: requests (필수) / pycryptodome (AES 암호화 영상, 옵션) / tqdm (진행률, 옵션) / ffmpeg (리먹스, 권장)
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin, urlparse
import requests
# Windows 콘솔 한글 깨짐 방지
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
DEFAULT_HEADERS = {
"User-Agent": DEFAULT_UA,
"Accept": "*/*",
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
}
# m3u8 속성 파싱: KEY="VAL",KEY2=VAL
_ATTR_RE = re.compile(r'([A-Z0-9\-]+)=("([^"]*)"|([^,]*))')
# 페이지에서 영상 주소 추출용 정규식
_PLAYER_AAAA_RE = re.compile(r"player_aaaa\s*=\s*(\{.*?\})\s*[;<]", re.S)
_PLAYER_DATA_RE = re.compile(r"player_data\s*=\s*(\{.*?\})\s*[;<]", re.S)
_M3U8_RE = re.compile(r"https?://[^\s\"'<>]*\.m3u8[^\s\"'<>]*", re.I)
_MP4_RE = re.compile(r"https?://[^\s\"'<>]*\.mp4[^\s\"'<>]*", re.I)
_IFRAME_RE = re.compile(r'<iframe[^>]+src=["\']([^"\']+)["\']', re.I)
def parse_attrs(s):
d = {}
for m in _ATTR_RE.finditer(s):
d[m.group(1)] = m.group(3) if m.group(3) is not None else m.group(4)
return d
def parse_m3u8(text, base_url):
"""m3u8 재생목록 파싱. 마스터(변환재생목록)와 미디어 재생목록을 모두 처리."""
result = {"variants": [], "segments": [], "init": None, "is_master": False}
pending_variant = None # 직전 #EXT-X-STREAM-INF 속성
cur_key = None # 현재 유효한 #EXT-X-KEY (세그먼트별로 참조 복사)
seq = 0
idx = 0
saw_seq = False
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#EXT-X-STREAM-INF:"):
pending_variant = parse_attrs(line[len("#EXT-X-STREAM-INF:"):])
continue
if line.startswith("#EXT-X-MEDIA-SEQUENCE:"):
try:
seq = int(line.split(":", 1)[1].strip())
saw_seq = True
except ValueError:
pass
continue
if line.startswith("#EXT-X-KEY:"):
a = parse_attrs(line[len("#EXT-X-KEY:"):])
method = a.get("METHOD", "NONE").upper()
if method == "AES-128":
uri = a.get("URI")
if uri:
uri = urljoin(base_url, uri) # 재생목록 기준 절대경로화
cur_key = {"uri": uri, "iv": a.get("IV")}
else:
cur_key = None
continue
if line.startswith("#EXT-X-MAP:"):
a = parse_attrs(line[len("#EXT-X-MAP:"):])
if a.get("URI"):
result["init"] = urljoin(base_url, a["URI"]) # fMP4 초기화 세그먼트
continue
if line.startswith("#"):
continue
# URL 라인
abs_url = urljoin(base_url, line)
if pending_variant is not None:
bw = 0
try:
bw = int(pending_variant.get("BANDWIDTH", "0"))
except ValueError:
pass
result["variants"].append(
{
"url": abs_url,
"bandwidth": bw,
"res": pending_variant.get("RESOLUTION", ""),
"codecs": pending_variant.get("CODECS", ""),
}
)
pending_variant = None
result["is_master"] = True
else:
result["segments"].append(
{"url": abs_url, "index": idx, "seq": seq, "key": cur_key}
)
idx += 1
seq += 1
return result
# --------------------------------------------------------------------------- #
# 진행률 표시 (tqdm이 있으면 사용, 없으면 단순 폴백)
# --------------------------------------------------------------------------- #
class _SimpleProgress:
def __init__(self, total=None, desc="", unit=""):
self.total = total
self.n = 0
self.desc = desc
self.unit = unit
self._last_pct = -1
def update(self, n=1):
self.n += n
if self.total:
pct = int(self.n * 100 / self.total)
if pct != self._last_pct and pct % 5 == 0:
self._last_pct = pct
if self.unit == "B":
sys.stdout.write(
f"\r{self.desc}: {pct}% "
f"({self.n/1048576:.1f}/{self.total/1048576:.1f} MB)"
)
else:
sys.stdout.write(f"\r{self.desc}: {pct}% ({self.n}/{self.total})")
sys.stdout.flush()
def close(self):
sys.stdout.write("\n")
sys.stdout.flush()
def make_progress(total=None, **kw):
try:
from tqdm import tqdm
return tqdm(total=total, **kw)
except Exception:
return _SimpleProgress(total=total, desc=kw.get("desc", ""), unit=kw.get("unit", ""))
# --------------------------------------------------------------------------- #
# 다운로더 본체
# --------------------------------------------------------------------------- #
class Downloader:
def __init__(self, output, headers=None, concurrency=8, retries=6, timeout=30):
self.session = requests.Session()
self.session.headers.update(DEFAULT_HEADERS)
for k, v in (headers or {}).items():
self.session.headers[k] = v
self.concurrency = max(1, concurrency)
self.retries = retries
self.timeout = timeout
self.output = output
self.tmp = output + ".parts"
os.makedirs(self.tmp, exist_ok=True)
self._key_cache = {}
# ---- 공통 HTTP ---- #
def _get_text(self, url):
r = self.session.get(url, timeout=self.timeout)
r.raise_for_status()
return r.text
def _download_file(self, url, path):
tmp = path + ".tmp"
with self.session.get(url, timeout=self.timeout, stream=True) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_content(65536):
if chunk:
f.write(chunk)
os.replace(tmp, path)
# ---- 진입: URL 종류 판별 ---- #
def run(self, url):
path = urlparse(url).path.lower()
if ".m3u8" in path:
return self.run_hls(url)
if path.endswith(".mp4"):
return self.run_direct(url)
return self.run_page(url)
# ---- 페이지에서 영상 주소 추출 ---- #
def run_page(self, page_url):
print(f"페이지 분석 중: {page_url}")
self.session.headers.setdefault("Referer", page_url)
html = self._get_text(page_url)
cands = [] # (kind, url)
def scan(text):
for rx, kind in ((_PLAYER_AAAA_RE, "player_aaaa"), (_PLAYER_DATA_RE, "player_data")):
for m in rx.finditer(text):
try:
obj = json.loads(m.group(1))
except ValueError:
continue
for field in ("url", "parse", "parse2"):
u = obj.get(field)
if isinstance(u, str) and u.startswith("http"):
cands.append((f"{kind}.{field}", u))
for u in _M3U8_RE.findall(text):
cands.append(("m3u8", u))
for u in _MP4_RE.findall(text):
cands.append(("mp4", u))
scan(html)
# iframe 내부 플레이어 페이지도 1단계까지 탐색
for src in _IFRAME_RE.findall(html):
abs_src = urljoin(page_url, src)
if "http" not in abs_src:
continue
try:
scan(self._get_text(abs_src))
except Exception:
pass
# 중복 제거(순서 유지) 후 m3u8 우선
seen, uniq = set(), []
for kind, u in cands:
if u in seen:
continue
seen.add(u)
uniq.append((kind, u))
if not uniq:
raise RuntimeError(
"페이지에서 영상 주소를 찾지 못했습니다. README의 "
"'m3u8 주소 직접 구하기' 방법으로 주소를 얻어 .m3u8 주소를 직접 넘겨주세요."
)
print("발견된 영상 주소 후보:")
for i, (kind, u) in enumerate(uniq):
print(f" [{i}] ({kind}) {u}")
chosen = next((u for _, u in uniq if ".m3u8" in u.lower()), None)
if chosen is None:
chosen = next((u for _, u in uniq if ".mp4" in u.lower()), uniq[0][1])
print(f"\n선택: {chosen}\n")
if ".m3u8" in chosen.lower():
return self.run_hls(chosen)
return self.run_direct(chosen)
# ---- HLS 다운로드 ---- #
def run_hls(self, m3u8_url):
print(f"HLS 재생목록 로드: {m3u8_url}")
parsed = parse_m3u8(self._get_text(m3u8_url), m3u8_url)
# 마스터 재생목록이면 최고 화질 변환 선택 후 미디어 재생목록 로드
while parsed["is_master"]:
best = max(parsed["variants"], key=lambda v: v["bandwidth"])
label = best["res"] or f"{best['bandwidth']}bps"
print(f"마스터 재생목록 -> 최고 화질 선택: {label}")
parsed = parse_m3u8(self._get_text(best["url"]), best["url"])
segs = parsed["segments"]
if not segs:
raise RuntimeError("재생목록에 다운로드할 세그먼트가 없습니다.")
encrypted = any(s["key"] for s in segs)
print(f"세그먼트 수: {len(segs)} | 암호화: {'AES-128' if encrypted else '없음'}")
# fMP4 초기화 세그먼트
init_path = None
if parsed["init"]:
init_path = os.path.join(self.tmp, "init.fmp4")
print("fMP4 초기화 세그먼트 다운로드 ...")
self._download_file(parsed["init"], init_path)
self._download_segments(segs)
merged = self._merge(segs, init_path)
final = self._remux(merged, init_path is not None)
return final
def _seg_path(self, seg):
return os.path.join(self.tmp, f"{seg['index']:06d}.seg")
def _download_segments(self, segs):
todo = [s for s in segs if not (os.path.exists(self._seg_path(s)) and os.path.getsize(self._seg_path(s)) > 0)]
skipped = len(segs) - len(todo)
print(f"다운로드: {len(todo)}개 (이미 받은 {skipped}개 건너뜀), 동시 {self.concurrency}연결")
prog = make_progress(total=len(segs), initial=skipped, desc="세그먼트", unit="seg")
failures = []
def work(seg):
for attempt in range(1, self.retries + 1):
try:
self._download_file(seg["url"], self._seg_path(seg))
return None
except Exception as e:
if attempt == self.retries:
return (seg, str(e))
time.sleep(min(2 ** attempt, 10))
return None
with ThreadPoolExecutor(max_workers=self.concurrency) as ex:
futures = [ex.submit(work, s) for s in todo]
for fut in as_completed(futures):
err = fut.result()
if err:
failures.append(err)
prog.update(1)
prog.close()
if failures:
sample = "; ".join(f"{s['index']}({m})" for s, m in failures[:3])
raise RuntimeError(f"{len(failures)}개 세그먼트 실패(재시도 후): {sample} ...")
@staticmethod
def _aes():
try:
from Crypto.Cipher import AES
return AES
except ImportError:
raise RuntimeError("암호화 영상입니다. 'pip install pycryptodome' 설치 후 재시도하세요.")
def _decrypt(self, data, seg):
key = seg["key"]
if not key:
return data
if key["uri"] not in self._key_cache:
r = self.session.get(key["uri"], timeout=self.timeout)
r.raise_for_status()
self._key_cache[key["uri"]] = r.content # 보통 16바이트
keybytes = self._key_cache[key["uri"]]
if key["iv"]:
ivb = bytes.fromhex(key["iv"].replace("0x", "").replace(" ", ""))
else:
ivb = seg["seq"].to_bytes(16, "big") # IV 미지정 시 시퀀스 번호
cipher = self._aes().new(keybytes, self._aes().MODE_CBC, ivb)
return cipher.decrypt(data)
def _merge(self, segs, init_path):
is_fmp4 = init_path is not None
ext = ".mp4" if is_fmp4 else ".ts"
merged = os.path.abspath(self.output + ".merged" + ext)
print("세그먼트 병합 중 ...")
with open(merged, "wb") as out:
if init_path and os.path.exists(init_path):
with open(init_path, "rb") as f:
out.write(f.read())
for seg in sorted(segs, key=lambda x: x["index"]):
with open(self._seg_path(seg), "rb") as f:
out.write(self._decrypt(f.read(), seg))
return merged
def _remux(self, merged, is_fmp4):
base, ext = os.path.splitext(self.output)
if ext.lower() in (".ts", ".m3u8", ".m4s", ".fmp4"):
ext = ""
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
final = (base or "video") + ".mp4"
if os.path.abspath(final) == os.path.abspath(merged):
final = (base or "video") + ".out.mp4"
print(f"ffmpeg 리먹스(-c copy) -> {os.path.basename(final)}")
ret = subprocess.run(
[ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", merged, "-c", "copy", final]
)
if ret.returncode == 0 and os.path.exists(final) and os.path.getsize(final) > 0:
os.remove(merged)
return final
print("경고: ffmpeg 리먹스 실패, 병합본을 그대로 유지합니다.", file=sys.stderr)
return merged
# ffmpeg 없음: 확장자만 맞춰 이동
final = (base or "video") + (".mp4" if is_fmp4 else ".ts")
os.replace(merged, final)
print("안내: ffmpeg가 없어 리먹스를 생략했습니다. MP4 변환을 권장합니다.", file=sys.stderr)
return final
# ---- 직접 MP4 다운로드 ---- #
def run_direct(self, url):
base, ext = os.path.splitext(self.output)
final = self.output if ext.lower() in (".mp4", ".mkv", ".ts") else (base or "video") + ".mp4"
print(f"직접 다운로드: {url}")
with self.session.get(url, timeout=self.timeout, stream=True) as r:
r.raise_for_status()
total = int(r.headers.get("Content-Length", 0)) or None
prog = make_progress(total=total, desc="다운로드", unit="B", unit_scale=True)
tmp = final + ".tmp"
with open(tmp, "wb") as f:
for chunk in r.iter_content(65536):
if chunk:
f.write(chunk)
prog.update(len(chunk))
prog.close()
os.replace(tmp, final)
return final
def main():
ap = argparse.ArgumentParser(
description="HLS/MP4 비디오 다운로더 (maccms 계열 사이트 대응)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"예제:\n"
' python downloader.py "https://site/vod/play/id/123/sid/1/nid/1.html" -o movie\n'
' python downloader.py "https://cdn.example.com/v/index.m3u8" -o movie --referer "https://site/"\n'
' python downloader.py "https://cdn.example.com/v/index.m3u8" --referer "https://site/" -H "Origin: https://site"\n'
),
)
ap.add_argument("url", help="재생페이지 URL 또는 .m3u8 / .mp4 직접 주소")
ap.add_argument("-o", "--output", default="video", help="출력 파일명(확장자 생략 가능, 기본 video)")
ap.add_argument("--referer", help="Referer 헤더 (CDN 토큰 인증에 필요한 경우)")
ap.add_argument("-H", "--header", action="append", default=[], metavar='"Key: Value"', help="추가 헤더 (여러개 가능)")
ap.add_argument("-c", "--concurrency", type=int, default=8, help="동시 다운로드 연결 수 (기본 8)")
ap.add_argument("--user-agent", default=None, help="User-Agent 재정의")
ap.add_argument("--keep-parts", action="store_true", help="병합 후 세그먼트 임시파일 유지(디버그용)")
args = ap.parse_args()
headers = {}
for h in args.header:
if ":" in h:
k, v = h.split(":", 1)
headers[k.strip()] = v.strip()
if args.referer:
headers["Referer"] = args.referer
if args.user_agent:
headers["User-Agent"] = args.user_agent
dl = Downloader(args.output, headers, concurrency=args.concurrency)
try:
final = dl.run(args.url)
except KeyboardInterrupt:
print("\n중단됨. 받은 세그먼트는 유지되어 재실행 시 이어받기됩니다.")
sys.exit(130)
except Exception as e:
print(f"\n오류: {e}", file=sys.stderr)
sys.exit(1)
print(f"\n완료: {final}")
if not args.keep_parts:
shutil.rmtree(dl.tmp, ignore_errors=True)
if __name__ == "__main__":
main()