Skip to content

Commit 328fc76

Browse files
ldm0206github-actions[bot]
authored andcommitted
Github Action Auto Updated
0 parents  commit 328fc76

9 files changed

Lines changed: 672071 additions & 0 deletions

File tree

‎.github/workflows/main.yml‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: 'Update schedule'
2+
3+
on:
4+
schedule:
5+
- cron: '30 4,16 * * *'
6+
workflow_dispatch:
7+
branches:
8+
- master
9+
push:
10+
branches:
11+
- master
12+
jobs:
13+
push:
14+
runs-on: ${{ matrix.operating-system }}
15+
timeout-minutes: 240
16+
strategy:
17+
matrix:
18+
operating-system: [ 'ubuntu-latest' ]
19+
steps:
20+
- uses: actions/checkout@v3
21+
with:
22+
fetch-depth: 0
23+
- name: Run with setup-python 3.13
24+
uses: actions/setup-python@v4
25+
with:
26+
python-version: '3.13'
27+
update-environment: true
28+
cache: 'pipenv'
29+
- name: Install pipenv
30+
run: pip3 install --user pipenv
31+
- name: Install dependecies
32+
run: |
33+
pipenv lock
34+
pipenv --python 3.13 && pipenv install --deploy
35+
- name: Update EPG
36+
run: pipenv run epg
37+
- name: Commit and push if changed
38+
run: |
39+
git config --local user.email "github-actions[bot]@users.noreply.github.com"
40+
git config --local user.name "github-actions[bot]"
41+
if [[ -f "output/epg.xml" ]]; then
42+
git add -f "output/epg.xml"
43+
fi
44+
if [[ -f "output/epg.gz" ]]; then
45+
git add -f "output/epg.gz"
46+
fi
47+
if ! git diff --staged --quiet; then
48+
git reset --soft $(git rev-list --max-parents=0 HEAD)
49+
git commit --amend -m "Github Action Auto Updated"
50+
git push --force
51+
fi
52+
53+
- name: Mirror the Github organization repos to Gitee.
54+
uses: Yikun/hub-mirror-action@master
55+
with:
56+
src: 'github/mytv-android'
57+
dst: 'gitee/mytv-android'
58+
dst_key: ${{ secrets.GITEE_PRIVATE_KEY }}
59+
dst_token: ${{ secrets.GITEE_TOKEN }}
60+
force_update: true
61+
static_list: "myEPG"
62+
account_type: org
63+
debug: true
64+

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
dist
2+
build
3+
updates/multicast/multicast_region_result.json
4+
.idea
5+
test.py
6+
config

‎Pipfile‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[[source]]
2+
name = "pypi"
3+
url = "https://pypi.org/simple"
4+
verify_ssl = true
5+
6+
[scripts]
7+
epg = "python merge.py"
8+
9+
[dev-packages]
10+
pyinstaller = "==6.12.0"
11+
selenium = "==4.29.0"
12+
13+
[packages]
14+
requests = "==2.32.3"
15+
bs4 = "==0.0.2"
16+
tqdm = "==4.67.1"
17+
async-timeout = "==5.0.1"
18+
aiohttp = "==3.11.13"
19+
flask = "==3.1.0"
20+
opencc-python-reimplemented = "==0.1.7"
21+
gunicorn = "==23.0.0"
22+
pillow = "==11.1.0"
23+
m3u8 = "==6.0.0"
24+
pytz = "==2025.1"
25+
pystray = "==0.19.5"
26+
[requires]
27+
python_version = "3.13"

‎Pipfile.lock‎

Lines changed: 1230 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎config.txt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#epg链接,一行一个
2+
https://raw.githubusercontent.com/mytv-android/SCTV_EPG/refs/heads/main/epg.xml
3+
https://epg.27481716.xyz/epg.xml
4+
https://epg.mb6.top/heiptv.xml
5+
https://epg.136605.xyz/9days.xml.gz
6+
https://raw.githubusercontent.com/kuke31/xmlgz/main/all.xml.gz

‎merge.py‎

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import xml.etree.ElementTree as ET
2+
from collections import defaultdict
3+
import aiohttp
4+
import asyncio
5+
from tqdm.asyncio import tqdm_asyncio # 引入 tqdm 的异步支持
6+
from datetime import datetime, timezone, timedelta
7+
import gzip
8+
import shutil
9+
from xml.dom import minidom
10+
import re
11+
from opencc import OpenCC
12+
import os
13+
from tqdm import tqdm # 引入 tqdm 的同步支持
14+
15+
TZ_UTC_PLUS_8 = timezone(timedelta(hours=8))
16+
17+
# ============ EPG 源预处理规则 ============
18+
# 在合并前对指定源的指定频道做预处理
19+
# 每条规则: (源URL关键字, 频道名关键字, 预处理函数)
20+
21+
def _adjust_timezone(programme, from_offset, to_offset):
22+
"""将 programme 节点的 start/stop 时区从 from_offset 替换为 to_offset"""
23+
for attr in ('start', 'stop'):
24+
val = programme.get(attr, '')
25+
if from_offset in val:
26+
programme.set(attr, val.replace(from_offset, to_offset))
27+
28+
def _make_tz_rule(channel_keyword, from_offset, to_offset):
29+
"""生成一个时区调整规则函数"""
30+
def rule(channel_name, programme):
31+
if channel_keyword in channel_name:
32+
_adjust_timezone(programme, from_offset, to_offset)
33+
return rule
34+
35+
# 预处理规则列表: (源URL包含的关键字, 规则函数)
36+
PREPROCESS_RULES = [
37+
# 天映经典频道: 时区 +0800 → +0900 (延迟一小时)
38+
("kuke31/xmlgz", _make_tz_rule("天映经典", "+0800", "+0700")),
39+
]
40+
41+
def preprocess_epg(url, epg_content):
42+
"""对 epg_content XML 字符串按规则做预处理,返回处理后的字符串"""
43+
matched_rules = [rule for keyword, rule in PREPROCESS_RULES if keyword in url]
44+
if not matched_rules:
45+
return epg_content
46+
47+
try:
48+
parser = ET.XMLParser(encoding='UTF-8')
49+
root = ET.fromstring(epg_content, parser=parser)
50+
except ET.ParseError:
51+
return epg_content
52+
53+
# 建立 channel_id -> display_name 的映射
54+
channel_names = {}
55+
for channel in root.findall('channel'):
56+
cid = channel.get('id', '')
57+
names = [n.text for n in channel.findall('display-name') if n.text]
58+
channel_names[cid] = ' '.join(names) + ' ' + cid
59+
60+
for programme in root.findall('programme'):
61+
cid = programme.get('channel', '')
62+
name_str = channel_names.get(cid, cid)
63+
for rule in matched_rules:
64+
rule(name_str, programme)
65+
66+
return ET.tostring(root, encoding='unicode')
67+
# ============ 预处理规则结束 ============
68+
69+
70+
def transform2_zh_hans(string):
71+
cc = OpenCC("t2s")
72+
new_str = cc.convert(string)
73+
return new_str
74+
75+
76+
async def fetch_epg(url):
77+
connector = aiohttp.TCPConnector(limit=16, ssl=False)
78+
headers = {
79+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"
80+
}
81+
try:
82+
async with aiohttp.ClientSession(connector=connector, trust_env=True, headers=headers) as session:
83+
async with session.get(url) as response:
84+
if url.endswith('.gz'):
85+
compressed_data = await response.read()
86+
return gzip.decompress(compressed_data).decode('utf-8', errors='ignore')
87+
else:
88+
return await response.text(encoding='utf-8')
89+
except aiohttp.ClientError as e:
90+
print(f"{url}HTTP请求错误: {e}")
91+
except asyncio.TimeoutError:
92+
print("{url}请求超时")
93+
except Exception as e:
94+
print(f"{url}其他错误: {e}")
95+
return None
96+
97+
def process_display_name(display_name):
98+
if display_name.endswith('高清'):
99+
display_name = display_name[:-2]
100+
return display_name
101+
102+
def parse_epg(epg_content):
103+
try:
104+
parser = ET.XMLParser(encoding='UTF-8')
105+
root = ET.fromstring(epg_content, parser=parser)
106+
except ET.ParseError as e:
107+
print(f"Error parsing XML: {e}")
108+
print(f"Problematic content: {epg_content[:500]}")
109+
return {}, defaultdict(list)
110+
111+
channels = {}
112+
programmes = defaultdict(list)
113+
114+
for channel in root.findall('channel'):
115+
channel_id = transform2_zh_hans(channel.get('id'))
116+
channel_display_names = []
117+
for name in channel.findall('display-name'):
118+
t_name = transform2_zh_hans(name.text)
119+
t_name = process_display_name(t_name)
120+
channel_display_names.append([t_name, name.get('lang', 'zh')])
121+
if not channel_id.isdigit() and channel_id not in channel_display_names:
122+
channel_display_names.append([channel_id, 'zh'])
123+
channels[channel_id] = channel_display_names
124+
125+
today = datetime.now(TZ_UTC_PLUS_8).date()
126+
valid_channels = set()
127+
128+
for programme in root.findall('programme'):
129+
channel_id = transform2_zh_hans(programme.get('channel'))
130+
channel_start = datetime.strptime(
131+
re.sub(r'\s+', '', programme.get('start')), "%Y%m%d%H%M%S%z")
132+
channel_stop = datetime.strptime(
133+
re.sub(r'\s+', '', programme.get('stop')), "%Y%m%d%H%M%S%z")
134+
channel_start = channel_start.astimezone(TZ_UTC_PLUS_8)
135+
channel_stop = channel_stop.astimezone(TZ_UTC_PLUS_8)
136+
137+
if channel_stop.date() == today:
138+
valid_channels.add(channel_id)
139+
140+
channel_elem = ET.SubElement(
141+
root, 'programme', attrib={"start": channel_start.strftime("%Y%m%d%H%M%S %z"), "stop": channel_stop.strftime("%Y%m%d%H%M%S %z")})
142+
for title in programme.findall('title'):
143+
if title.text is None:
144+
channel_title = "精彩节目"
145+
else:
146+
channel_title = title.text.strip()
147+
langattr = title.get('lang')
148+
if langattr == 'zh' or langattr is None:
149+
channel_title = transform2_zh_hans(channel_title)
150+
channel_elem_t = ET.SubElement(
151+
channel_elem, 'title')
152+
channel_elem_t.text = channel_title
153+
if langattr is not None:
154+
channel_elem_t.set('lang', langattr)
155+
for desc in programme.findall('desc'):
156+
if desc.text is None:
157+
continue
158+
langattr = desc.get('lang')
159+
channel_desc = desc.text.strip()
160+
if langattr == 'zh' or langattr is None:
161+
channel_desc = transform2_zh_hans(channel_desc)
162+
channel_elem_d = ET.SubElement(
163+
channel_elem, 'desc')
164+
channel_elem_d.text = channel_desc.strip()
165+
if langattr is not None:
166+
channel_elem_d.set('lang', langattr)
167+
programmes[channel_id].append(channel_elem)
168+
169+
# Filter channels that don't have any program ending today
170+
channels = {k: v for k, v in channels.items() if k in valid_channels}
171+
# Optional: Filter programmes as well to keep data consistent,
172+
# though only valid channels are returned so main loop might be fine.
173+
# But filtering programmes dict saves memory and ensures correctness if main iterates programmes keys logic changes.
174+
programmes = {k: v for k, v in programmes.items() if k in valid_channels}
175+
176+
return channels, programmes
177+
178+
179+
def write_to_xml(channels_id, channels_names, programmes, filename):
180+
# 目录不存在
181+
if not os.path.exists('output'):
182+
os.makedirs('output')
183+
current_time = datetime.now(TZ_UTC_PLUS_8).strftime("%Y%m%d%H%M%S %z")
184+
root = ET.Element('tv', attrib={'date': current_time})
185+
for channel_id in channels_id:
186+
channel_elem = ET.SubElement(
187+
root, 'channel', attrib={"id": channel_id})
188+
for display_name_node in channels_names[channel_id]:
189+
display_name = display_name_node[0]
190+
langattr = display_name_node[1]
191+
display_name_elem = ET.SubElement(
192+
channel_elem, 'display-name', attrib={"lang": langattr})
193+
display_name_elem.text = display_name
194+
for prog in programmes[channel_id]:
195+
prog.set('channel', channel_id) # 设置 programme 的 channel 属性
196+
root.append(prog)
197+
198+
# Beautify the XML output
199+
rough_string = ET.tostring(root, 'utf-8')
200+
reparsed = minidom.parseString(rough_string)
201+
with open(filename, 'w', encoding='utf-8') as f:
202+
f.write(reparsed.toprettyxml(indent='\t', newl='\n'))
203+
204+
205+
def compress_to_gz(input_filename, output_filename):
206+
with open(input_filename, 'rb') as f_in:
207+
with gzip.open(output_filename, 'wb') as f_out:
208+
shutil.copyfileobj(f_in, f_out)
209+
210+
211+
def get_urls():
212+
urls = []
213+
with open('config.txt', 'r', encoding='utf-8') as file:
214+
for line in file:
215+
line = line.strip()
216+
if line and not line.startswith('#'):
217+
urls.append(line)
218+
return urls
219+
220+
221+
async def main():
222+
urls = get_urls()
223+
tasks = [fetch_epg(url) for url in urls]
224+
print("Fetching EPG data...")
225+
epg_contents = await tqdm_asyncio.gather(*tasks, desc="Fetching URLs")
226+
all_channels_map = {}
227+
all_channel_id = set()
228+
all_channel_names = defaultdict(list)
229+
all_programmes = defaultdict(list)
230+
print("Finished.")
231+
i = 0
232+
for epg_content in epg_contents:
233+
i += 1
234+
print(f"Processing EPG source...{i}/{len(epg_contents)}")
235+
if epg_content is None:
236+
continue
237+
print("Parsing EPG data...")
238+
epg_content = preprocess_epg(urls[i - 1], epg_content)
239+
channels, programmes = parse_epg(epg_content)
240+
print("Finished.")
241+
with tqdm(total=len(channels), desc="Merging EPG", unit="file") as pbar:
242+
for channel_id, display_names in channels.items():
243+
if len(programmes[channel_id]) == 0:
244+
continue
245+
is_in_map = False
246+
map_id = ""
247+
for display_name_node in display_names:
248+
if is_in_map:
249+
break
250+
display_name = display_name_node[0]
251+
is_in_map = display_name in all_channels_map
252+
map_id = display_name
253+
map_id = all_channels_map.get(map_id, channel_id)
254+
if not is_in_map:
255+
all_channel_id.add(map_id)
256+
all_channel_names[map_id] = display_names
257+
all_programmes[map_id] = programmes[channel_id]
258+
for display_name_node in display_names:
259+
display_name = display_name_node[0]
260+
all_channels_map[display_name] = map_id
261+
else:
262+
if len(all_programmes[map_id]) < len(programmes[channel_id]):
263+
all_programmes[map_id] = programmes[channel_id]
264+
for display_name_node in display_names:
265+
display_name = display_name_node[0]
266+
if display_name not in all_channels_map:
267+
all_channel_names[map_id].append(display_name_node)
268+
all_channels_map[display_name] = map_id
269+
pbar.update(1) # 更新进度条
270+
print("Writing to XML...")
271+
write_to_xml(all_channel_id, all_channel_names,
272+
all_programmes, 'output/epg.xml')
273+
compress_to_gz('output/epg.xml', 'output/epg.gz')
274+
275+
if __name__ == '__main__':
276+
asyncio.run(main())

‎output/epg.gz‎

2.67 MB
Binary file not shown.

0 commit comments

Comments
 (0)