Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-02 15:42:31 +00:00
parent bde490f53e
commit 484b1d8d12
13 changed files with 9670 additions and 16951 deletions
+328 -43
View File
@@ -6,6 +6,12 @@
广东卫视4K,
深圳卫视4K,
南国都市4K,
北京卫视4K,
CCTV4K|CCTV-4K|CCTV 4K|CCTV4K真4K,
8K,#genre#
北京纪实8K,
CCTV8K|CCTV-8K|CCTV 8K,
央视,#genre#
CCTV1|CCTV1-综合|CCTV-1 综合|CCTV1|CCTV-1,
@@ -39,9 +45,9 @@ CCTV电视指南|电视指南,
CCTV世界地理|世界地理,
CCTV卫生健康|卫生健康,
中国天气,
CETV1|CETV-1|中国教育1台,
CETV2|CETV-2|中国教育2台,
CETV4|CETV-4|中国教育4台,
CETV1|CETV-1|CETV-01|中国教育1台,
CETV2|CETV-2|CETV-02|中国教育2台,
CETV4|CETV-4CETV-03|中国教育4台,
文化精品,
CGTN英语,
CGTN记录,
@@ -51,8 +57,12 @@ CGTN西语,
CGTN阿语,
CGTN,
直播中国,
CCTV 4K,
CCTV 8K,
卫视,#genre#
重温经典,
@@ -106,7 +116,14 @@ CCTV 8K,
河北4K,
山西卫视,
陕西卫视,
北京卫视4K,
数字,#genre#
CHC影迷电影|CHC高清电影,
@@ -121,6 +138,8 @@ SiTV 劲爆体育|劲爆体育,
SiTV动漫秀场|动漫秀场,
生活时尚,
NewTV 精品记录,
NewTV 武搏世界,
NewTV黑莓电影|黑莓电影,
NewTV黑莓动画|黑莓动画,
NewTV超级体育|超级体育|NewTV超级体育 4M1080|NewTV_超级体育「IPV6」,
@@ -160,6 +179,12 @@ NewTV哒啵赛事|哒啵赛事,
华数4K,
纯享4K,
安徽,#genre#
滁州新闻综合频道|滁州新闻综合,
滁州科教频道|滁州科教,
@@ -167,6 +192,12 @@ NewTV哒啵赛事|哒啵赛事,
铜陵新闻综合频道|铜陵新闻|铜陵综合,
亳州农村,
北京,#genre#
北京财经,
北京青年,
@@ -175,16 +206,36 @@ NewTV哒啵赛事|哒啵赛事,
四海钓鱼,
环球旅游,
重庆,#genre#
重庆汽摩,
万州三峡移民,
福建,#genre#
漳州新闻综合,
广西,#genre#
南宁影视娱乐,
河北,#genre#
邢台综合,
河北都市,
@@ -195,8 +246,21 @@ NewTV哒啵赛事|哒啵赛事,
河北公共,
鹿泉二套,
宁夏,#genre#
内蒙古,#genre#
内蒙新闻,
内蒙经济,
@@ -212,6 +276,13 @@ NewTV哒啵赛事|哒啵赛事,
赤峰新闻综合,
乌兰察布新闻,
江苏,#genre#
江苏城市,
江苏综艺,
@@ -224,6 +295,13 @@ NewTV哒啵赛事|哒啵赛事,
苏州4k,
浙江,#genre#
浙江钱江,
浙江钱江都市,
@@ -251,10 +329,10 @@ NewTV哒啵赛事|哒啵赛事,
宁波电视台3套,
宁波电视台4套,
宁波4套影视,
NBTV-1,
NBTV-2,
NBTV-3,
NBTV-4,
NBTV1|NBTV-1,
NBTV2|NBTV-2,
NBTV3|NBTV-3,
NBTV4|NBTV-4,
浙江教科,
浙江经济,
之江纪录,
@@ -348,6 +426,14 @@ NBTV-4,
舟山新闻综合,
舟山群岛旅游,
湖南,#genre#
湖南都市,
湖南经视,
@@ -364,6 +450,12 @@ NBTV-4,
长沙女性,
衡阳新闻综合,
湖北,#genre#
湖北综合,
湖北经视,
@@ -380,19 +472,62 @@ NBTV-4,
武汉教育,
江夏新闻综合,
贵州,#genre#
甘肃,#genre#
甘肃公共,
河南,#genre#
海南,#genre#
吉林,#genre#
黑龙江,#genre#
辽宁,#genre#
辽宁北方,
辽宁影视剧,
@@ -403,6 +538,14 @@ NBTV-4,
辽宁体育,
朝阳新闻综合,
广东,#genre#
汕头新闻综合|汕头综合|汕头综合高清|「移动1」汕头综合HD,
广东珠江,
@@ -422,8 +565,21 @@ NBTV-4,
汕头经济,
南国都市,
青海,#genre#
上海,#genre#
上海都市,
上海外语,
@@ -434,9 +590,27 @@ NBTV-4,
上海纪实,
新闻综合,
陕西,#genre#
西安新闻,
浙江,#genre#
浙江新闻,
浙江国际,
@@ -504,6 +678,15 @@ NBTV-4,
余姚姚江文化,
中国蓝新闻,
山东,#genre#
山东少儿,
山东新闻,
@@ -517,23 +700,62 @@ NBTV-4,
济南综合,
济南教育,
山西,#genre#
山西影视,
山西文体生活|山西文体,
山西社会与法治|山西法治,
山西经济与科技|山西经济,
四川,#genre#
四川新闻,
四川妇女儿童,
天津,#genre#
新疆,#genre#
云南,#genre#
港澳台,#genre#
凤凰卫视中文台|凤凰中文|鳳凰中文,
凤凰卫视资讯台|凤凰资讯|鳳凰資訊,
@@ -591,8 +813,8 @@ now新闻,
有线综合,
now直播,
now财经,
RTHK-31,
RTHK-32,
RTHK31|RTHK-31,
RTHK32|RTHK-32,
寰宇闽南,
寰宇财经,
东森财经,
@@ -712,6 +934,13 @@ NOW NEWS,
NU2,
华视HD,
YouTube,#genre#
三立INEWS,
ABC NEWS,
@@ -720,46 +949,102 @@ INDIA TODAY,
NBC NEWS,
影视,#genre#
微服私访,
林正英电影,
天映经典,
东森洋片,
YY,#genre#
铁齿铜牙纪晓岚,1354143978
111,1382571192
封神榜,1353426319
神雕侠侣,1351762426
狄仁杰,1351755386
上海滩,1382745184
变形金刚,1382736803
迷,1382736719
电影,37454459
大唐双龙传,1354930979
剧集轮播,#genre#
西游记,
猫和老鼠,
相声小品,
刑事侦缉档案,
陀枪师姐,
洗冤录,
妙手仁心,
扫黄先锋,
笑看风云,
地方,#genre#
东阳影视生活,
咪咕,#genre#
广播,#genre#
MY,
埋堆堆,#genre#
MTV,#genre#
IHOT,#genre#
iHOT爱喜剧,
iHOT爱娱乐,
iHOT爱经典,
iHOT爱江湖,
iHOT爱体育,
iHOT爱探索,
iHOT爱奇谈,
iHOT爱世界,
iHOT爱怀旧,
iHOT爱极限,
iHOT爱幼教,
iHOT爱解密,
iHOT爱历史,
iHOT爱猎奇,
iHOT爱都市,
iHOT爱美食,
iHOT爱时尚,
iHOT爱青春,
iHOT爱家庭,
春晚,#genre#
春晚2024年,
春晚2023年,
春晚2022年,
春晚2021年,
春晚2020年,
春晚2019年,
春晚2018年,
春晚2017年,
春晚2015年,
春晚2014年,
春晚2013年,
春晚2011年,
春晚2010年,
春晚2009年,
春晚2008年,
春晚2007年,
春晚2006年,
春晚2005年,
春晚2004年,
春晚2003年,
春晚2002年,
春晚2001年,
春晚2000年,
春晚1999年,
春晚1998年,
春晚1997年,
春晚1996年,
春晚1995年,
春晚1994年,
春晚1993年,
春晚1992年,
春晚1991年,
春晚1989年,
春晚1988年,
春晚1987年,
春晚1986年,
春晚1985年,
春晚1984年,
春晚1983年,
-1
View File
@@ -112,7 +112,6 @@ CCTV 8K,
CHC影迷电影|CHC高清电影,
CHC动作电影,
CHC家庭影院,
1905电影网(国内)|1905电影网|1905国内|1905国内电影,
睛彩青少,
睛彩竞技,
睛彩篮球,
+2 -2
View File
@@ -1,11 +1,11 @@
https://iptv-org.github.io/iptv/index.m3u
https://raw.githubusercontent.com/YueChan/Live/main/Global.m3u
https://raw.githubusercontent.com/fanmingming/live/main/tv/m3u/ipv6.m3u
https://raw.githubusercontent.com/hujingguang/ChinaIPTV/main/cnTV_AutoUpdate.m3u8
https://raw.githubusercontent.com/YueChan/Live/main/Global.m3u
https://raw.githubusercontent.com/kimwang1978/collect-txt/refs/heads/main/bbxx_lite.m3u
https://raw.githubusercontent.com/best-fan/iptv-sources/refs/heads/main/cn_all_status.m3u8
+260 -276
View File
@@ -4,435 +4,419 @@ import requests
import logging
import shutil
import threading
import chardet
from collections import OrderedDict
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# === 配置日志 ===
def setup_logger():
# 确保日志目录存在
# === Configure Logger ===
def setupLogger():
os.makedirs("logs", exist_ok=True)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger.handlers.clear() # 清除已有 handler 避免重复
logger.handlers.clear()
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
# 控制台输出
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# 文件输出
file_handler = logging.FileHandler("logs/iptv_update.log", encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(formatter)
logger.addHandler(consoleHandler)
fileHandler = logging.FileHandler("logs/iptv_update.log", encoding="utf-8")
fileHandler.setFormatter(formatter)
logger.addHandler(fileHandler)
return logger
logger = setup_logger()
logger = setupLogger()
# 全局锁,用于文件写入
write_lock = threading.Lock()
writeLock = threading.Lock()
def ensure_dir(file_path):
"""确保文件所在的目录存在"""
dirname = os.path.dirname(file_path)
if dirname:
os.makedirs(dirname, exist_ok=True)
def ensureDir(filePath):
"""Ensure the directory of the file exists"""
dirName = os.path.dirname(filePath)
if dirName:
os.makedirs(dirName, exist_ok=True)
def get_session():
"""创建一个带有重试机制的requests Session"""
def getSession():
"""Create a robust session with retry mechanisms and standard browser headers"""
session = requests.Session()
retry = Retry(connect=3, backoff_factor=0.5)
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "*/*",
"Connection": "keep-alive"
})
retry = Retry(connect=3, read=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retry)
session.mount('http://', adapter)
session.mount('https://', adapter)
return session
def load_urls_from_file(file_path):
"""从文本文件加载URL列表"""
def loadUrlsFromFile(filePath):
"""Load URL list from a text file"""
urls = []
if not os.path.exists(file_path):
logger.warning(f"URL配置文件未找到: {file_path}")
if not os.path.exists(filePath):
logger.warning(f"URL configuration file not found: {filePath}")
return urls
try:
# 使用 utf-8-sig 安全过滤由于记事本编辑可能产生的 \ufeff BOM 头
with open(file_path, "r", encoding="utf-8-sig") as f:
with open(filePath, "r", encoding="utf-8-sig") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
urls.append(line)
logger.info(f"{file_path} 加载了 {len(urls)} 个源")
logger.info(f"Loaded {len(urls)} sources from {filePath}")
except Exception as e:
logger.error(f"读取URL文件失败: {e}")
logger.error(f"Failed to read URL file: {e}")
return urls
def parse_template(template_file):
"""解析模板文件"""
template_channels = OrderedDict()
current_category = None
def parseTemplate(templateFile):
"""Parse template file structure"""
templateChannels = OrderedDict()
currentCategory = None
try:
# 使用 utf-8-sig 避免首行解析出错
with open(template_file, "r", encoding="utf-8-sig") as f:
with open(templateFile, "r", encoding="utf-8-sig") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "#genre#" in line:
current_category = line.split(",")[0].strip()
template_channels[current_category] = []
elif current_category:
channel_name = line.split(",")[0].strip()
template_channels[current_category].append(channel_name)
currentCategory = line.split(",")[0].strip()
templateChannels[currentCategory] = []
elif currentCategory:
channelName = line.split(",")[0].strip()
if channelName:
templateChannels[currentCategory].append(channelName)
except FileNotFoundError:
logger.warning(f"模板文件未找到: {template_file}")
logger.warning(f"Template file not found: {templateFile}")
return None
return templateChannels
return template_channels
def fetch_channels(url):
"""从URL获取频道列表"""
def fetchChannels(url):
"""Fetch and decode channels from remote URL safely handling gzip and encodings"""
channels = OrderedDict()
# 使用上下文管理器确保 socket 资源正确释放
with get_session() as session:
with getSession() as session:
try:
with session.get(url, timeout=30) as response:
response.raise_for_status()
# 优化编码解析:跳过极其缓慢的 apparent_encoding 计算,直接指定 utf-8
if response.encoding is None or response.encoding.lower() == 'iso-8859-1':
response.encoding = 'utf-8'
text_content = response.text
# Use content instead of raw stream to automatically decode content-encoding like gzip
response = session.get(url, timeout=15)
response.raise_for_status()
rawContent = response.content
if not rawContent:
return channels
# Detect encoding using the first 50KB chunk securely
sampleChunk = rawContent[:51200]
detected = chardet.detect(sampleChunk)
encoding = detected['encoding'] if detected and detected['encoding'] else 'utf-8'
fullText = rawContent.decode(encoding, errors='ignore')
except Exception as e:
logger.error(f"处理 {url} 时出错: {e}")
logger.error(f"Network fetch exception for {url}: {e}")
return channels
lines = [line.strip() for line in text_content.splitlines() if line.strip()]
lines = [line.strip() for line in fullText.splitlines() if line.strip()]
if not lines:
return channels
is_m3u = any("#EXTINF" in line for line in lines[:10])
isM3u = any("#EXTINF" in line for line in lines[:10])
if is_m3u:
current_category = "默认分类"
current_name = "未知频道"
re_group = re.compile(r'group-title="([^"]*)"')
re_name = re.compile(r',([^,]*)$')
if isM3u:
currentCategory = "默认分类"
currentName = "未知频道"
reGroup = re.compile(r'group-title=["\']([^"\']*)["\']')
reName = re.compile(r',([^,]*)$')
for line in lines:
if line.startswith("#EXTINF"):
group_match = re_group.search(line)
if group_match:
current_category = group_match.group(1).strip()
name_match = re_name.search(line)
if name_match:
current_name = name_match.group(1).strip()
groupMatch = reGroup.search(line)
if groupMatch:
currentCategory = groupMatch.group(1).strip()
nameMatch = reName.search(line)
if nameMatch:
currentName = nameMatch.group(1).strip()
elif not line.startswith("#") and "://" in line:
if current_category not in channels:
channels[current_category] = []
if current_name and current_name != "未知频道":
channels[current_category].append((current_name, line))
current_name = "未知频道"
if currentCategory not in channels:
channels[currentCategory] = []
if currentName and currentName != "未知频道":
channels[currentCategory].append((currentName, line.strip()))
currentName = "未知频道"
else:
current_category = None
currentCategory = None
for line in lines:
if "#genre#" in line:
current_category = line.split(",")[0].strip()
if current_category not in channels:
channels[current_category] = []
elif current_category and "," in line:
currentCategory = line.split(",")[0].strip()
if currentCategory not in channels:
channels[currentCategory] = []
elif currentCategory and "," in line:
parts = line.split(",", 1)
if len(parts) == 2:
name, url_part = parts
if name.strip() and url_part.strip():
channels[current_category].append((name.strip(), url_part.strip()))
name, urlPart = parts
if name.strip() and urlPart.strip():
channels[currentCategory].append((name.strip(), urlPart.strip()))
return channels
def match_channels(template_channels, all_channels):
def matchChannels(templateChannels, allChannels):
"""Match source channels with template using enhanced regex bounds and hash mapping"""
matched = OrderedDict()
unmatched_template = OrderedDict()
unmatchedTemplate = OrderedDict()
# 1. 数据扁平化
flattened_source_channels = []
for cat, chans in all_channels.items():
# 1. Establish global inverted index map
sourceMap = {}
for cat, chans in allChannels.items():
for name, url in chans:
flattened_source_channels.append({
'norm_name': name.lower(),
'name': name,
'url': url,
'cat': cat,
'key': f"{name}_{url}"
})
normName = name.lower().strip()
if normName not in sourceMap:
sourceMap[normName] = []
sourceMap[normName].append({'name': name, 'url': url, 'cat': cat})
used_channel_keys = set()
usedChannelKeys = set()
# 初始化
for cat in template_channels:
for cat in templateChannels:
matched[cat] = OrderedDict()
unmatched_template[cat] = []
unmatchedTemplate[cat] = []
# 2. 匹配逻辑
for category, tmpl_names in template_channels.items():
for tmpl_name in tmpl_names:
# 2. Match with enhanced boundaries (blocking unwanted alphanumeric and Chinese prefixes/suffixes)
for category, tmplNames in templateChannels.items():
for tmplName in tmplNames:
# 去重并解析变体
variants_raw = [n.strip() for n in tmpl_name.split("|") if n.strip()]
variants = list(OrderedDict.fromkeys(variants_raw))
variantsRaw = [n.strip() for n in tmplName.split("|") if n.strip()]
variants = list(OrderedDict.fromkeys(variantsRaw))
primary_name = variants[0]
found_for_this_template = False
primaryName = variants[0]
foundForThisTemplate = False
for variant in variants:
variant_lower = variant.lower()
variantLower = variant.lower()
# 正则:匹配结束($) 或 非字母数字且非加号([^a-z0-9\+])
# 防止 CCTV5 匹配 CCTV5+
pattern = re.compile(re.escape(variant_lower) + r'($|[^a-z0-9\+])')
# Enhanced strict boundary assertion: blocks english letters, digits, and Chinese characters
# Prevents "CCTV1" matching "CCTV11" or "广东CCTV1"
pattern = re.compile(
r'(?<![a-zA-Z0-9\u4e00-\u9fa5])' +
re.escape(variantLower) +
r'(?![a-zA-Z0-9\+\u4e00-\u9fa5])'
)
for src in flattened_source_channels:
if src['key'] in used_channel_keys:
continue
targetKeys = [k for k in sourceMap.keys() if variantLower in k]
# 使用正则搜索
if pattern.search(src['norm_name']):
if primary_name not in matched[category]:
matched[category][primary_name] = []
for srcNameLower in targetKeys:
if pattern.search(srcNameLower):
for src in sourceMap[srcNameLower]:
key = f"{src['name']}_{src['url']}"
if key in usedChannelKeys:
continue
matched[category][primary_name].append((src['name'], src['url']))
if primaryName not in matched[category]:
matched[category][primaryName] = []
used_channel_keys.add(src['key'])
found_for_this_template = True
matched[category][primaryName].append((src['name'], src['url']))
usedChannelKeys.add(key)
foundForThisTemplate = True
if not found_for_this_template:
unmatched_template[category].append(tmpl_name)
if not foundForThisTemplate:
unmatchedTemplate[category].append(tmplName)
# 3. 找出源中未使用的频道
unmatched_source = OrderedDict()
for src in flattened_source_channels:
if src['key'] not in used_channel_keys:
if src['cat'] not in unmatched_source:
unmatched_source[src['cat']] = []
unmatched_source[src['cat']].append((src['name'], src['url']))
# 3. Filter unmatched source channels
unmatchedSource = OrderedDict()
for srcNameLower, srcList in sourceMap.items():
for src in srcList:
key = f"{src['name']}_{src['url']}"
if key not in usedChannelKeys:
if src['cat'] not in unmatchedSource:
unmatchedSource[src['cat']] = []
unmatchedSource[src['cat']].append((src['name'], src['url']))
return matched, unmatched_template, unmatched_source
return matched, unmatchedTemplate, unmatchedSource
def is_ipv6(url):
def isIpv6(url):
return "://[" in url
def generate_outputs(channels, template_channels, m3u_path, txt_path):
"""生成文件 - 路径参数化"""
written_urls = set()
# 安全地确保输出目录存在
ensure_dir(m3u_path)
ensure_dir(txt_path)
def generateOutputs(channels, templateChannels, m3uPath, txtPath):
"""Generate final M3U and TXT output files cleanly"""
writtenUrls = set()
ensureDir(m3uPath)
ensureDir(txtPath)
try:
with write_lock:
with open(m3u_path, "w", encoding="utf-8") as m3u, \
open(txt_path, "w", encoding="utf-8") as txt:
with writeLock:
with open(m3uPath, "w", encoding="utf-8") as m3u, \
open(txtPath, "w", encoding="utf-8") as txt:
m3u.write("#EXTM3U\n")
for category in template_channels:
for category in templateChannels:
if category not in channels or not channels[category]:
continue
txt.write(f"\n{category},#genre#\n")
for channel_key_name, channel_list in channels[category].items():
for channelKeyName, channelList in channels[category].items():
uniqueUrls = []
seenUrls = set()
unique_urls = []
seen_urls = set()
for _, url in channelList:
if url not in seenUrls and url not in writtenUrls:
uniqueUrls.append(url)
seenUrls.add(url)
writtenUrls.add(url)
for _, url in channel_list:
if url not in seen_urls and url not in written_urls:
unique_urls.append(url)
seen_urls.add(url)
written_urls.add(url)
totalLines = len(uniqueUrls)
for idx, url in enumerate(uniqueUrls, 1):
baseUrl = re.split(r'[$#]', url)[0].strip()
suffixName = "IPV6" if isIpv6(url) else "IPV4"
total_lines = len(unique_urls)
for idx, url in enumerate(unique_urls, 1):
base_url = url.split("$")[0]
suffix_name = "IPV6" if is_ipv6(url) else "IPV4"
displayName = channelKeyName
metaSuffix = f"$LR•{suffixName}"
if totalLines > 1:
metaSuffix += f"{totalLines}『线路{idx}"
display_name = channel_key_name
finalUrl = f"{baseUrl}{metaSuffix}"
safeDisplayName = displayName.replace('"', '\\"')
m3u.write(f'#EXTINF:-1 tvg-name="{safeDisplayName}" group-title="{category}",{displayName}\n')
m3u.write(f"{finalUrl}\n")
meta_suffix = f"$LR•{suffix_name}"
if total_lines > 1:
meta_suffix += f"{total_lines}『线路{idx}"
txt.write(f"{displayName},{finalUrl}\n")
final_url = f"{base_url}{meta_suffix}"
m3u.write(f'#EXTINF:-1 tvg-name="{display_name}" group-title="{category}",{display_name}\n')
m3u.write(f"{final_url}\n")
txt.write(f"{display_name},{final_url}\n")
logger.info(f"输出完成: {m3u_path}, {txt_path}")
logger.info(f"Outputs generated: {m3uPath}, {txtPath}")
except Exception as e:
logger.error(f"写入输出文件失败: {e}")
logger.error(f"Failed to write output files: {e}")
def generate_unmatched_report(unmatched_template, unmatched_source, report_file):
"""生成未匹配报告"""
total_template_lost = sum(len(v) for v in unmatched_template.values())
def generateUnmatchedReport(unmatchedTemplate, unmatchedSource, reportFile):
"""Generate reports for unmatched elements"""
totalTemplateLost = sum(len(v) for v in unmatchedTemplate.values())
# 如果未指定报告文件路径,则仅计算丢失数量,不执行文件写入
if not report_file:
return total_template_lost
ensure_dir(report_file)
if not reportFile:
return totalTemplateLost
ensureDir(reportFile)
try:
with open(report_file, "w", encoding="utf-8") as f:
f.write(f"# 未匹配报告 {datetime.now()}\n")
f.write(f"# 模板未匹配数: {total_template_lost}\n\n")
f.write("## 模板中有但源中无\n")
for cat, names in unmatched_template.items():
with open(reportFile, "w", encoding="utf-8") as f:
f.write(f"# Unmatched Report {datetime.now()}\n")
f.write(f"# Lost templates count: {totalTemplateLost}\n\n")
f.write("## In Template but missing in Source\n")
for cat, names in unmatchedTemplate.items():
if names:
f.write(f"\n{cat},#genre#\n")
for name in list(OrderedDict.fromkeys(names)):
f.write(f"{name},\n")
f.write("\n\n## 源中有但模板无\n")
for cat, chans in unmatched_source.items():
f.write("\n\n## In Source but missing in Template\n")
for cat, chans in unmatchedSource.items():
if chans:
f.write(f"\n{cat},#genre#\n")
unique_names = list(OrderedDict.fromkeys([c[0] for c in chans]))
for name in unique_names:
uniqueNames = list(OrderedDict.fromkeys([c[0] for c in chans]))
for name in uniqueNames:
f.write(f"{name},\n")
logger.info(f"报告已生成: {report_file}")
return total_template_lost
logger.info(f"Report generated: {reportFile}")
return totalTemplateLost
except Exception as e:
logger.error(f"生成报告失败: {e}")
logger.error(f"Failed to generate report: {e}")
return 0
def remove_unmatched_from_template(template_file, unmatched_template):
backup_file = template_file + ".backup"
def removeUnmatchedFromTemplate(templateFile, unmatchedTemplate):
"""Clean up invalid channels from template file"""
backupFile = templateFile + ".backup"
try:
shutil.copy2(template_file, backup_file)
with open(template_file, "r", encoding="utf-8-sig") as f:
shutil.copy2(templateFile, backupFile)
with open(templateFile, "r", encoding="utf-8-sig") as f:
lines = f.readlines()
new_lines = []
current_cat = None
to_remove = {cat: set(names) for cat, names in unmatched_template.items()}
newLines = []
currentCat = None
toRemove = {cat: set(names) for cat, names in unmatchedTemplate.items()}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
new_lines.append(line)
newLines.append(line)
continue
if "#genre#" in stripped:
current_cat = stripped.split(",")[0].strip()
new_lines.append(line)
currentCat = stripped.split(",")[0].strip()
newLines.append(line)
continue
if current_cat:
if currentCat:
name = stripped.split(",")[0].strip()
if current_cat in to_remove and name in to_remove[current_cat]:
if currentCat in toRemove and name in toRemove[currentCat]:
continue
new_lines.append(line)
newLines.append(line)
else:
# 修复: 若不在任何 category 内的内容(如异常格式),不应被错误丢弃
new_lines.append(line)
newLines.append(line)
with open(template_file, "w", encoding="utf-8") as f:
f.writelines(new_lines)
logger.info(f"已从模板 {template_file} 移除无效频道")
with open(templateFile, "w", encoding="utf-8") as f:
f.writelines(newLines)
logger.info(f"Removed invalid channels from template {templateFile}")
except Exception as e:
logger.error(f"更新模板失败: {e}")
logger.error(f"Failed to update template: {e}")
def process_iptv_task(template_file, tv_urls, output_m3u, output_txt, report_file, auto_clean=True):
"""
处理单个IPTV任务的封装函数
"""
logger.info(f"=== 开始处理任务: {template_file} ===")
template = parse_template(template_file)
def processIptvTask(templateFile, tvUrls, outputM3u, outputTxt, reportFile, autoClean=True):
logger.info(f"=== Starting task: {templateFile} ===")
template = parseTemplate(templateFile)
if not template:
return
logger.info(f"开始从 {len(tv_urls)} 个源获取数据...")
all_channels = OrderedDict()
success_count = 0
fail_count = 0
logger.info(f"Fetching data from {len(tvUrls)} sources...")
allChannels = OrderedDict()
successCount = 0
failCount = 0
with ThreadPoolExecutor(max_workers=5) as executor:
future_to_url = {executor.submit(fetch_channels, url): url for url in tv_urls}
for future in as_completed(future_to_url):
url = future_to_url[future]
futureToUrl = {executor.submit(fetchChannels, url): url for url in tvUrls}
for future in as_completed(futureToUrl):
url = futureToUrl[future]
try:
data = future.result()
if data:
success_count += 1
successCount += 1
for cat, chans in data.items():
if cat not in all_channels:
all_channels[cat] = []
all_channels[cat].extend(chans)
if cat not in allChannels:
allChannels[cat] = []
allChannels[cat].extend(chans)
else:
fail_count += 1
failCount += 1
except Exception as e:
fail_count += 1
logger.error(f" {url} 异常: {e}")
failCount += 1
logger.error(f"Thread execution anomaly for source {url}: {e}")
logger.info(f"数据获取完毕: 成功解析 {success_count} 个源,失败/空数据 {fail_count} 个源。")
logger.info("开始匹配频道...")
logger.info(f"Fetch completed: {successCount} success, {failCount} failed.")
logger.info("Matching channels...")
matched, unmatched_tmpl, unmatched_src = match_channels(template, all_channels)
generate_outputs(matched, template, output_m3u, output_txt)
lost_count = generate_unmatched_report(unmatched_tmpl, unmatched_src, report_file)
if auto_clean and lost_count > 0:
logger.info(f"清理 {lost_count} 个无效频道...")
remove_unmatched_from_template(template_file, unmatched_tmpl)
matched, unmatchedTmpl, unmatchedSrc = matchChannels(template, allChannels)
logger.info(f"=== 任务完成: {template_file} ===\n")
generateOutputs(matched, template, outputM3u, outputTxt)
lostCount = generateUnmatchedReport(unmatchedTmpl, unmatchedSrc, reportFile)
if autoClean and lostCount > 0:
logger.info(f"Cleaning {lostCount} invalid channels...")
removeUnmatchedFromTemplate(templateFile, unmatchedTmpl)
logger.info(f"=== Task completed: {templateFile} ===\n")
if __name__ == "__main__":
# === 配置区 ===
URLS_FILE = "py/config/urls.txt"
urlsFile = "py/config/urls.txt"
# 1. 加载源
TV_URLS = load_urls_from_file(URLS_FILE)
if not TV_URLS:
logger.warning("未从文件中加载到URL,使用空列表")
TV_URLS = []
tvUrls = loadUrlsFromFile(urlsFile)
if not tvUrls:
logger.warning("No URLs loaded from file, using empty list")
tvUrls = []
# === 任务1: 主列表 ===
process_iptv_task(
template_file="py/config/iptv.txt",
tv_urls=TV_URLS,
output_m3u="lib/iptv.m3u",
output_txt="lib/iptv.txt",
report_file="py/config/iptv.log",
auto_clean=True
processIptvTask(
templateFile="py/config/iptv.txt",
tvUrls=tvUrls,
outputM3u="lib/iptv.m3u",
outputTxt="lib/iptv.txt",
reportFile="py/config/iptv.log",
autoClean=False
)
# === 任务2: 测试列表 (如果配置文件存在) ===
TEST_TEMPLATE_FILE = "py/config/iptv_test.txt"
if os.path.exists(TEST_TEMPLATE_FILE):
process_iptv_task(
template_file=TEST_TEMPLATE_FILE,
tv_urls=TV_URLS,
output_m3u="lib/iptv_test.m3u",
output_txt="lib/iptv_test.txt",
report_file=None,
auto_clean=False
testTemplateFile = "py/config/iptv_test.txt"
if os.path.exists(testTemplateFile):
processIptvTask(
templateFile=testTemplateFile,
tvUrls=tvUrls,
outputM3u="lib/iptv_test.m3u",
outputTxt="lib/iptv_test.txt",
reportFile=None,
autoClean=False
)
else:
logger.info(f"未检测到测试配置 {TEST_TEMPLATE_FILE},跳过测试生成。")
logger.info(f"Test config {testTemplateFile} not found, skipping.")