First Commit
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing
|
||||
from dataclasses import astuple, dataclass, fields
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
from importlib import import_module
|
||||
from itertools import chain
|
||||
from os import PathLike
|
||||
from typing import Iterator, List, Literal, Tuple, Union
|
||||
|
||||
import requests
|
||||
|
||||
from epg2xml import __title__, __version__
|
||||
from epg2xml.utils import Element, PrefixLogger, RateLimiter, dump_json
|
||||
|
||||
log = logging.getLogger("PROV")
|
||||
|
||||
|
||||
PTN_TITLE = re.compile(r"(.*) \(?(\d+부)\)?")
|
||||
PTN_SPACES = re.compile(r" {2,}")
|
||||
CAT_KO2EN = {
|
||||
"교양": "Arts / Culture (without music)",
|
||||
"만화": "Cartoons / Puppets",
|
||||
"교육": "Education / Science / Factual topics",
|
||||
"취미": "Leisure hobbies",
|
||||
"드라마": "Movie / Drama",
|
||||
"영화": "Movie / Drama",
|
||||
"음악": "Music / Ballet / Dance",
|
||||
"뉴스": "News / Current affairs",
|
||||
"다큐": "Documentary",
|
||||
"라이프": "Documentary",
|
||||
"시사/다큐": "Documentary",
|
||||
"연예": "Show / Game show",
|
||||
"스포츠": "Sports",
|
||||
"홈쇼핑": "Advertisement / Shopping",
|
||||
}
|
||||
TAG_CREDITS = (
|
||||
"director",
|
||||
"actor",
|
||||
"writer",
|
||||
"adapter",
|
||||
"producer",
|
||||
"composer",
|
||||
"editor",
|
||||
"presenter",
|
||||
"commentator",
|
||||
"guest",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EPGProgram:
|
||||
"""For individual program entities"""
|
||||
|
||||
channelid: str
|
||||
stime: datetime = None
|
||||
etime: datetime = None
|
||||
title: str = None
|
||||
title_sub: str = None
|
||||
part_num: str = None
|
||||
ep_num: str = None
|
||||
categories: List[str] = None
|
||||
rebroadcast: bool = False
|
||||
rating: int = 0
|
||||
# not usually given by default
|
||||
desc: str = None
|
||||
poster_url: str = None
|
||||
cast: List[dict] = None # 출연진
|
||||
crew: List[dict] = None # 제작진
|
||||
extras: List[str] = None
|
||||
keywords: List[str] = None
|
||||
|
||||
def sanitize(self) -> None:
|
||||
for f in fields(self):
|
||||
attr = getattr(self, f.name)
|
||||
if f.type == List[str] and attr is not None:
|
||||
setattr(self, f.name, [x.strip() for x in filter(bool, attr) if x.strip()])
|
||||
elif f.type == str:
|
||||
setattr(self, f.name, (attr or "").strip())
|
||||
|
||||
def to_xml(self, cfg: dict) -> None:
|
||||
self.sanitize()
|
||||
|
||||
# local variables
|
||||
stime = self.stime.strftime("%Y%m%d%H%M%S +0900")
|
||||
etime = self.etime.strftime("%Y%m%d%H%M%S +0900")
|
||||
title = self.title
|
||||
title_sub = self.title_sub
|
||||
cast = self.cast or []
|
||||
crew = self.crew or []
|
||||
categories = self.categories or []
|
||||
keywords = self.keywords or []
|
||||
episode = self.ep_num
|
||||
rebroadcast = "재" if self.rebroadcast else ""
|
||||
rating = "전체 관람가" if self.rating == 0 else f"{self.rating}세 이상 관람가"
|
||||
|
||||
# programm
|
||||
_p = Element("programme", start=stime, stop=etime, channel=self.channelid)
|
||||
|
||||
# title, sub-title
|
||||
if matches := PTN_TITLE.match(title):
|
||||
title = matches.group(1).strip()
|
||||
title_sub = (matches.group(2) + " " + title_sub).strip()
|
||||
title = [
|
||||
title or title_sub or "제목 없음",
|
||||
f"({episode}회)" if episode and cfg["ADD_EPNUM_TO_TITLE"] else "",
|
||||
f"({rebroadcast})" if rebroadcast and cfg["ADD_REBROADCAST_TO_TITLE"] else "",
|
||||
]
|
||||
title = PTN_SPACES.sub(" ", " ".join(filter(bool, title)))
|
||||
_p.append(Element("title", title, lang="ko"))
|
||||
if title_sub:
|
||||
_p.append(Element("sub-title", title_sub, lang="ko"))
|
||||
|
||||
# desc
|
||||
if cfg["ADD_DESCRIPTION"]:
|
||||
desc = [
|
||||
title,
|
||||
f"부제 : {title_sub}" if title_sub else "",
|
||||
f"방송 : {rebroadcast}방송" if rebroadcast else "",
|
||||
f"회차 : {episode}회" if episode else "",
|
||||
f"장르 : {','.join(categories)}" if categories else "",
|
||||
f"출연 : {','.join(x['name'] for x in cast)}" if cast else "",
|
||||
f"제작 : {','.join(x['name'] for x in crew)}" if crew else "",
|
||||
f"등급 : {rating}",
|
||||
self.desc,
|
||||
]
|
||||
desc = PTN_SPACES.sub(" ", "\n".join(filter(bool, desc)))
|
||||
_p.append(Element("desc", desc, lang="ko"))
|
||||
|
||||
# credits
|
||||
if cast or crew:
|
||||
_c = Element("credits")
|
||||
for cc in sorted(cast + crew, key=lambda x: TAG_CREDITS.index(x["title"])):
|
||||
title = cc.pop("title")
|
||||
name = cc.pop("name")
|
||||
_c.append(Element(title, name, **cc))
|
||||
_p.append(_c)
|
||||
|
||||
# categories
|
||||
for cat_ko in categories:
|
||||
_p.append(Element("category", cat_ko, lang="ko"))
|
||||
if cat_en := CAT_KO2EN.get(cat_ko):
|
||||
_p.append(Element("category", cat_en, lang="en"))
|
||||
|
||||
# keywords
|
||||
for keyword in keywords:
|
||||
_p.append(Element("keyword", keyword, lang="ko"))
|
||||
|
||||
# icon
|
||||
if self.poster_url:
|
||||
_p.append(Element("icon", src=self.poster_url))
|
||||
|
||||
# episode-num
|
||||
if episode:
|
||||
if cfg["ADD_XMLTV_NS"]:
|
||||
try:
|
||||
episode_ns = int(episode) - 1
|
||||
except ValueError:
|
||||
episode_ns = int(episode.split(",", 1)[0]) - 1
|
||||
episode_ns = f"0.{str(episode_ns)}.0/0"
|
||||
_p.append(Element("episode-num", episode_ns, system="xmltv_ns"))
|
||||
else:
|
||||
_p.append(Element("episode-num", episode, system="onscreen"))
|
||||
|
||||
# previously-shown
|
||||
if rebroadcast:
|
||||
_p.append(Element("previously-shown"))
|
||||
|
||||
# rating
|
||||
if rating:
|
||||
# TODO: 영상물등급위원회(KMRB)는 TV프로그램 심의에 관여하지 않으므로 수정이 필요
|
||||
_r = Element("rating", system="KMRB")
|
||||
_r.append(Element("value", rating))
|
||||
_p.append(_r)
|
||||
|
||||
# dumps
|
||||
print(_p.tostring(level=1))
|
||||
|
||||
|
||||
class EPGChannel:
|
||||
"""For individual channel entities"""
|
||||
|
||||
__slots__ = ["id", "src", "svcid", "name", "icon", "no", "category", "programs"]
|
||||
|
||||
def __init__(self, channelinfo):
|
||||
self.id: str = channelinfo["Id"]
|
||||
self.src: str = channelinfo["Source"]
|
||||
self.svcid: str = channelinfo["ServiceId"]
|
||||
self.name: str = channelinfo["Name"]
|
||||
self.icon: str = channelinfo.get("Icon_url")
|
||||
self.no: str = channelinfo.get("No")
|
||||
self.category: str = channelinfo.get("Category")
|
||||
# placeholder
|
||||
self.programs: List[EPGProgram] = []
|
||||
"""
|
||||
개별 EPGProgram이 소속 channelid를 가지고 있어서 굳이 EPGChannel의 하위 리스트로 관리해야할
|
||||
이유는 없지만, endtime이 없는 EPG 항목을 위해 한 번에 써야할 필요가 있는 Provider가 있기에
|
||||
(kt, lg, skb, naver, daum) 채널 단위로 관리하는 편이 유리하다.
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} <{self.id}>"
|
||||
|
||||
def set_etime(self) -> None:
|
||||
"""Completes missing program endtimes based on the successive relationship between programs."""
|
||||
for ind, prog in enumerate(self.programs):
|
||||
if prog.etime:
|
||||
continue
|
||||
try:
|
||||
prog.etime = self.programs[ind + 1].stime
|
||||
except IndexError:
|
||||
prog.etime = (prog.stime + timedelta(days=1)).replace(hour=0, minute=0, second=0)
|
||||
|
||||
def to_xml(self) -> None:
|
||||
chel = Element("channel", id=self.id)
|
||||
# TODO: something better for display-name?
|
||||
chel.append(Element("display-name", self.name))
|
||||
chel.append(Element("display-name", self.src))
|
||||
if self.no:
|
||||
chel.append(Element("display-name", f"{self.no}"))
|
||||
chel.append(Element("display-name", f"{self.no} {self.name}"))
|
||||
chel.append(Element("display-name", f"{self.no} {self.src}"))
|
||||
if self.icon:
|
||||
chel.append(Element("icon", src=self.icon))
|
||||
print(chel.tostring(level=1))
|
||||
|
||||
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
|
||||
|
||||
|
||||
class EPGProvider:
|
||||
"""Base class for EPG Providers"""
|
||||
|
||||
referer: str = None
|
||||
title_regex: Union[str, re.Pattern] = None
|
||||
tps: float = 1.0
|
||||
was_channel_updated: bool = False
|
||||
|
||||
def __init__(self, cfg: dict):
|
||||
self.provider_name = self.__class__.__name__
|
||||
self.cfg = cfg
|
||||
self.sess = requests.Session()
|
||||
self.sess.headers.update({"User-Agent": UA, "Referer": self.referer})
|
||||
if http_proxy := cfg["HTTP_PROXY"]:
|
||||
self.sess.proxies.update({"http": http_proxy, "https": http_proxy})
|
||||
if self.title_regex:
|
||||
self.title_regex = re.compile(self.title_regex)
|
||||
self.request = RateLimiter(tps=self.tps)(self.__request)
|
||||
# placeholders
|
||||
self.svc_channels: List[dict] = []
|
||||
self.req_channels: List[EPGChannel] = []
|
||||
|
||||
def __request(self, url: str, method: str = "GET", **kwargs) -> str:
|
||||
ret = ""
|
||||
try:
|
||||
r = self.sess.request(method=method, url=url, **kwargs)
|
||||
try:
|
||||
ret = r.json()
|
||||
except (json.decoder.JSONDecodeError, ValueError):
|
||||
ret = r.text
|
||||
except requests.exceptions.HTTPError as e:
|
||||
log.error("요청 중 에러: %s", e)
|
||||
except Exception:
|
||||
log.exception("요청 중 예외:")
|
||||
return ret
|
||||
|
||||
def load_svc_channels(self, channeljson: dict = None) -> None:
|
||||
plog = PrefixLogger(log, f"[{self.provider_name:5s}]")
|
||||
|
||||
# check if update required
|
||||
try:
|
||||
channelinfo = channeljson[self.provider_name.upper()]
|
||||
total = channelinfo["TOTAL"]
|
||||
channels = channelinfo["CHANNELS"]
|
||||
assert total == len(channels), "TOTAL != len(CHANNELS)"
|
||||
updated_at = datetime.fromisoformat(channelinfo["UPDATED"])
|
||||
if (datetime.now() - updated_at).total_seconds() <= 3600 * 24 * 4:
|
||||
self.svc_channels = channels
|
||||
plog.info("%03d service channels loaded from cache", len(channels))
|
||||
return
|
||||
plog.debug("Updating service channels as outdated...")
|
||||
except Exception as e:
|
||||
plog.debug("Updating service channels as cache broken: %s", e)
|
||||
|
||||
try:
|
||||
channels = self.get_svc_channels()
|
||||
except Exception:
|
||||
plog.exception("Exception while retrieving service channels:")
|
||||
else:
|
||||
self.svc_channels = channels
|
||||
self.was_channel_updated = True
|
||||
plog.info("%03d service channels successfully fetched from server", len(channels))
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
raise NotImplementedError("method 'get_svc_channels' must be implemented")
|
||||
|
||||
def load_req_channels(self) -> None:
|
||||
"""from MY_CHANNELS to req_channels"""
|
||||
plog = PrefixLogger(log, f"[{self.provider_name:5s}]")
|
||||
my_channels = self.cfg["MY_CHANNELS"]
|
||||
if my_channels == "*":
|
||||
plog.debug("Overriding all MY_CHANNELS by service channels...")
|
||||
my_channels = self.svc_channels
|
||||
if not my_channels:
|
||||
return
|
||||
req_channels = []
|
||||
svc_channels = {x["ServiceId"]: x for x in self.svc_channels}
|
||||
for my_no, my_ch in enumerate(my_channels):
|
||||
if "ServiceId" not in my_ch:
|
||||
plog.warning("'ServiceId' Not Found: %s", my_ch)
|
||||
continue
|
||||
req_ch = svc_channels.pop(my_ch["ServiceId"], None)
|
||||
if req_ch is None:
|
||||
plog.warning("'ServiceId' Not in Service: %s", my_ch)
|
||||
continue
|
||||
for _k, _v in my_ch.items():
|
||||
if _v:
|
||||
req_ch[_k] = _v
|
||||
req_ch["Source"] = self.provider_name
|
||||
req_ch.setdefault("No", str(my_no))
|
||||
if "Id" not in req_ch:
|
||||
try:
|
||||
req_ch["Id"] = eval(f"f'{self.cfg['ID_FORMAT']}'", None, req_ch)
|
||||
except Exception:
|
||||
req_ch["Id"] = f'{req_ch["ServiceId"]}.{req_ch["Source"].lower()}'
|
||||
if not self.cfg["ADD_CHANNEL_ICON"]:
|
||||
req_ch.pop("Icon_url", None)
|
||||
req_channels.append(EPGChannel(req_ch))
|
||||
plog.info("요청 %3d - 불가 %3d = 최종 %3d", len(my_channels), len(my_channels) - len(req_channels), len(req_channels))
|
||||
self.req_channels = req_channels
|
||||
|
||||
def write_channels(self) -> None:
|
||||
for ch in self.req_channels:
|
||||
if not ch.programs:
|
||||
log.warning("Skip writing as no program entries found for '%s'", ch.id)
|
||||
continue
|
||||
ch.to_xml()
|
||||
|
||||
def get_programs(self) -> None:
|
||||
raise NotImplementedError("method 'get_programs' must be implemented")
|
||||
|
||||
def write_programs(self) -> None:
|
||||
for ch in self.req_channels:
|
||||
for prog in ch.programs:
|
||||
prog.to_xml(self.cfg)
|
||||
ch.programs.clear() # for memory efficiency
|
||||
|
||||
|
||||
def no_endtime(func):
|
||||
@wraps(func)
|
||||
def wrapped(self: EPGProvider, *args, **kwargs):
|
||||
func(self, *args, **kwargs)
|
||||
for ch in self.req_channels:
|
||||
ch.set_etime()
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class EPGHandler:
|
||||
"""for handling EPGProviders"""
|
||||
|
||||
def __init__(self, cfgs: dict):
|
||||
self.providers: List[EPGProvider] = self.load_providers(cfgs)
|
||||
|
||||
def load_providers(self, cfgs: dict) -> List[EPGProvider]:
|
||||
providers = []
|
||||
for name, cfg in cfgs.items():
|
||||
if not cfg["ENABLED"]:
|
||||
continue
|
||||
try:
|
||||
m = import_module(f"epg2xml.providers.{name.lower()}")
|
||||
providers.append(getattr(m, name.upper())(cfg))
|
||||
except ModuleNotFoundError:
|
||||
log.error("No such provider found: '%s'", name)
|
||||
sys.exit(1)
|
||||
return providers
|
||||
|
||||
def load_channels(self, channelfile: str, parallel: bool = False) -> None:
|
||||
try:
|
||||
log.debug("Trying to load cached channels from json")
|
||||
with open(channelfile, "r", encoding="utf-8") as fp:
|
||||
channeljson = json.load(fp)
|
||||
except (json.decoder.JSONDecodeError, ValueError, FileNotFoundError) as e:
|
||||
log.debug("Failed to load cached channels from json: %s", e)
|
||||
channeljson = {}
|
||||
if parallel:
|
||||
with ThreadPoolExecutor() as exe:
|
||||
for p in self.providers:
|
||||
exe.submit(p.load_svc_channels, channeljson=channeljson)
|
||||
else:
|
||||
for p in self.providers:
|
||||
p.load_svc_channels(channeljson=channeljson)
|
||||
if any(p.was_channel_updated for p in self.providers):
|
||||
for p in self.providers:
|
||||
channeljson[p.provider_name.upper()] = {
|
||||
"UPDATED": datetime.now().isoformat(),
|
||||
"TOTAL": len(p.svc_channels),
|
||||
"CHANNELS": p.svc_channels,
|
||||
}
|
||||
dump_json(channelfile, channeljson)
|
||||
log.info("Channel file was upgraded. You may check the changes here: %s", channelfile)
|
||||
|
||||
def load_req_channels(self):
|
||||
for p in self.providers:
|
||||
p.load_req_channels()
|
||||
|
||||
log.debug("Checking uniqueness of channelid...")
|
||||
cids = [c.id for p in self.providers for c in p.req_channels]
|
||||
assert len(cids) == len(set(cids)), f"채널ID 중복: { {k:v for k,v in Counter(cids).items() if v > 1} }"
|
||||
|
||||
def get_programs(self, parallel: bool = False):
|
||||
if parallel:
|
||||
with ThreadPoolExecutor() as exe:
|
||||
for p in self.providers:
|
||||
exe.submit(p.get_programs)
|
||||
else:
|
||||
for p in self.providers:
|
||||
p.get_programs()
|
||||
|
||||
def to_xml(self):
|
||||
print('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
print('<!DOCTYPE tv SYSTEM "xmltv.dtd">\n')
|
||||
print(f'<tv generator-info-name="{__title__} v{__version__}">')
|
||||
|
||||
log.debug("Writing channels...")
|
||||
for p in self.providers:
|
||||
p.write_channels()
|
||||
|
||||
log.debug("Writing programs...")
|
||||
for p in self.providers:
|
||||
p.write_programs()
|
||||
|
||||
print("</tv>")
|
||||
|
||||
@property
|
||||
def all_channels(self) -> Iterator:
|
||||
"""shortcut to access all channels in providers"""
|
||||
return chain.from_iterable(p.req_channels for p in self.providers)
|
||||
|
||||
@property
|
||||
def all_programs(self) -> Iterator:
|
||||
"""shortcut to access all programs in providers"""
|
||||
return chain.from_iterable(ch.programs for ch in self.all_channels)
|
||||
|
||||
def to_db(self, dbfile: PathLike) -> None:
|
||||
with SQLite(dbfile, "w") as db:
|
||||
db.insert_channels(self.all_channels)
|
||||
db.insert_programs(self.all_programs)
|
||||
|
||||
def from_db(self, dbfile: PathLike) -> None:
|
||||
with SQLite(dbfile, "r") as db:
|
||||
for p in self.providers:
|
||||
for ch in db.queryall("SELECT * FROM epgchannel WHERE Source = ?", (p.provider_name,)):
|
||||
chn = EPGChannel(dict(ch))
|
||||
for prog in db.queryall("SELECT * FROM epgprogram WHERE channelid = ?", (chn.id,)):
|
||||
chn.programs.append(EPGProgram(**dict(prog)))
|
||||
p.req_channels.append(chn)
|
||||
|
||||
|
||||
sqlite3.register_adapter(bool, int)
|
||||
sqlite3.register_converter("BOOLEAN", lambda v: bool(int(v)))
|
||||
sqlite3.register_adapter(list, lambda v: json.dumps(v, ensure_ascii=False))
|
||||
sqlite3.register_converter("JSON", json.loads)
|
||||
|
||||
SQLITE_DTYPES = {
|
||||
bool: "BOOLEAN",
|
||||
datetime: "TIMESTAMP",
|
||||
int: "INTEGER",
|
||||
List[dict]: "JSON",
|
||||
List[str]: "JSON",
|
||||
}
|
||||
|
||||
|
||||
class SQLite:
|
||||
def __init__(self, dbfile: PathLike, mode: Literal["r", "w", "a"] = "r", **kwargs):
|
||||
kwargs.setdefault("detect_types", sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES)
|
||||
self.conn = sqlite3.connect(dbfile, **kwargs)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
self.__db_init(mode=mode)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.conn.close()
|
||||
|
||||
def __db_init(self, mode: Literal["r", "w", "a"]) -> None:
|
||||
if mode == "r":
|
||||
return
|
||||
with closing(self.conn.cursor()) as c:
|
||||
# create table - epgchannel
|
||||
c.execute("CREATE TABLE IF NOT EXISTS epgchannel (Id, Source, ServiceId, Name, Icon_url, No, Category)")
|
||||
# create table - epgprogram
|
||||
cols = [f"{f.name} {SQLITE_DTYPES.get(f.type, 'TEXT')}" for f in fields(EPGProgram)]
|
||||
c.execute(f"CREATE TABLE IF NOT EXISTS epgprogram ({', '.join(cols)})")
|
||||
if mode == "w":
|
||||
c.execute("DELETE FROM epgchannel")
|
||||
c.execute("DELETE FROM epgprogram")
|
||||
self.conn.commit()
|
||||
|
||||
def insert_channels(self, channels: List[EPGChannel]) -> None:
|
||||
def _astuple(ch: EPGChannel) -> Tuple:
|
||||
return (ch.id, ch.src, ch.svcid, ch.name, ch.icon, ch.no, ch.category)
|
||||
|
||||
sql = "INSERT INTO epgchannel VALUES (?,?,?,?,?,?,?)"
|
||||
with closing(self.conn.cursor()) as c:
|
||||
c.executemany(sql, map(_astuple, channels))
|
||||
self.conn.commit()
|
||||
|
||||
def insert_programs(self, programs: List[EPGProgram]) -> None:
|
||||
cols = [f.name for f in fields(EPGProgram)]
|
||||
sql = f"INSERT INTO epgprogram({','.join(cols)}) VALUES ({','.join('?'*len(cols))})"
|
||||
with closing(self.conn.cursor()) as c:
|
||||
c.executemany(sql, map(astuple, programs))
|
||||
self.conn.commit()
|
||||
|
||||
def queryall(self, *args, **kwargs) -> List[sqlite3.Row]:
|
||||
with closing(self.conn.cursor()) as c:
|
||||
return c.execute(*args, **kwargs).fetchall()
|
||||
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from urllib.parse import quote
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider, no_endtime
|
||||
from epg2xml.utils import ParserBeautifulSoup as BeautifulSoup
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
|
||||
CH_CATE = ["지상파", "종합편성", "케이블", "스카이라이프", "해외위성", "라디오"]
|
||||
|
||||
|
||||
class DAUM(EPGProvider):
|
||||
"""EPGProvider for DAUM
|
||||
|
||||
데이터: rawhtml
|
||||
요청수: #channels
|
||||
특이사항:
|
||||
- 최대 7일치를 한 번에
|
||||
- 프로그램 시작 시각만 제공
|
||||
"""
|
||||
|
||||
referer = None
|
||||
title_regex = r"^(?P<title>.*?)\s?([\<\(]?(?P<part>\d{1})부[\>\)]?)?\s?(<(?P<subname1>.*)>)?\s?((?P<epnum>\d+)회)?\s?(<(?P<subname2>.*)>)?$"
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
svc_channels = []
|
||||
url = "https://search.daum.net/search?DA=B3T&w=tot&rtmaxcoll=B3T&q={}"
|
||||
channelsel1 = '#channelNaviLayer > div[class^="layer_tv layer_all"] ul > li'
|
||||
channelsel2 = 'div[class="wrap_sub"] > span > a'
|
||||
for c in CH_CATE:
|
||||
search_url = url.format(f"{c} 편성표")
|
||||
data = self.request(search_url)
|
||||
soup = BeautifulSoup(data)
|
||||
if not soup.find_all(attrs={"disp-attr": "B3T"}):
|
||||
continue
|
||||
all_channels = [str(x.text.strip()) for x in soup.select(channelsel1)]
|
||||
if not all_channels:
|
||||
all_channels += [str(x.text.strip()) for x in soup.select(channelsel2)]
|
||||
svc_cate = c.replace("스카이라이프", "SKYLIFE")
|
||||
for x in all_channels:
|
||||
svc_channels.append(
|
||||
{
|
||||
"Name": x,
|
||||
"ServiceId": f"{svc_cate} {x}",
|
||||
"Category": c,
|
||||
}
|
||||
)
|
||||
return svc_channels
|
||||
|
||||
@no_endtime
|
||||
def get_programs(self) -> None:
|
||||
url = "https://search.daum.net/search?DA=B3T&w=tot&rtmaxcoll=B3T&q={}"
|
||||
for idx, _ch in enumerate(self.req_channels):
|
||||
log.info("%03d/%03d %s", idx + 1, len(self.req_channels), _ch)
|
||||
search_url = url.format(quote(_ch.svcid + " 편성표"))
|
||||
data = self.request(search_url)
|
||||
try:
|
||||
_epgs = self.__epgs_of_days(_ch.id, data)
|
||||
except AssertionError as e:
|
||||
log.warning("%s: %s", e, _ch)
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s", _ch)
|
||||
else:
|
||||
_ch.programs.extend(_epgs)
|
||||
|
||||
def __epgs_of_days(self, channelid: str, data: str) -> List[EPGProgram]:
|
||||
soup = BeautifulSoup(data)
|
||||
assert soup.find_all(attrs={"disp-attr": "B3T"}), "EPG 정보가 없거나 없는 채널입니다"
|
||||
days = soup.select('div[class="tbl_head head_type2"] > span > span[class="date"]')
|
||||
|
||||
# 연도 추정
|
||||
currdate = datetime.now() # 언제나 basedate보다 미래
|
||||
basedate = datetime.strptime(days[0].text.strip(), "%m.%d").replace(year=currdate.year)
|
||||
if (basedate - currdate).days > 0:
|
||||
basedate = basedate.replace(year=basedate.year - 1)
|
||||
|
||||
_epgs = []
|
||||
for nd, _ in enumerate(days):
|
||||
hours = soup.select(f'[id="tvProgramListWrap"] > table > tbody > tr > td:nth-of-type({nd+1})')
|
||||
assert len(hours) == 24, f"24개의 시간 행이 있어야 합니다: 현재: {len(hours):d}"
|
||||
for nh, hour in enumerate(hours):
|
||||
for dl in hour.select("dl"):
|
||||
_epg = EPGProgram(channelid)
|
||||
nm = int(dl.select("dt")[0].text.strip())
|
||||
_epg.stime = basedate + timedelta(days=nd, hours=nh, minutes=nm)
|
||||
for atag in dl.select("dd > a"):
|
||||
_epg.title = atag.text.strip()
|
||||
for span in dl.select("dd > span"):
|
||||
class_val = " ".join(span["class"])
|
||||
if class_val == "":
|
||||
_epg.title = span.text.strip()
|
||||
elif "ico_re" in class_val:
|
||||
_epg.rebroadcast = True
|
||||
elif "ico_rate" in class_val:
|
||||
_epg.rating = int(class_val.split("ico_rate")[1].strip())
|
||||
else:
|
||||
# ico_live ico_hd ico_subtitle ico_hand ico_uhd ico_talk ico_st
|
||||
_epg.extras = (_epg.extras or []) + [span.text.strip()]
|
||||
if m := self.title_regex.search(_epg.title):
|
||||
_epg.title = m.group("title")
|
||||
_epg.part_num = m.group("part")
|
||||
_epg.ep_num = m.group("epnum")
|
||||
_epg.title_sub = m.group("subname2") or m.group("subname1")
|
||||
if _epg.part_num:
|
||||
_epg.title += f" {_epg.part_num}부"
|
||||
_epgs.append(_epg)
|
||||
return _epgs
|
||||
@@ -0,0 +1,116 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List
|
||||
from urllib.parse import unquote
|
||||
|
||||
from bs4 import SoupStrainer
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider, no_endtime
|
||||
from epg2xml.utils import ParserBeautifulSoup as BeautifulSoup
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
|
||||
CH_CATE = [
|
||||
# 0은 전체 채널
|
||||
{"id": "1", "name": "UHD"},
|
||||
{"id": "3", "name": "홍보"},
|
||||
{"id": "4", "name": "지상파"},
|
||||
{"id": "5", "name": "홈쇼핑"},
|
||||
{"id": "6", "name": "종합편성"},
|
||||
{"id": "8", "name": "드라마/버라이어티"},
|
||||
{"id": "10", "name": "오락/음악"},
|
||||
{"id": "12", "name": "영화/시리즈"},
|
||||
{"id": "137", "name": "스포츠"},
|
||||
{"id": "206", "name": "취미/레저"},
|
||||
{"id": "317", "name": "애니/유아"},
|
||||
{"id": "442", "name": "교육"},
|
||||
{"id": "446", "name": "다큐/교양"},
|
||||
{"id": "447", "name": "뉴스/경제"},
|
||||
{"id": "448", "name": "공공/공익/정보"},
|
||||
{"id": "449", "name": "종교"},
|
||||
{"id": "491", "name": "오픈채널"},
|
||||
{"id": "507", "name": "유료"},
|
||||
{"id": "508", "name": "오디오"},
|
||||
]
|
||||
PTN_RATING = re.compile(r"([\d,]+)")
|
||||
|
||||
|
||||
class KT(EPGProvider):
|
||||
"""EPGProvider for KT
|
||||
|
||||
데이터: rawhtml
|
||||
요청수: #channels * #days
|
||||
특이사항:
|
||||
- 가끔 업데이트 지연
|
||||
- 프로그램 시작 시각만 제공
|
||||
"""
|
||||
|
||||
referer = "https://tv.kt.com/"
|
||||
title_regex = r"^(?P<title>.*?)\s?([\<\(]?(?P<part>\d+)부[\>\)]?)?$"
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
svc_channels = []
|
||||
url = "https://tv.kt.com/tv/channel/pChList.asp"
|
||||
params = {"ch_type": "1", "parent_menu_id": "0"}
|
||||
for c in CH_CATE:
|
||||
params.update({"parent_menu_id": c["id"]})
|
||||
soup = BeautifulSoup(self.request(url, method="POST", data=params))
|
||||
raw_channels = [unquote(x.find("span", {"class": "ch"}).text.strip()) for x in soup.select("li > a")]
|
||||
# 몇몇 채널은 (TV로만 제공, 유료채널) 웹에서 막혀있지만 실제로는 데이터가 있을 수 있다.
|
||||
for x in raw_channels:
|
||||
svc_channels.append(
|
||||
{
|
||||
"Name": " ".join(x.split()[1:]),
|
||||
"No": str(x.split()[0]),
|
||||
"ServiceId": x.split()[0],
|
||||
"Category": c["name"],
|
||||
}
|
||||
)
|
||||
return svc_channels
|
||||
|
||||
@no_endtime
|
||||
def get_programs(self) -> None:
|
||||
url = "https://tv.kt.com/tv/channel/pSchedule.asp"
|
||||
params = {
|
||||
"ch_type": "1", # 1: live 2: skylife 3: uhd live 4: uhd skylife
|
||||
"view_type": "1", # 1: daily 2: weekly
|
||||
"service_ch_no": "SVCID",
|
||||
"seldate": "EPGDATE",
|
||||
}
|
||||
for idx, _ch in enumerate(self.req_channels):
|
||||
log.info("%03d/%03d %s", idx + 1, len(self.req_channels), _ch)
|
||||
for nd in range(int(self.cfg["FETCH_LIMIT"])):
|
||||
day = date.today() + timedelta(days=nd)
|
||||
params.update({"service_ch_no": _ch.svcid, "seldate": day.strftime("%Y%m%d")})
|
||||
data = self.request(url, method="POST", data=params)
|
||||
try:
|
||||
_epgs = self.__epgs_of_day(_ch.id, data, day)
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s, %s", _ch, day)
|
||||
else:
|
||||
_ch.programs.extend(_epgs)
|
||||
|
||||
def __epgs_of_day(self, channelid: str, data: str, day: datetime) -> List[EPGProgram]:
|
||||
_epgs = []
|
||||
soup = BeautifulSoup(unquote(data), parse_only=SoupStrainer("tbody"))
|
||||
for row in soup.find_all("tr"):
|
||||
cell = row.find_all("td")
|
||||
hour = cell[0].text.strip()
|
||||
for minute, program, category in zip(*[c.find_all("p") for c in cell[1:]]):
|
||||
_epg = EPGProgram(channelid)
|
||||
_epg.stime = datetime.strptime(f"{day} {hour}:{minute.text.strip()}", "%Y-%m-%d %H:%M")
|
||||
_epg.title = program.text.replace("방송중 ", "").strip()
|
||||
if m := self.title_regex.match(_epg.title):
|
||||
_epg.title = m.group("title")
|
||||
if part_num := m.group("part"):
|
||||
_epg.part_num = part_num
|
||||
_epg.title += f" ({_epg.part_num}부)"
|
||||
_epg.categories = [category.text.strip()]
|
||||
for image in program.find_all("img", alt=True):
|
||||
if "시청 가능" not in (alt := image["alt"]):
|
||||
continue
|
||||
grade = PTN_RATING.match(alt)
|
||||
_epg.rating = int(grade.group(1)) if grade else 0
|
||||
_epgs.append(_epg)
|
||||
return _epgs
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider, no_endtime
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
|
||||
G_CODE = {"0": 0, "1": 7, "2": 12, "3": 15, "4": 19}
|
||||
P_CATE = {
|
||||
"00": "영화",
|
||||
"02": "만화",
|
||||
"03": "드라마",
|
||||
"05": "스포츠",
|
||||
"06": "교육",
|
||||
"07": None, # 어린이/교육
|
||||
"08": "연예/오락",
|
||||
"09": "공연/음악",
|
||||
"10": None, # 게임
|
||||
"11": "다큐",
|
||||
"12": "뉴스/정보",
|
||||
"13": "라이프",
|
||||
"15": None, # 홈쇼핑
|
||||
"16": None, # 경제/부동산
|
||||
"31": "기타",
|
||||
}
|
||||
|
||||
|
||||
class LG(EPGProvider):
|
||||
"""EPGProvider for LG
|
||||
|
||||
데이터: jsonapi
|
||||
요청수: #channels * #days
|
||||
특이사항:
|
||||
- 5일치만 제공
|
||||
- 프로그램 시작 시각만 제공
|
||||
참고:
|
||||
- 사이트 리뉴얼 이후 프로그램 카테고리가 아직 명확히 정해지지 않은 듯 하다.
|
||||
"""
|
||||
|
||||
referer = "https://www.lguplus.com/iptv/channel-guide"
|
||||
title_regex = r"\s?(?:\[.*?\])?(.*?)(?:\[(.*)\])?\s?(?:\(([\d,]+)회\))?\s?(<재>)?$"
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
svc_channels = []
|
||||
url = "https://www.lguplus.com/uhdc/fo/prdv/chnlgid/v1/tv-schedule-list"
|
||||
data = self.request(url)
|
||||
cate = {x["urcBrdCntrTvChnlGnreCd"]: x["urcBrdCntrTvChnlGnreNm"] for x in data["brdGnreDtoList"]}
|
||||
for ch in self.request(url)["brdCntrTvChnlIDtoList"]:
|
||||
svc_channels.append(
|
||||
{
|
||||
"Name": ch["urcBrdCntrTvChnlDscr"],
|
||||
"No": ch["urcBrdCntrTvChnlNo"],
|
||||
"ServiceId": ch["urcBrdCntrTvChnlId"],
|
||||
"Category": cate[ch["urcBrdCntrTvChnlGnreCd"]],
|
||||
}
|
||||
)
|
||||
return svc_channels
|
||||
|
||||
@no_endtime
|
||||
def get_programs(self) -> None:
|
||||
max_ndays = 5
|
||||
if int(self.cfg["FETCH_LIMIT"]) > max_ndays:
|
||||
log.warning(
|
||||
"""
|
||||
|
||||
***********************************************************************
|
||||
|
||||
%s는 당일포함 %d일치만 EPG를 제공하고 있습니다.
|
||||
|
||||
***********************************************************************
|
||||
""",
|
||||
self.provider_name,
|
||||
max_ndays,
|
||||
)
|
||||
url = "https://www.lguplus.com/uhdc/fo/prdv/chnlgid/v1/tv-schedule-list"
|
||||
params = {"urcBrdCntrTvChnlId": "SVCID", "brdCntrTvChnlBrdDt": "EPGDATE"}
|
||||
for idx, _ch in enumerate(self.req_channels):
|
||||
log.info("%03d/%03d %s", idx + 1, len(self.req_channels), _ch)
|
||||
for nd in range(min(int(self.cfg["FETCH_LIMIT"]), max_ndays)):
|
||||
day = date.today() + timedelta(days=nd)
|
||||
params.update({"urcBrdCntrTvChnlId": _ch.svcid, "brdCntrTvChnlBrdDt": day.strftime("%Y%m%d")})
|
||||
data = self.request(url, params=params) or {}
|
||||
data = data.get("brdCntTvSchIDtoList", [])
|
||||
if not data:
|
||||
log.warning("EPG 정보가 없거나 없는 채널입니다: %s %s", _ch, day)
|
||||
break # 오늘 없으면 내일도 없는 채널로 간주
|
||||
try:
|
||||
_epgs = self.__epgs_of_day(_ch.id, data)
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s, %s", _ch, day)
|
||||
else:
|
||||
_ch.programs.extend(_epgs)
|
||||
|
||||
def __epgs_of_day(self, channelid: str, data: list) -> List[EPGProgram]:
|
||||
_epgs = []
|
||||
for p in data:
|
||||
_epg = EPGProgram(channelid)
|
||||
_epg.title = p["brdPgmTitNm"]
|
||||
_epg.desc = p["brdPgmDscr"]
|
||||
_epg.stime = datetime.strptime(p["brdCntrTvChnlBrdDt"] + p["epgStrtTme"], "%Y%m%d%H:%M:%S")
|
||||
_epg.rating = G_CODE.get(p["brdWtchAgeGrdCd"], 0)
|
||||
_epg.extras = [p["brdPgmRsolNm"]] # 화질
|
||||
if p["subtBrdYn"] == "Y":
|
||||
_epg.extras.append("자막")
|
||||
if p["explBrdYn"] == "Y":
|
||||
_epg.extras.append("화면해설")
|
||||
if p["silaBrdYn"] == "Y":
|
||||
_epg.extras.append("수화")
|
||||
if m := self.title_regex.match(_epg.title):
|
||||
_epg.title = m.group(1)
|
||||
_epg.title_sub = m.group(2)
|
||||
_epg.ep_num = m.group(3)
|
||||
_epg.rebroadcast = bool(m.group(4))
|
||||
if P_CATE[p["urcBrdCntrTvSchdGnreCd"]]:
|
||||
_epg.categories = [P_CATE[p["urcBrdCntrTvSchdGnreCd"]]]
|
||||
_epgs.append(_epg)
|
||||
return _epgs
|
||||
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List
|
||||
from xml.sax.saxutils import unescape
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider, no_endtime
|
||||
from epg2xml.utils import ParserBeautifulSoup as BeautifulSoup
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
today = date.today()
|
||||
|
||||
CH_CATE = [
|
||||
{"name": "지상파", "u1": "100"},
|
||||
{"name": "종합 편성", "u1": "500"},
|
||||
{"name": "케이블", "u1": "200"},
|
||||
{"name": "스카이라이프", "u1": "300"},
|
||||
{"name": "해외위성", "u1": "9000"},
|
||||
{"name": "라디오", "u1": "400"},
|
||||
]
|
||||
|
||||
|
||||
class NAVER(EPGProvider):
|
||||
"""EPGProvider for NAVER
|
||||
|
||||
데이터: rawhtml
|
||||
요청수: #channels * #days
|
||||
특이사항:
|
||||
- 프로그램 시작 시각만 제공
|
||||
"""
|
||||
|
||||
referer = "https://m.search.naver.com/search.naver?where=m&query=%ED%8E%B8%EC%84%B1%ED%91%9C"
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
svc_channels = []
|
||||
url = "https://m.search.naver.com/p/csearch/content/nqapirender.nhn"
|
||||
params = {
|
||||
"key": "ScheduleChannelList",
|
||||
"where": "nexearch",
|
||||
"pkid": "66",
|
||||
"u1": "CATEGORY_CODE",
|
||||
}
|
||||
for c in CH_CATE:
|
||||
params.update({"u1": c["u1"]})
|
||||
data = self.request(url, params=params)
|
||||
if data["statusCode"].lower() != "success":
|
||||
log.error("유효한 응답이 아닙니다: %s", data["statusCode"])
|
||||
continue
|
||||
soup = BeautifulSoup(data["dataHtml"])
|
||||
for ch in soup.select('li[class="item"]'):
|
||||
try:
|
||||
svcid = ch.select("div > div[data-cid]")[0]["data-cid"]
|
||||
name = str(ch.select('div[class="channel_name"] > a')[0].text)
|
||||
svc_channels.append(
|
||||
{
|
||||
"Name": name,
|
||||
"ServiceId": svcid,
|
||||
"Category": c["name"],
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return svc_channels
|
||||
|
||||
@no_endtime
|
||||
def get_programs(self) -> None:
|
||||
url = "https://m.search.naver.com/p/csearch/content/nqapirender.nhn"
|
||||
params = {"key": "SingleChannelDailySchedule", "where": "m", "pkid": "66", "u1": "SVCID", "u2": "EPGDATE"}
|
||||
|
||||
for idx, _ch in enumerate(self.req_channels):
|
||||
log.info("%03d/%03d %s", idx + 1, len(self.req_channels), _ch)
|
||||
for nd in range(int(self.cfg["FETCH_LIMIT"])):
|
||||
day = today + timedelta(days=nd)
|
||||
params.update({"u1": _ch.svcid, "u2": day.strftime("%Y%m%d")})
|
||||
data = self.request(url, params=params)
|
||||
if data["statusCode"].lower() != "success":
|
||||
log.error("유효한 응답이 아닙니다: %s %s", _ch, data["statusCode"])
|
||||
continue
|
||||
try:
|
||||
_epgs = self.__epgs_of_day(_ch.id, data, day)
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s, %s", _ch, day)
|
||||
else:
|
||||
_ch.programs.extend(_epgs)
|
||||
|
||||
def __epgs_of_day(self, channelid: str, data: dict, day: datetime) -> List[EPGProgram]:
|
||||
_epgs = []
|
||||
soup = BeautifulSoup("".join(data["dataHtml"]))
|
||||
for row in soup.find_all("li", {"class": "list"}):
|
||||
cell = row.find_all("div")
|
||||
_epg = EPGProgram(channelid)
|
||||
_epg.title = unescape(cell[4].text.strip())
|
||||
_epg.stime = datetime.strptime(f"{str(day)} {cell[1].text.strip()}", "%Y-%m-%d %H:%M")
|
||||
for span in cell[3].findAll("span"):
|
||||
span_txt = span.text.strip()
|
||||
if "ico_age" in span["class"]:
|
||||
_epg.rating = int(span_txt.rstrip("세"))
|
||||
elif "re" in span["class"]:
|
||||
_epg.rebroadcast = True
|
||||
else:
|
||||
_epg.extras = (_epg.extras or []) + [span_txt]
|
||||
try:
|
||||
_epg.title_sub = cell[5].text.strip()
|
||||
except Exception:
|
||||
pass
|
||||
_epgs.append(_epg)
|
||||
return _epgs
|
||||
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List
|
||||
from xml.sax.saxutils import unescape
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
|
||||
GENRE_CODE = {
|
||||
"1": "드라마",
|
||||
"2": "영화",
|
||||
"4": "만화",
|
||||
"8": "스포츠",
|
||||
"9": "교육",
|
||||
"11": "홈쇼핑",
|
||||
"13": "예능",
|
||||
"14": "시사/다큐",
|
||||
"15": "음악",
|
||||
"16": "라이프",
|
||||
"17": "교양",
|
||||
"18": "뉴스",
|
||||
}
|
||||
|
||||
|
||||
class SK(EPGProvider):
|
||||
"""EPGProvider for SK
|
||||
|
||||
데이터: jsonapi
|
||||
요청수: #channels
|
||||
특이사항:
|
||||
- 최대 3일치를 한 번에
|
||||
"""
|
||||
|
||||
referer = "https://www.bworld.co.kr/"
|
||||
title_regex = r"^(.*?)(\(([\d,]+)회\))?(<(.*)>)?(\((재)\))?$"
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
svc_channels = []
|
||||
url = "https://www.bworld.co.kr/content/realtime/realtime_list.ajax"
|
||||
params = {"pack": "PM50305785"}
|
||||
c_name = ""
|
||||
for x in self.request(url, params=params):
|
||||
if x["depth"] == "1":
|
||||
c_name = x["m_name"]
|
||||
elif x["depth"] == "2" and c_name and c_name not in ["프로모션"]:
|
||||
svc_channels.append(
|
||||
{
|
||||
"Name": unescape(x["m_name"]),
|
||||
"No": str(x["ch_no"]),
|
||||
"ServiceId": x["id_svc"],
|
||||
"Category": c_name,
|
||||
}
|
||||
)
|
||||
return svc_channels
|
||||
|
||||
def get_programs(self) -> None:
|
||||
max_ndays = 3
|
||||
if int(self.cfg["FETCH_LIMIT"]) > max_ndays:
|
||||
log.warning(
|
||||
"""
|
||||
|
||||
***********************************************************************
|
||||
|
||||
%s는 당일포함 %d일치만 EPG를 제공하고 있습니다.
|
||||
|
||||
***********************************************************************
|
||||
""",
|
||||
self.provider_name,
|
||||
max_ndays,
|
||||
)
|
||||
url = "https://www.bworld.co.kr/myb/core-prod/product/btv-channel/week-frmt-list"
|
||||
params = {"idSvc": "SVCID", "stdDt": "EPGDATE", "gubun": "week"}
|
||||
|
||||
for idx, _ch in enumerate(self.req_channels):
|
||||
log.info("%03d/%03d %s", idx + 1, len(self.req_channels), _ch)
|
||||
params.update({"idSvc": _ch.svcid, "stdDt": date.today().strftime("%Y%m%d")})
|
||||
try:
|
||||
infolist = self.request(url, params=params)["result"]["chnlFrmtInfoList"]
|
||||
assert isinstance(infolist, list)
|
||||
except Exception:
|
||||
log.exception("예상치 못한 응답: %s", params)
|
||||
continue
|
||||
for nd in range(min(int(self.cfg["FETCH_LIMIT"]), max_ndays)):
|
||||
day = date.today() + timedelta(days=nd)
|
||||
try:
|
||||
_epgs = self.__epgs_of_day(_ch.id, infolist, day)
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s, %s", _ch, day)
|
||||
else:
|
||||
_ch.programs.extend(_epgs)
|
||||
|
||||
def __epgs_of_day(self, channelid: str, data: list, day: datetime) -> List[EPGProgram]:
|
||||
_epgs = []
|
||||
for info in data:
|
||||
if info["eventDt"] != day.strftime("%Y%m%d"):
|
||||
continue
|
||||
_epg = EPGProgram(channelid)
|
||||
_epg.title = info["nmTitle"]
|
||||
if m := self.title_regex.match(_epg.title):
|
||||
_epg.title = m.group(1)
|
||||
_epg.title_sub = m.group(5)
|
||||
_epg.rebroadcast = bool(m.group(7))
|
||||
_epg.ep_num = m.group(3)
|
||||
_epg.rating = int(info.get("cdRating") or "0")
|
||||
_epg.stime = datetime.strptime(info["dtEventStart"], "%Y%m%d%H%M%S")
|
||||
_epg.etime = datetime.strptime(info["dtEventEnd"], "%Y%m%d%H%M%S")
|
||||
if info["cdGenre"] and (info["cdGenre"] in GENRE_CODE):
|
||||
_epg.categories = [GENRE_CODE[info["cdGenre"]]]
|
||||
_epg.desc = info["nmSynop"] or None # 값이 없음
|
||||
_epgs.append(_epg)
|
||||
return _epgs
|
||||
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
|
||||
|
||||
class SPOTV(EPGProvider):
|
||||
"""EPGProvider for SPOTV
|
||||
|
||||
데이터: jsonapi
|
||||
요청수: #days
|
||||
특이사항:
|
||||
- 5일치만 제공
|
||||
"""
|
||||
|
||||
referer = "https://www.spotvnow.co.kr/channel"
|
||||
title_regex = r"\s?(?:\[(.*?)\])?\s?(.*?)\s?(?:[\(<](.*)[\)>])?\s?(?:-(\d+))?\s?(?:<?([\d,]+)회>?)?\s?$"
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
url = "https://www.spotvnow.co.kr/api/v3/channel"
|
||||
return [
|
||||
{
|
||||
"Name": ch["name"],
|
||||
"ServiceId": ch["id"],
|
||||
"Icon_url": ch["logo"],
|
||||
}
|
||||
for ch in self.request(url)
|
||||
]
|
||||
|
||||
def __dt(self, dt: str) -> datetime:
|
||||
if not dt:
|
||||
return None
|
||||
if dt.endswith("24:00"):
|
||||
return datetime.strptime(dt.replace("24:00", "00:00"), "%Y-%m-%d %H:%M") + timedelta(days=1)
|
||||
return datetime.strptime(dt, "%Y-%m-%d %H:%M")
|
||||
|
||||
def get_programs(self) -> None:
|
||||
max_ndays = 5
|
||||
if int(self.cfg["FETCH_LIMIT"]) > max_ndays:
|
||||
log.warning(
|
||||
"""
|
||||
|
||||
***********************************************************************
|
||||
|
||||
%s는 당일포함 %d일치만 EPG를 제공하고 있습니다.
|
||||
|
||||
***********************************************************************
|
||||
""",
|
||||
self.provider_name,
|
||||
max_ndays,
|
||||
)
|
||||
data = []
|
||||
for nd in range(min(int(self.cfg["FETCH_LIMIT"]), max_ndays)):
|
||||
day = date.today() + timedelta(days=nd)
|
||||
url = "https://www.spotvnow.co.kr/api/v3/program/" + day.strftime("%Y-%m-%d")
|
||||
try:
|
||||
data.extend(self.request(url))
|
||||
except Exception:
|
||||
log.exception("데이터 가져오는 중 에러:")
|
||||
continue
|
||||
|
||||
# 날짜의 경계에서 발생할 수 있는 중복 제거
|
||||
_data = []
|
||||
for _d in data:
|
||||
_d.pop("date", None)
|
||||
if _d not in _data:
|
||||
_data.append(_d)
|
||||
|
||||
for idx, _ch in enumerate(self.req_channels):
|
||||
log.info("%03d/%03d %s", idx + 1, len(self.req_channels), _ch)
|
||||
try:
|
||||
_epgs = self.__epgs_of_channel(_ch.id, _data, _ch.svcid)
|
||||
except AssertionError as e:
|
||||
log.warning("%s: %s", e, _ch)
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s", _ch)
|
||||
else:
|
||||
_ch.programs.extend(_epgs)
|
||||
|
||||
def __epgs_of_channel(self, channelid: str, data: dict, svcid: str) -> List[EPGProgram]:
|
||||
programs = [x for x in data if x["channelId"] == svcid]
|
||||
assert programs, "EPG 정보가 없거나 없는 채널입니다"
|
||||
|
||||
_epgs = []
|
||||
for p in programs:
|
||||
_epg = EPGProgram(channelid)
|
||||
_epg.title = p["title"]
|
||||
_epg.stime = self.__dt(p["startTime"])
|
||||
# 끝나는 시간이 없으면 해당일 자정으로 강제
|
||||
_epg.etime = self.__dt(p["endTime"]) or (_epg.stime.replace(hour=0, minute=0) + timedelta(days=1))
|
||||
if _epg.stime == _epg.etime:
|
||||
continue
|
||||
|
||||
if m := self.title_regex.match(_epg.title):
|
||||
_epg.title = m.group(2)
|
||||
subs = []
|
||||
if prefix := m.group(1):
|
||||
subs.append(prefix)
|
||||
if sub := m.group(3):
|
||||
subs += [sub.replace(")(", ", ").replace(") (", ", ")]
|
||||
title_sub = ", ".join(subs)
|
||||
if num := m.group(4):
|
||||
title_sub += f"-{num}"
|
||||
if title_sub:
|
||||
_epg.title_sub = title_sub
|
||||
_epg.ep_num = m.group(5)
|
||||
if p["type"] == 300:
|
||||
# 100: live, 200: 본방송
|
||||
_epg.rebroadcast = True
|
||||
_epgs.append(_epg)
|
||||
return _epgs
|
||||
@@ -0,0 +1,180 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from itertools import islice
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
today = date.today()
|
||||
|
||||
PRIORITY_IMG_CODE = ["CAIC2300", "CAIC1600", "CAIC0100", "CAIC0400"]
|
||||
G_CODE = {
|
||||
"CPTG0100": 0,
|
||||
"CPTG0200": 7,
|
||||
"CPTG0300": 12,
|
||||
"CPTG0400": 15,
|
||||
"CPTG0500": 19,
|
||||
"CMMG0100": 0,
|
||||
"CMMG0200": 12,
|
||||
"CMMG0300": 15,
|
||||
"CMMG0400": 19,
|
||||
}
|
||||
|
||||
|
||||
class TVING(EPGProvider):
|
||||
"""EPGProvider for TVING
|
||||
|
||||
데이터: jsonapi
|
||||
요청수: #channels/20 * #days * 24/3
|
||||
특이사항:
|
||||
- 최대 20채널 최대 3시간 허용
|
||||
"""
|
||||
|
||||
referer = "https://www.tving.com/schedule/main.do"
|
||||
tps = 3.0
|
||||
|
||||
url = "https://api.tving.com/v2/media/schedules"
|
||||
base_params = {
|
||||
"pageNo": "1",
|
||||
"pageSize": "20", # maximum 20
|
||||
"order": "chno",
|
||||
"scope": "all",
|
||||
"adult": "all",
|
||||
"free": "all",
|
||||
"broadDate": "20200608",
|
||||
"broadcastDate": "20200608",
|
||||
"startBroadTime": "030000", # 최대 3시간 간격
|
||||
"endBroadTime": "060000",
|
||||
# "channelCode": "C06941,C07381,...",
|
||||
"screenCode": "CSSD0100",
|
||||
"networkCode": "CSND0900",
|
||||
"osCode": "CSOD0900",
|
||||
"teleCode": "CSCD0900",
|
||||
"apiKey": "1e7952d0917d6aab1f0293a063697610",
|
||||
}
|
||||
|
||||
def __params(self, **params) -> dict:
|
||||
"""returns url parameters for api requests with base ones"""
|
||||
p = self.base_params.copy()
|
||||
p.update(params)
|
||||
return p
|
||||
|
||||
def __get(self, url: str, **kwargs) -> List[dict]:
|
||||
params = self.__params(**kwargs.pop("params", {}))
|
||||
_page = 1
|
||||
_results = []
|
||||
while True:
|
||||
params["pageNo"] = str(_page)
|
||||
_data = self.request(url=url, params=params, **kwargs)
|
||||
if _data["header"]["status"] != 200:
|
||||
raise requests.exceptions.RequestException
|
||||
_results.extend(_data["body"]["result"])
|
||||
if _data["body"]["has_more"] == "Y":
|
||||
_page += 1
|
||||
else:
|
||||
break
|
||||
return _results
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
def get_imgurl(_item: dict):
|
||||
for _code in PRIORITY_IMG_CODE:
|
||||
try:
|
||||
img_list = [x for x in _item["image"] if x["code"] == _code]
|
||||
if not img_list:
|
||||
continue
|
||||
return "https://image.tving.com" + (img_list[0].get("url") or img_list[0]["url2"])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
params = {
|
||||
"broadDate": today.strftime("%Y%m%d"),
|
||||
"broadcastDate": today.strftime("%Y%m%d"),
|
||||
"startBroadTime": datetime.now().strftime("%H0000"),
|
||||
"endBroadTime": (datetime.now() + timedelta(hours=3)).strftime("%H0000"),
|
||||
}
|
||||
return [
|
||||
{
|
||||
"Name": x["channel_name"]["ko"],
|
||||
"Icon_url": get_imgurl(x),
|
||||
"ServiceId": x["channel_code"],
|
||||
"Category": x["schedules"][0]["channel"]["category_name"]["ko"],
|
||||
}
|
||||
for x in self.__get(self.url, params=params)
|
||||
if x["schedules"] is not None
|
||||
]
|
||||
|
||||
def get_programs(self) -> None:
|
||||
def grouper(iterable, n):
|
||||
it = iter(iterable)
|
||||
group = tuple(islice(it, n))
|
||||
while group:
|
||||
yield group
|
||||
group = tuple(islice(it, n))
|
||||
|
||||
for gid, chgroup in enumerate(grouper(self.req_channels, 20)):
|
||||
schdict = {}
|
||||
params = {"channelCode": ",".join([x.svcid.strip() for x in chgroup])}
|
||||
for nd in range(int(self.cfg["FETCH_LIMIT"])):
|
||||
day = today + timedelta(days=nd)
|
||||
params.update({"broadDate": day.strftime("%Y%m%d"), "broadcastDate": day.strftime("%Y%m%d")})
|
||||
for t in range(8):
|
||||
params.update({"startBroadTime": f"{t*3:02d}0000", "endBroadTime": f"{t*3+3:02d}0000"})
|
||||
for ch in self.__get(self.url, params=params):
|
||||
chcode = ch["channel_code"]
|
||||
schdict.setdefault(chcode, [])
|
||||
toappend = ch.get("schedules") or []
|
||||
try:
|
||||
# 3시간 단위로 요청된 스케줄 앞 뒤로 중복이 있을 수 있다.
|
||||
if schdict[chcode][-1] == toappend[0]:
|
||||
toappend = toappend[1:]
|
||||
except Exception:
|
||||
pass
|
||||
schdict[chcode] += toappend
|
||||
|
||||
for idx, _ch in enumerate(chgroup):
|
||||
log.info("%03d/%03d %s", gid * 20 + idx + 1, len(self.req_channels), _ch)
|
||||
try:
|
||||
_epgs = self.__epgs_of_channel(_ch.id, schdict[_ch.svcid])
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s", _ch)
|
||||
else:
|
||||
_ch.programs.extend(_epgs)
|
||||
|
||||
def __epgs_of_channel(self, channelid: str, schedules: List[dict]) -> List[EPGProgram]:
|
||||
_epgs = []
|
||||
for sch in schedules:
|
||||
_epg = EPGProgram(channelid)
|
||||
# 공통
|
||||
_epg.stime = datetime.strptime(str(sch["broadcast_start_time"]), "%Y%m%d%H%M%S")
|
||||
_epg.etime = datetime.strptime(str(sch["broadcast_end_time"]), "%Y%m%d%H%M%S")
|
||||
_epg.rebroadcast = sch["rerun_yn"] == "Y"
|
||||
|
||||
get_from = "movie" if sch["movie"] else "program"
|
||||
img_code = "CAIM2100" if sch["movie"] else "CAIP0900"
|
||||
|
||||
_epg.rating = G_CODE[sch[get_from].get("grade_code", "CPTG0100")]
|
||||
_epg.title = sch[get_from]["name"]["ko"]
|
||||
_epg.title_sub = sch[get_from]["name"].get("en")
|
||||
if cate1 := sch[get_from]["category1_name"].get("ko"):
|
||||
_epg.categories = [cate1]
|
||||
if cate2 := sch[get_from]["category2_name"].get("ko"):
|
||||
_epg.categories = (_epg.categories or []) + [cate2]
|
||||
_epg.cast = [{"name": x, "title": "actor"} for x in sch[get_from]["actor"]]
|
||||
_epg.crew = [{"name": x, "title": "director"} for x in sch[get_from]["director"]]
|
||||
|
||||
poster = [x["url"] for x in sch[get_from]["image"] if x["code"] == img_code]
|
||||
if poster:
|
||||
_epg.poster_url = "https://image.tving.com" + poster[0]
|
||||
# _prog.poster_url += '/dims/resize/236'
|
||||
|
||||
_epg.desc = sch[get_from]["story" if sch["movie"] else "synopsis"]["ko"]
|
||||
if sch["episode"]:
|
||||
episode = sch["episode"]["frequency"]
|
||||
_epg.ep_num = "" if episode == 0 else str(episode)
|
||||
_epg.desc = sch["episode"]["synopsis"]["ko"]
|
||||
_epgs.append(_epg)
|
||||
return _epgs
|
||||
@@ -0,0 +1,155 @@
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
from xml.sax.saxutils import unescape
|
||||
|
||||
from epg2xml.providers import EPGProgram, EPGProvider
|
||||
|
||||
log = logging.getLogger(__name__.rsplit(".", maxsplit=1)[-1].upper())
|
||||
today = date.today()
|
||||
|
||||
|
||||
class WAVVE(EPGProvider):
|
||||
"""EPGProvider for WAVVE
|
||||
|
||||
데이터: jsonapi
|
||||
요청수: 1
|
||||
특이사항:
|
||||
- 해외나 VPS는 차단 가능성이 높음
|
||||
"""
|
||||
|
||||
referer = "https://www.wavve.com/"
|
||||
title_regex = r"^(.*?)(?:\s*[\(<]?([\d]+)회[\)>]?)?(?:\([월화수목금토일]?\))?(\([선별전주\(\)재방]*?재[\d방]?\))?\s*(?:\[(.+)\])?$"
|
||||
|
||||
base_url = "https://apis.wavve.com"
|
||||
base_params = {
|
||||
"apikey": "E5F3E0D30947AA5440556471321BB6D9",
|
||||
"client_version": "6.0.1",
|
||||
"device": "pc",
|
||||
"drm": "wm",
|
||||
"partner": "pooq",
|
||||
"pooqzone": "none",
|
||||
"region": "kor",
|
||||
"targetage": "all",
|
||||
}
|
||||
|
||||
def __init__(self, cfg):
|
||||
super().__init__(cfg)
|
||||
self.sess.headers.update({"wavve-credential": "none"})
|
||||
|
||||
def __url(self, url: str) -> str:
|
||||
"""completes partial urls from api response or for api request"""
|
||||
if url.startswith(("http://", "https://")):
|
||||
return url
|
||||
if url.startswith("/"):
|
||||
return self.base_url + url
|
||||
return "https://" + url
|
||||
|
||||
def __params(self, **params) -> dict:
|
||||
"""returns url parameters for api requests with base ones"""
|
||||
p = self.base_params.copy()
|
||||
p.update(params)
|
||||
return p
|
||||
|
||||
def __get(self, url: str, **kwargs):
|
||||
url = self.__url(url)
|
||||
params = self.__params(**kwargs.pop("params", {}))
|
||||
return self.request(url, params=params, **kwargs)
|
||||
|
||||
def get_svc_channels(self) -> List[dict]:
|
||||
today_str = today.strftime("%Y-%m-%d")
|
||||
hour_min = datetime.now().hour // 3
|
||||
# 현재 시간과 가까운 미래에 서비스 가능한 채널만 가져옴
|
||||
params = {
|
||||
"enddatetime": f"{today_str} {(hour_min+1)*3:02d}:00",
|
||||
"genre": "all",
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
"startdatetime": f"{today_str} {hour_min*3:02d}:00",
|
||||
}
|
||||
return [
|
||||
{
|
||||
"Name": x["channelname"],
|
||||
"Icon_url": self.__url(x["channelimage"]),
|
||||
"ServiceId": x["channelid"],
|
||||
}
|
||||
for x in self.__get("/live/epgs", params=params)["list"]
|
||||
]
|
||||
|
||||
def __epg_of_program(self, channelid: str, data: dict) -> EPGProgram:
|
||||
_epg = EPGProgram(channelid)
|
||||
_epg.stime = datetime.strptime(data["starttime"], "%Y-%m-%d %H:%M")
|
||||
_epg.etime = datetime.strptime(data["endtime"], "%Y-%m-%d %H:%M")
|
||||
# 채널이름은 그대로 들어오고 프로그램 제목은 escape되어 들어옴
|
||||
_epg.title = unescape(data["title"])
|
||||
if m := self.title_regex.match(_epg.title):
|
||||
_epg.title = m.group(1)
|
||||
_epg.title_sub = m.group(4)
|
||||
episode = (m.group(2) or "").replace("회", "").strip()
|
||||
_epg.ep_num = None if episode == "0" else episode
|
||||
_epg.rebroadcast = bool(m.group(3))
|
||||
_epg.rating = 0 if data["targetage"] == "n" else int(data["targetage"])
|
||||
|
||||
# 추가 정보 가져오기
|
||||
if not self.cfg["GET_MORE_DETAILS"]:
|
||||
return _epg
|
||||
programid = data["programid"].strip()
|
||||
if not programid:
|
||||
# 개별 programid가 없는 경우도 있으니 체크해야함
|
||||
return _epg
|
||||
detail = self.get_program_details(programid)
|
||||
if not detail:
|
||||
return _epg
|
||||
# 여러가지 추가 정보가 제공되지만
|
||||
# 방송되지 않은 미래의 프로그램/에피소드 정보는 반영되지 않았기에
|
||||
# 일부 정보만 유효함을 유념
|
||||
synopsis = detail["seasonsynopsis"] or detail["programsynopsis"] or detail["episodesynopsis"]
|
||||
_epg.desc = "\n".join(
|
||||
[x.replace("<br>", "\n").strip() for x in synopsis.splitlines()]
|
||||
) # carriage return(\r) 제거, <br> 제거
|
||||
_epg.categories = [detail["genretext"].strip()]
|
||||
_epg.poster_url = self.__url(detail["seasonposterimage"].strip())
|
||||
_epg.keywords = [x["text"] for x in detail["tags"]["list"]]
|
||||
actors = detail.get("season_actors") or detail.get("actors") or {"list": []}
|
||||
directors = detail.get("season_directors") or detail.get("directors") or {"list": []}
|
||||
writers = detail.get("season_writers") or detail.get("writers") or {"list": []}
|
||||
_epg.cast = [{"name": x["text"], "title": "actor"} for x in actors["list"]]
|
||||
_epg.crew = [{"name": x["text"], "title": "director"} for x in directors["list"]]
|
||||
_epg.crew += [{"name": x["text"], "title": "writer"} for x in writers["list"]]
|
||||
return _epg
|
||||
|
||||
def get_programs(self) -> None:
|
||||
# parameters for requests
|
||||
params = {
|
||||
"enddatetime": (today + timedelta(days=int(self.cfg["FETCH_LIMIT"]) - 1)).strftime("%Y-%m-%d 24:00"),
|
||||
"genre": "all",
|
||||
"limit": 500,
|
||||
"offset": 0,
|
||||
"startdatetime": today.strftime("%Y-%m-%d 00:00"),
|
||||
}
|
||||
channeldict = {x["channelid"]: x for x in self.__get("/live/epgs", params=params)["list"]}
|
||||
|
||||
for idx, _ch in enumerate(self.req_channels):
|
||||
log.info("%03d/%03d %s", idx + 1, len(self.req_channels), _ch)
|
||||
for program in channeldict[_ch.svcid]["list"]:
|
||||
try:
|
||||
_epg = self.__epg_of_program(_ch.id, program)
|
||||
except Exception:
|
||||
log.exception("프로그램 파싱 중 예외: %s", _ch)
|
||||
else:
|
||||
_ch.programs.append(_epg)
|
||||
|
||||
@lru_cache
|
||||
def get_program_details(self, programid: str) -> dict:
|
||||
try:
|
||||
params = {"history": "season", "programid": programid}
|
||||
data = self.__get("/fz/vod/programs/landing", params=params)
|
||||
if data.get("resultcode") in ["550"]:
|
||||
# 애초에 유효하지 않은 programid가 있을 수 있음
|
||||
# { "resultcode": "550", "resultmessage": "해당 데이터가 없습니다." }
|
||||
return None
|
||||
return self.__get(f"/fz/vod/contents-detail/{data['content_id'].strip()}")
|
||||
except Exception:
|
||||
log.exception("프로그램 상세 정보 요청 중 예외: %s", programid)
|
||||
return None
|
||||
Reference in New Issue
Block a user