import requests
from bs4 import BeautifulSoup
import re,os,subprocess
from datetime import timedelta, date
import yt_dlp
import env
import config
from audioRead import *
from cleanup import maybe_cleanup_after_run
from newsProcess import STATUS_FAILED, news_to_db
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def get_xwlb_video_link(url):
"""
从央视网新闻联播页面抓取历史完整版视频链接
"""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Referer': 'https://tv.cctv.com/'
}
try:
response = requests.get(url, headers=headers, timeout=10)
response.encoding = 'utf-8'
if response.status_code != 200:
logger.error(f"请求失败,状态码: {response.status_code}")
#print(f"请求失败,状态码: {response.status_code}")
return []
except Exception as e:
logger.error(f"请求异常: {e}")
return []
soup = BeautifulSoup(response.text, 'html.parser')
video_links = []
# 查找所有包含“完整版《新闻联播》”的链接
# 方法1: 查找包含 完整版《新闻联播》 的 a 标签
for a_tag in soup.find_all('a', href=True):
# 检查文本中是否包含“完整版”和“新闻联播”
title_text = a_tag.get_text(strip=True)
inner_html = str(a_tag)
# 判断是否是“完整版《新闻联播》”的链接
if ('完整版' in title_text and '新闻联播' in title_text) or \
(re.search(r']*>完整版\s*《新闻联播》', inner_html)):
video_url = a_tag['href']
# 提取日期信息(从标题或链接中)
date_match = re.search(r'\d{8}', title_text)
if not date_match:
# 从链接中提取日期,如 /2025/09/25/...VID...250925.shtml
date_match = re.search(r'/(\d{4})/(\d{2})/(\d{2})/', video_url)
if date_match:
year, month, day = date_match.groups()
date_str = f"{year}{month}{day}"
else:
date_str = "未知日期"
else:
date_str = date_match.group()
video_links.append({
'date': date_str,
'title': title_text.strip(),
'url': video_url,
'page_url': url
})
logger.info(f"✅ 找到新闻联播完整版: {date_str} -> {video_url}")
# 原实现误返回循环内的单个 video_url(且无匹配时该变量未定义会 UnboundLocalError)
return video_links
def xwlb_urls(start: str, end: str):
"""
start/end 格式 '20240925'
返回列表,如 ['https://tv.cctv.com/lm/xwlb/day/20240925.shtml', ...]
"""
d0 = date(int(start[:4]), int(start[4:6]), int(start[6:8]))
d1 = date(int(end[:4]), int(end[4:6]), int(end[6:8]))
urls = []
for n in range((d1 - d0).days + 1):
day = d0 + timedelta(days=n)
urls.append({"url": f"https://tv.cctv.com/lm/xwlb/day/{day:%Y%m%d}.shtml", "date": f"{day:%Y%m%d}"})
#print(urls)
return urls
def get_all_video_links(start: str, end: str):
"""逐日解析完整版视频链接,返回 [{'url':..., 'date': 'YYYYMMDD'}, ...]
原实现把 get_xwlb_video_link() 的返回值(单个 URL 或空列表)直接包成 dict,
导致空链接也会进入下载流程;现改为按天取第一条有效链接并显式跳过失败日期。
"""
base_urls = xwlb_urls(start, end)
video_urls = []
for item in base_urls:
links = get_xwlb_video_link(item['url'])
if not links:
logger.error(f"❌ {item['date']} 未解析到「完整版《新闻联播》」链接,跳过: {item['url']}")
continue
if len(links) > 1:
logger.warning(f"⚠️ {item['date']} 解析到 {len(links)} 条完整版链接,取第一条: {links[0]['url']}")
video_urls.append({"url": links[0]['url'], "date": item['date']})
return video_urls
'''
get_xwlb_video_link() 方法获得的url,
urls like: https://tv.cctv.com/2024/10/30/VIDEUlPz1Qusy41JFQj3LMLd241030.shtml
通过yt-dlp下载视频,保存为mp4文件,并用ffmpeg提取音频为mp3文件,文件名使用url 的日期部分,如上面的url应保存为 20241030.mp4 和 20241030.mp3
文件保存路径为当前目录下的 xwlb_video 文件夹,若不存在则创建。
'''
def download_and_extract_audio(video_url,date_str,download_dir):
"""
使用yt-dlp下载视频并提取音频;已存在音频时跳过(幂等,支持重跑)
返回: mp3 路径(成功)或 None(失败)
"""
# 从URL中提取日期
if not video_url:
logger.error(f"❌ {date_str} 视频链接为空,跳过下载")
return None
download_dir = os.fspath(download_dir)
os.makedirs(download_dir, exist_ok=True)
# 构建文件路径
mp4_path = os.path.join(download_dir, f"{date_str}.mp4")
mp3_path = os.path.join(download_dir, f"{date_str}.mp3")
# 幂等:音频已抽取过就不再重复下载(原实现每次重跑都重新下载 ~100MB)
if os.path.exists(mp3_path) and os.path.getsize(mp3_path) > 0:
logger.info(f"⏭️ {date_str} 音频已存在,跳过下载: {mp3_path}")
return mp3_path
try:
# 使用yt-dlp库下载视频
logger.info(f"📥 开始下载 {date_str} 的视频...")
# 配置yt-dlp选项
ydl_opts = {
'outtmpl': mp4_path,
'format': 'best[ext=mp4]/best',
'progress_hooks': [lambda d: print(f"\r📥 下载进度: {d.get('_percent_str', 'N/A').strip()} | {d.get('_speed_str', 'N/A').strip()} | 已下载: {d.get('_downloaded_bytes_str', 'N/A')}", end='') if d['status'] == 'downloading' else None],
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([video_url])
logger.info(f"📥 下载 {date_str} 完成")
# 使用ffmpeg提取音频
logger.info(f"🎵 开始提取 {date_str} 的音频...")
# 原实现 stdout=None/stderr=None 会把 ffmpeg 完整 banner 灌进日志(日志已 34MB),
# 现仅在失败时记录 stderr 尾部
proc = subprocess.run([
"ffmpeg",
"-i", mp4_path,
"-c:a", "libmp3lame", # 明确指定MP3编码器
"-q:a", "0",
"-map", "a",
mp3_path,
"-y" # 覆盖已存在文件
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg 提取音频失败(returncode={proc.returncode}): {(proc.stderr or '')[-500:]}")
logger.info(f"🎵 提取 {date_str} 音频完成")
logger.info(f"✅ 成功处理 {date_str}: {mp4_path}, {mp3_path}")
return mp3_path
except Exception as e:
logger.error(f"❌ 处理 {date_str} 时发生异常: {e}")
return None
# 在get_all_video_links函数后添加调用代码
def process_videos(start_date, end_date):
"""
处理指定日期范围内的所有视频
返回值(供定时任务判断成败):
dict: {'total': 待处理天数, 'ok': 全链路成功天数, 'failed': 失败天数}
total=0 表示一天都没抓到链接,按失败处理(页面可能还没发布)。
"""
video_urls = get_all_video_links(start_date, end_date)
if not video_urls:
logger.error(f"❌ {start_date}~{end_date} 未获取到任何视频链接,流程结束")
return {'total': 0, 'ok': 0, 'failed': 0}
summary = {'total': 0, 'ok': 0, 'failed': 0}
# 目录来自 config.yml 的 paths.*(相对项目根,可写绝对路径)
download_dir = config.video_dir()
output_folder = config.audio_dir()
for sub_url in video_urls:
date_str = sub_url.get('date')
url = sub_url.get('url')
if not url or not date_str:
logger.error(f"❌ 跳过无效条目: {sub_url}")
summary['failed'] += 1
continue
summary['total'] += 1
print("=" * 80)
mp3_path = download_and_extract_audio(url, date_str, download_dir)
if not mp3_path or not os.path.exists(mp3_path):
logger.error(f"❌ {date_str} 音频不可用,跳过识别与入库")
print("=" * 80)
summary['failed'] += 1
continue
# 处理长音频 + 切分入库;两者都成功才算当天任务完成
day_ok = False
try:
audio_result = process_long_audio(mp3_path, output_folder, date_str)
if audio_result and audio_result.get('failed', 1) > 0:
# 识别不完整:**不执行切分**。否则会给这天写出 ext,
# 让下游把"半天内容"当成完整一天(且失败原因被掩盖)。
# 分片数据仍在 xwlb_daily 里,重试会补全。
logger.error(f"❌ {date_str} 识别不完整({audio_result.get('ok')}/{audio_result.get('chunks')} 片),"
f"跳过切分以免发布不完整的一天")
split_status = 'skipped(incomplete-asr)'
else:
split_status = news_to_db(date_str)
day_ok = bool(audio_result and audio_result.get('failed', 1) == 0
and split_status != STATUS_FAILED)
logger.info(f"{date_str} 任务结果: 识别 {audio_result.get('ok', 0)}/{audio_result.get('chunks', 0)} 片,切分 {split_status}")
except Exception as e:
logger.error(f"❌ {date_str} 处理失败: {e}")
print("=" * 80)
summary['ok' if day_ok else 'failed'] += 1
# 当天全程任务完成后清理 mp3/mp4 与 wav(config.yml: cleanup.*)
maybe_cleanup_after_run(date_str, day_ok)
logger.info(f"本次运行汇总: 共 {summary['total']} 天,成功 {summary['ok']} 天,失败 {summary['failed']} 天")
return summary
# ========================
# 主程序执行
# ========================
if __name__ == "__main__":
import sys
import re
from datetime import datetime
# 检查命令行参数
if len(sys.argv) < 2:
print("用法: python getVideo5.py ")
print("日期格式: YYYYMMDD")
sys.exit(1)
start_date = sys.argv[1]
end_date = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else start_date
# 检查日期格式
date_pattern = r'^\d{8}$'
if not re.match(date_pattern, start_date) or not re.match(date_pattern, end_date):
print("错误: 日期格式必须为 YYYYMMDD")
sys.exit(1)
# 检查日期有效性
try:
#start_dt = datetime.strptime(start_date, '%Y%m%d')
#end_dt = datetime.strptime(end_date, '%Y%m%d')
if start_date > end_date:
print(f"错误: start_date {start_date} 不能大于 end_date {end_date}")
sys.exit(1)
print("正在抓取央视《新闻联播》历史完整版视频链接...")
print("=" * 80)
config.log_summary()
summary = process_videos(start_date, end_date)
# 退出码供定时任务判断: 0=当天全链路成功; 1=失败(含"没抓到链接",交由重试)
if summary and summary['ok'] > 0 and summary['failed'] == 0:
sys.exit(0)
print(f"❌ 运行失败: {summary}")
sys.exit(1)
except ValueError as e:
print(f"错误: 无效日期 - {e}")
sys.exit(1)