547 lines
22 KiB
Python
547 lines
22 KiB
Python
import re
|
||
import asyncio
|
||
import logging
|
||
import json
|
||
import os
|
||
from collections import OrderedDict
|
||
from datetime import datetime, timedelta
|
||
import difflib
|
||
import hashlib
|
||
from urllib.parse import urlparse, urlunparse, parse_qs
|
||
|
||
# 检查 aiohttp 是否安装
|
||
try:
|
||
import aiohttp
|
||
except ImportError:
|
||
print("错误: 缺少必要的依赖库 'aiohttp'。")
|
||
print("请使用以下命令安装:")
|
||
print("pip install aiohttp")
|
||
import sys
|
||
sys.exit(1)
|
||
|
||
# 检查 config 是否存在
|
||
try:
|
||
import config
|
||
required_attrs = ['source_urls', 'epg_urls', 'announcements', 'url_blacklist', 'ip_version_priority']
|
||
for attr in required_attrs:
|
||
if not hasattr(config, attr):
|
||
raise AttributeError(f"配置文件缺少必要的属性: {attr}")
|
||
except ImportError:
|
||
print("错误: 找不到配置模块 'config.py'。")
|
||
print("请确保项目目录下有 config.py 文件,内容示例如下:")
|
||
print("""
|
||
# config.py 示例内容
|
||
source_urls = [
|
||
"https://example.com/source1.m3u",
|
||
"https://example.com/source2.m3u"
|
||
]
|
||
epg_urls = ["https://example.com/epg.xml"]
|
||
announcements = [
|
||
{
|
||
"channel": "公告",
|
||
"entries": [
|
||
{
|
||
"name": None,
|
||
"url": "https://example.com/notice",
|
||
"logo": "https://picsum.photos/100/100?random=1"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
url_blacklist = []
|
||
ip_version_priority = "ipv4"
|
||
""")
|
||
import sys
|
||
sys.exit(1)
|
||
except AttributeError as e:
|
||
print(f"配置文件错误: {e}")
|
||
import sys
|
||
sys.exit(1)
|
||
|
||
# 日志记录,只记录错误信息
|
||
logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s',
|
||
handlers=[logging.FileHandler("./live/function.log", "w", encoding="utf-8"), logging.StreamHandler()])
|
||
|
||
# 确保 live 文件夹存在
|
||
output_folder = "live"
|
||
if not os.path.exists(output_folder):
|
||
os.makedirs(output_folder)
|
||
|
||
# 缓存文件夹和文件
|
||
cache_folder = "./live/cache"
|
||
cache_file = os.path.join(cache_folder, "url_cache.json")
|
||
cache_valid_days = 7 # 缓存有效期(天)
|
||
max_cache_entries = 500 # 最多保留的缓存条目数(防止极端膨胀)
|
||
|
||
if not os.path.exists(cache_folder):
|
||
os.makedirs(cache_folder)
|
||
|
||
def calculate_hash(content):
|
||
"""计算字符串的MD5哈希值"""
|
||
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
||
|
||
def load_cache():
|
||
"""加载缓存文件,并确保其结构正确"""
|
||
if os.path.exists(cache_file):
|
||
try:
|
||
with open(cache_file, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
# 兼容旧格式
|
||
if "urls" not in data:
|
||
data["urls"] = {}
|
||
if "timestamp" not in data:
|
||
data["timestamp"] = datetime.now().isoformat()
|
||
return data
|
||
except Exception as e:
|
||
logging.error(f"加载缓存失败: {e}")
|
||
return {"urls": {}, "timestamp": datetime.now().isoformat()}
|
||
|
||
def clean_expired_cache(cache, valid_hashes=None):
|
||
"""
|
||
清理过期或无效的缓存条目
|
||
- 移除 timestamp 超过 cache_valid_days 的条目
|
||
- 如果提供了 valid_hashes,则只保留哈希在该集合中的条目
|
||
- 限制最多保留 max_cache_entries 个条目(按最近使用时间排序)
|
||
"""
|
||
now = datetime.now()
|
||
urls = cache.get("urls", {})
|
||
cleaned = {}
|
||
|
||
for url_hash, entry in list(urls.items()):
|
||
try:
|
||
ts = datetime.fromisoformat(entry.get("timestamp", ""))
|
||
except Exception:
|
||
continue
|
||
if (now - ts).days >= cache_valid_days:
|
||
continue # 过期
|
||
if valid_hashes is not None and url_hash not in valid_hashes:
|
||
continue # 不在当前源URL列表中
|
||
cleaned[url_hash] = entry
|
||
|
||
# 如果仍过多,按时间戳保留最近的 max_cache_entries 条
|
||
if len(cleaned) > max_cache_entries:
|
||
sorted_entries = sorted(cleaned.items(),
|
||
key=lambda x: x[1].get("timestamp", ""),
|
||
reverse=True)
|
||
cleaned = dict(sorted_entries[:max_cache_entries])
|
||
|
||
cache["urls"] = cleaned
|
||
cache["timestamp"] = now.isoformat()
|
||
|
||
def save_cache(cache, valid_hashes=None):
|
||
"""保存缓存到磁盘,自动清理过期/无效条目"""
|
||
clean_expired_cache(cache, valid_hashes)
|
||
try:
|
||
with open(cache_file, "w", encoding="utf-8") as f:
|
||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
logging.error(f"保存缓存失败: {e}")
|
||
|
||
def is_cache_valid(cache):
|
||
"""整体检查缓存文件的时间戳是否在有效期内(作为快速判断)"""
|
||
if not cache:
|
||
return False
|
||
try:
|
||
ts = datetime.fromisoformat(cache.get("timestamp", datetime.now().isoformat()))
|
||
return (datetime.now() - ts).days < cache_valid_days
|
||
except Exception:
|
||
return False
|
||
|
||
def parse_template(template_file):
|
||
"""解析 demo.txt 模板,返回 OrderedDict {类别: [频道名1, 频道名2...]} """
|
||
template_channels = OrderedDict()
|
||
current_category = None
|
||
|
||
with open(template_file, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line and not line.startswith("#"):
|
||
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)
|
||
|
||
return template_channels
|
||
|
||
def clean_channel_name(channel_name):
|
||
"""清洗频道名称:去除特殊符号、空格,并将数字部分标准化"""
|
||
cleaned_name = re.sub(r'[$「」-]', '', channel_name)
|
||
cleaned_name = re.sub(r'\s+', '', cleaned_name)
|
||
# 将 "CCTV1" 转换为 "CCTV1",但将 "CCTV10" 处理为 "CCTV10" 等(去掉前导零)
|
||
cleaned_name = re.sub(r'(\D*)(\d+)', lambda m: m.group(1) + str(int(m.group(2))), cleaned_name)
|
||
return cleaned_name.upper()
|
||
|
||
def is_valid_url(url):
|
||
return bool(re.match(r'^https?://', url))
|
||
|
||
def remove_unnecessary_params(url):
|
||
"""移除URL中不必要的查询参数(当前仅示例,可按需修改)"""
|
||
parsed_url = urlparse(url)
|
||
query_params = parse_qs(parsed_url.query)
|
||
# 保留必要参数(请按实际需要修改)
|
||
necessary_params = {}
|
||
for param, values in query_params.items():
|
||
if param in ['必要参数1', '必要参数2']:
|
||
necessary_params[param] = values
|
||
new_query = '&'.join([f'{param}={value[0]}' for param, value in necessary_params.items()])
|
||
new_url = urlunparse((parsed_url.scheme, parsed_url.netloc, parsed_url.path,
|
||
parsed_url.params, new_query, parsed_url.fragment))
|
||
return new_url
|
||
|
||
async def fetch_channels(session, url, cache, valid_source_hashes):
|
||
"""
|
||
异步获取指定URL的频道列表,优先从缓存读取
|
||
valid_source_hashes: 当前所有源URL的哈希集合,用于保存缓存时清理无效条目
|
||
"""
|
||
channels = OrderedDict()
|
||
unique_urls = set()
|
||
cache_hit = False
|
||
|
||
url_hash = calculate_hash(url)
|
||
# 检查具体的缓存条目是否有效
|
||
if url_hash in cache.get("urls", {}):
|
||
cached_entry = cache["urls"][url_hash]
|
||
try:
|
||
entry_time = datetime.fromisoformat(cached_entry.get("timestamp", ""))
|
||
if datetime.now() - entry_time <= timedelta(days=cache_valid_days):
|
||
logging.info(f"从缓存加载: {url}")
|
||
channels = OrderedDict(cached_entry.get("channels", {}))
|
||
unique_urls = set(cached_entry.get("unique_urls", []))
|
||
cache_hit = True
|
||
except Exception:
|
||
pass
|
||
|
||
if not cache_hit:
|
||
try:
|
||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as response:
|
||
response.raise_for_status()
|
||
content = await response.text()
|
||
response.encoding = 'utf-8'
|
||
lines = content.split("\n")
|
||
# 判断文件类型
|
||
is_m3u = any(line.startswith("#EXTINF") for line in lines[:15])
|
||
if is_m3u:
|
||
channels.update(parse_m3u_lines(lines, unique_urls))
|
||
else:
|
||
channels.update(parse_txt_lines(lines, unique_urls))
|
||
|
||
if channels:
|
||
# 更新缓存
|
||
cache["urls"][url_hash] = {
|
||
"url": url,
|
||
"channels": dict(channels),
|
||
"unique_urls": list(unique_urls),
|
||
"timestamp": datetime.now().isoformat(),
|
||
"content_hash": calculate_hash(content)
|
||
}
|
||
# 每次更新后保存,并自动清理过期/无效条目
|
||
save_cache(cache, valid_hashes=valid_source_hashes)
|
||
|
||
except asyncio.TimeoutError:
|
||
logging.error(f"url: {url} 请求超时")
|
||
except Exception as e:
|
||
logging.error(f"url: {url} 失败❌, Error: {e}")
|
||
|
||
# 再次去重(同一频道同一URL)
|
||
for category, channel_list in channels.items():
|
||
seen = set()
|
||
unique_list = []
|
||
for ch_name, ch_url in channel_list:
|
||
if (ch_name, ch_url) not in seen:
|
||
unique_list.append((ch_name, ch_url))
|
||
seen.add((ch_name, ch_url))
|
||
channels[category] = unique_list
|
||
|
||
return channels
|
||
|
||
def parse_m3u_lines(lines, unique_urls):
|
||
"""解析 M3U 格式"""
|
||
channels = OrderedDict()
|
||
current_category = None
|
||
channel_name = None
|
||
|
||
for line in lines:
|
||
line = line.strip()
|
||
if line.startswith("#EXTINF"):
|
||
match = re.search(r'group-title="(.*?)",(.*)', line)
|
||
if match:
|
||
current_category = match.group(1).strip()
|
||
channel_name = match.group(2).strip()
|
||
if channel_name and channel_name.startswith("CCTV"):
|
||
channel_name = clean_channel_name(channel_name)
|
||
if current_category not in channels:
|
||
channels[current_category] = []
|
||
elif line and not line.startswith("#"):
|
||
channel_url = line.strip()
|
||
if is_valid_url(channel_url) and channel_url not in unique_urls:
|
||
unique_urls.add(channel_url)
|
||
if current_category and channel_name:
|
||
channels[current_category].append((channel_name, channel_url))
|
||
return channels
|
||
|
||
def parse_txt_lines(lines, unique_urls):
|
||
"""解析 TXT 格式(#genre#)"""
|
||
channels = OrderedDict()
|
||
current_category = None
|
||
|
||
for line in lines:
|
||
line = line.strip()
|
||
if "#genre#" in line:
|
||
current_category = line.split(",")[0].strip()
|
||
channels[current_category] = []
|
||
elif current_category:
|
||
match = re.match(r"^(.*?),(.*?)$", line)
|
||
if match:
|
||
channel_name = match.group(1).strip()
|
||
if channel_name and channel_name.startswith("CCTV"):
|
||
channel_name = clean_channel_name(channel_name)
|
||
channel_urls = match.group(2).strip().split('#')
|
||
|
||
for channel_url in channel_urls:
|
||
channel_url = channel_url.strip()
|
||
if is_valid_url(channel_url) and channel_url not in unique_urls:
|
||
unique_urls.add(channel_url)
|
||
channels[current_category].append((channel_name, channel_url))
|
||
else:
|
||
# 无逗号的行(纯URL),保留频道名为空
|
||
if is_valid_url(line) and line not in unique_urls:
|
||
unique_urls.add(line)
|
||
channels[current_category].append(("", line))
|
||
return channels
|
||
|
||
def find_similar_name(target_name, name_list):
|
||
"""在名称列表中寻找最相似的名字"""
|
||
matches = difflib.get_close_matches(target_name, name_list, n=1, cutoff=0.6)
|
||
return matches[0] if matches else None
|
||
|
||
async def filter_source_urls(template_file):
|
||
"""核心流程:获取所有源,匹配模板频道"""
|
||
template_channels = parse_template(template_file)
|
||
source_urls = config.source_urls
|
||
cache = load_cache()
|
||
|
||
# 计算当前源URL集合的哈希值(用于后续清理缓存)
|
||
valid_source_hashes = {calculate_hash(url) for url in source_urls}
|
||
|
||
all_channels = OrderedDict()
|
||
async with aiohttp.ClientSession() as session:
|
||
tasks = [fetch_channels(session, url, cache, valid_source_hashes) for url in source_urls]
|
||
fetched_channels_list = await asyncio.gather(*tasks)
|
||
|
||
for fetched in fetched_channels_list:
|
||
merge_channels(all_channels, fetched)
|
||
|
||
matched_channels = match_channels(template_channels, all_channels)
|
||
|
||
# 最终保存一次,清除所有过期或无效条目
|
||
save_cache(cache, valid_hashes=valid_source_hashes)
|
||
return matched_channels, template_channels, cache
|
||
|
||
def match_channels(template_channels, all_channels):
|
||
"""将模板频道与在线频道进行模糊匹配"""
|
||
matched_channels = OrderedDict()
|
||
|
||
# 收集所有在线频道名称(原始名)
|
||
all_online_names = []
|
||
for cat, chan_list in all_channels.items():
|
||
for ch_name, _ in chan_list:
|
||
all_online_names.append(ch_name)
|
||
|
||
# 清洗后的名字用于匹配
|
||
cleaned_online_names = [clean_channel_name(name) for name in all_online_names]
|
||
|
||
for category, channel_list in template_channels.items():
|
||
matched_channels[category] = OrderedDict()
|
||
for template_name in channel_list:
|
||
cleaned_template = clean_channel_name(template_name)
|
||
similar = find_similar_name(cleaned_template, cleaned_online_names)
|
||
if similar:
|
||
# 反查原始名称
|
||
original = next((name for name, cname in zip(all_online_names, cleaned_online_names) if cname == similar), None)
|
||
if original:
|
||
for online_cat, online_list in all_channels.items():
|
||
for ch_name, ch_url in online_list:
|
||
if ch_name == original:
|
||
matched_channels[category].setdefault(template_name, []).append(ch_url)
|
||
return matched_channels
|
||
|
||
def merge_channels(target, source):
|
||
"""合并频道字典"""
|
||
for category, channel_list in source.items():
|
||
if category in target:
|
||
target[category].extend(channel_list)
|
||
else:
|
||
target[category] = channel_list
|
||
|
||
def is_ipv6(url):
|
||
"""判断是否为 IPv6 地址格式"""
|
||
return re.match(r'^http:\/\/\[[0-9a-fA-F:]+\]', url) is not None
|
||
|
||
def updateChannelUrlsM3U(channels, template_channels, cache):
|
||
"""生成最终的 IPv4/IPv6 M3U 和 TXT 文件"""
|
||
written_urls_ipv4 = set()
|
||
written_urls_ipv6 = set()
|
||
url_changes = {"added": [], "removed": [], "modified": []}
|
||
|
||
# 检测URL变化(基于缓存中的历史数据)
|
||
if is_cache_valid(cache):
|
||
previous_urls = {}
|
||
for entry in cache.get("urls", {}).values():
|
||
for cat, chan_list in entry.get("channels", {}).items():
|
||
for ch_name, url in chan_list:
|
||
previous_urls[url] = (cat, ch_name)
|
||
|
||
current_urls = {}
|
||
for category, chan_dict in channels.items():
|
||
for ch_name, urls in chan_dict.items():
|
||
for url in urls:
|
||
current_urls[url] = (category, ch_name)
|
||
|
||
for url, (cat, name) in current_urls.items():
|
||
if url not in previous_urls:
|
||
url_changes["added"].append((cat, name, url))
|
||
|
||
for url, (cat, name) in previous_urls.items():
|
||
if url not in current_urls:
|
||
url_changes["removed"].append((cat, name, url))
|
||
|
||
# 更新公告中的日期占位符
|
||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||
for group in config.announcements:
|
||
for announcement in group['entries']:
|
||
if announcement['name'] is None:
|
||
announcement['name'] = current_date
|
||
|
||
# 准备输出文件路径
|
||
ipv4_m3u = os.path.join(output_folder, "live_ipv4.m3u")
|
||
ipv4_txt = os.path.join(output_folder, "live_ipv4.txt")
|
||
ipv6_m3u = os.path.join(output_folder, "live_ipv6.m3u")
|
||
ipv6_txt = os.path.join(output_folder, "live_ipv6.txt")
|
||
|
||
with open(ipv4_m3u, "w", encoding="utf-8") as f_m3u_v4, \
|
||
open(ipv4_txt, "w", encoding="utf-8") as f_txt_v4, \
|
||
open(ipv6_m3u, "w", encoding="utf-8") as f_m3u_v6, \
|
||
open(ipv6_txt, "w", encoding="utf-8") as f_txt_v6:
|
||
|
||
# 写入 M3U 头部
|
||
epg_urls_str = ",".join(f'"{u}"' for u in config.epg_urls)
|
||
f_m3u_v4.write(f"#EXTM3U x-tvg-url={epg_urls_str}\n")
|
||
f_m3u_v6.write(f"#EXTM3U x-tvg-url={epg_urls_str}\n")
|
||
|
||
# 写入公告频道
|
||
for group in config.announcements:
|
||
f_txt_v4.write(f"{group['channel']},#genre#\n")
|
||
f_txt_v6.write(f"{group['channel']},#genre#\n")
|
||
for entry in group['entries']:
|
||
url = remove_unnecessary_params(entry['url'])
|
||
if is_ipv6(url):
|
||
if url not in written_urls_ipv6 and is_valid_url(url):
|
||
written_urls_ipv6.add(url)
|
||
write_to_files(f_m3u_v6, f_txt_v6, group['channel'], entry['name'], 1, url)
|
||
else:
|
||
if url not in written_urls_ipv4 and is_valid_url(url):
|
||
written_urls_ipv4.add(url)
|
||
write_to_files(f_m3u_v4, f_txt_v4, group['channel'], entry['name'], 1, url)
|
||
|
||
# 按模板顺序写入匹配的频道
|
||
for category, channel_list in template_channels.items():
|
||
f_txt_v4.write(f"{category},#genre#\n")
|
||
f_txt_v6.write(f"{category},#genre#\n")
|
||
if category in channels:
|
||
for ch_name in channel_list:
|
||
if ch_name in channels[category]:
|
||
sorted_v4 = []
|
||
sorted_v6 = []
|
||
for url in channels[category][ch_name]:
|
||
url = remove_unnecessary_params(url)
|
||
if is_ipv6(url):
|
||
if url not in written_urls_ipv6 and is_valid_url(url):
|
||
sorted_v6.append(url)
|
||
written_urls_ipv6.add(url)
|
||
else:
|
||
if url not in written_urls_ipv4 and is_valid_url(url):
|
||
sorted_v4.append(url)
|
||
written_urls_ipv4.add(url)
|
||
|
||
for idx, url in enumerate(sorted_v4, start=1):
|
||
new_url = add_url_suffix(url, idx, len(sorted_v4), "IPV4")
|
||
write_to_files(f_m3u_v4, f_txt_v4, category, ch_name, idx, new_url)
|
||
|
||
for idx, url in enumerate(sorted_v6, start=1):
|
||
new_url = add_url_suffix(url, idx, len(sorted_v6), "IPV6")
|
||
write_to_files(f_m3u_v6, f_txt_v6, category, ch_name, idx, new_url)
|
||
|
||
# TXT 文件末尾换行
|
||
f_txt_v4.write("\n")
|
||
f_txt_v6.write("\n")
|
||
|
||
# 记录URL变化日志
|
||
if any(url_changes.values()):
|
||
log_path = os.path.join(output_folder, "url_changes.log")
|
||
with open(log_path, "a", encoding="utf-8") as f:
|
||
f.write(f"\n=== 更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ===\n")
|
||
if url_changes["added"]:
|
||
f.write("\n新增URL:\n")
|
||
for cat, name, url in url_changes["added"]:
|
||
f.write(f"- {cat} - {name}: {url}\n")
|
||
if url_changes["removed"]:
|
||
f.write("\n移除URL:\n")
|
||
for cat, name, url in url_changes["removed"]:
|
||
f.write(f"- {cat} - {name}: {url}\n")
|
||
if url_changes["modified"]:
|
||
f.write("\n修改URL:\n")
|
||
for cat, name, old, new in url_changes["modified"]:
|
||
f.write(f"- {cat} - {name}: {old} → {new}\n")
|
||
|
||
def sort_and_filter_urls(urls, written_urls):
|
||
"""按 IP 优先级排序并过滤黑名单,此函数在原流程中未直接使用,但保留以备扩展"""
|
||
filtered = [
|
||
url for url in sorted(urls, key=lambda u: not is_ipv6(u) if config.ip_version_priority == "ipv6" else is_ipv6(u))
|
||
if url and url not in written_urls and not any(bl in url for bl in config.url_blacklist) and is_valid_url(url)
|
||
]
|
||
written_urls.update(filtered)
|
||
return filtered
|
||
|
||
def add_url_suffix(url, index, total, ip_version):
|
||
"""为URL添加线路标识后缀"""
|
||
suffix = f"${ip_version}" if total == 1 else f"${ip_version}•线路{index}"
|
||
base = url.split('$', 1)[0] if '$' in url else url
|
||
return f"{base}{suffix}"
|
||
|
||
def write_to_files(f_m3u, f_txt, category, channel_name, index, new_url):
|
||
"""写入M3U和TXT条目"""
|
||
f_m3u.write(f'#EXTINF:-1 group-title="{category}",{channel_name}\n')
|
||
f_m3u.write(new_url + "\n")
|
||
f_txt.write(f"{channel_name},{new_url}\n")
|
||
|
||
if __name__ == "__main__":
|
||
template_file = "demo.txt"
|
||
try:
|
||
if not os.path.exists(template_file):
|
||
print(f"错误: 找不到模板文件 '{template_file}'。")
|
||
print("请确保项目目录下有 demo.txt 文件。")
|
||
print("示例内容如下:")
|
||
print("""
|
||
# demo.txt 示例内容
|
||
央视,#genre#
|
||
CCTV-1
|
||
CCTV-2
|
||
卫视,#genre#
|
||
北京卫视
|
||
上海卫视
|
||
广东卫视
|
||
""")
|
||
import sys
|
||
sys.exit(1)
|
||
|
||
loop = asyncio.get_event_loop()
|
||
channels, template_channels, cache = loop.run_until_complete(filter_source_urls(template_file))
|
||
updateChannelUrlsM3U(channels, template_channels, cache)
|
||
loop.close()
|
||
print("操作完成!结果已保存到live文件夹。")
|
||
except Exception as e:
|
||
print(f"执行过程中发生错误: {e}")
|
||
logging.error(f"程序运行失败: {e}")
|