's'
This commit is contained in:
243
main.py
Normal file
243
main.py
Normal file
@@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
定时检查 YouTube 频道是否有直播存档视频,有就删除
|
||||
优化重点:
|
||||
- 新增开关:DELETE_ONLY_RECENT_48H(只删最近48小时内的存档)
|
||||
- 慢速删除:每删一个随机等待 5~20 秒,每 5 个长休息 1~5 分钟
|
||||
- 每日上限:默认每天最多删 30 个,避免风控/配额耗尽
|
||||
- 限流保护:遇到 quota/rate 错误自动暂停 30 分钟
|
||||
- 支持 uploads playlist + search fallback
|
||||
"""
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import time
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from googleapiclient.discovery import build
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from google.auth.transport.requests import Request
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ===================== 配置 =====================
|
||||
CLIENT_SECRETS_FILE = "client_secrets.json"
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/youtube",
|
||||
"https://www.googleapis.com/auth/youtube.force-ssl",
|
||||
"https://www.googleapis.com/auth/youtubepartner"
|
||||
]
|
||||
API_SERVICE_NAME = "youtube"
|
||||
API_VERSION = "v3"
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 1800 # 检查间隔:30 分钟
|
||||
CHANNEL_ID = "UC-8V3pDMlM2B75fvbYRBsNg"
|
||||
|
||||
# 安全删除参数
|
||||
MAX_DAILY_DELETES = 30 # 每天最多删 30 个(可调 10~50)
|
||||
SHORT_DELAY_MIN = 5 # 每删一个最小等待秒
|
||||
SHORT_DELAY_MAX = 20 # 每删一个最大等待秒
|
||||
LONG_DELAY_AFTER = 5 # 每删几个后长休息
|
||||
LONG_DELAY_MIN = 60 # 长休息最小秒
|
||||
LONG_DELAY_MAX = 300 # 长休息最大秒
|
||||
LIMIT_PAUSE_SECONDS = 1800 # 配额/限流时暂停 30 分钟
|
||||
|
||||
# 新增开关:是否只删除最近 48 小时内的直播存档
|
||||
DELETE_ONLY_RECENT_48H = True # True = 只删 48 小时内;False = 删除所有
|
||||
# ================================================
|
||||
|
||||
CREDENTIALS_FILE = "youtube_token.pickle"
|
||||
|
||||
|
||||
def get_authenticated_service():
|
||||
credentials = None
|
||||
if os.path.exists(CREDENTIALS_FILE):
|
||||
with open(CREDENTIALS_FILE, "rb") as token:
|
||||
credentials = pickle.load(token)
|
||||
|
||||
if not credentials or not credentials.valid:
|
||||
if credentials and credentials.expired and credentials.refresh_token:
|
||||
credentials.refresh(Request())
|
||||
else:
|
||||
flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRETS_FILE, SCOPES)
|
||||
credentials = flow.run_local_server(port=0)
|
||||
with open(CREDENTIALS_FILE, "wb") as token:
|
||||
pickle.dump(credentials, token)
|
||||
|
||||
return build(API_SERVICE_NAME, API_VERSION, credentials=credentials)
|
||||
|
||||
|
||||
def delete_live_archive_videos(youtube):
|
||||
try:
|
||||
deleted_count = 0
|
||||
uploads_playlist_id = None
|
||||
|
||||
# 尝试获取 uploads playlist
|
||||
try:
|
||||
channels_response = youtube.channels().list(
|
||||
part="contentDetails",
|
||||
id=CHANNEL_ID
|
||||
).execute()
|
||||
|
||||
if channels_response.get("items"):
|
||||
related_playlists = channels_response["items"][0].get("contentDetails", {}).get("relatedPlaylists", {})
|
||||
uploads_playlist_id = related_playlists.get("uploads")
|
||||
if uploads_playlist_id:
|
||||
logger.info(f"找到 uploads playlist ID: {uploads_playlist_id}")
|
||||
else:
|
||||
logger.warning("未找到 uploads playlist - 尝试 search fallback")
|
||||
except Exception as e:
|
||||
logger.warning(f"获取 uploads playlist 失败: {e}")
|
||||
|
||||
video_ids = []
|
||||
|
||||
# 优先用 uploads playlist
|
||||
if uploads_playlist_id:
|
||||
next_page_token = None
|
||||
while True:
|
||||
playlist_response = youtube.playlistItems().list(
|
||||
part="snippet",
|
||||
playlistId=uploads_playlist_id,
|
||||
maxResults=50,
|
||||
pageToken=next_page_token
|
||||
).execute()
|
||||
|
||||
for item in playlist_response.get("items", []):
|
||||
video_ids.append(item["snippet"]["resourceId"]["videoId"])
|
||||
|
||||
next_page_token = playlist_response.get("nextPageToken")
|
||||
if not next_page_token:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
logger.info(f"从 uploads playlist 发现 {len(video_ids)} 个视频 ID")
|
||||
|
||||
# fallback: search 已结束直播
|
||||
else:
|
||||
next_page_token = None
|
||||
while True:
|
||||
search_response = youtube.search().list(
|
||||
part="id,snippet",
|
||||
channelId=CHANNEL_ID,
|
||||
eventType="completed",
|
||||
type="video",
|
||||
maxResults=50,
|
||||
pageToken=next_page_token,
|
||||
order="date"
|
||||
).execute()
|
||||
|
||||
for item in search_response.get("items", []):
|
||||
if item["id"]["kind"] == "youtube#video":
|
||||
video_ids.append(item["id"]["videoId"])
|
||||
|
||||
next_page_token = search_response.get("nextPageToken")
|
||||
if not next_page_token:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
logger.info(f"从 search 发现 {len(video_ids)} 个潜在直播存档视频 ID")
|
||||
|
||||
if not video_ids:
|
||||
logger.info("未发现任何视频 ID,本次检查结束")
|
||||
return
|
||||
|
||||
# 计算 48 小时前的 UTC 时间(如果开关开启)
|
||||
cutoff_time = None
|
||||
if DELETE_ONLY_RECENT_48H:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=48)
|
||||
logger.info(f"只删除结束时间晚于 {cutoff_time.isoformat()} 的存档")
|
||||
|
||||
# 批量检查 + 慢速删除
|
||||
batch_size = 20
|
||||
for i in range(0, len(video_ids), batch_size):
|
||||
if deleted_count >= MAX_DAILY_DELETES:
|
||||
logger.info(f"达到每日上限 {MAX_DAILY_DELETES} 个,停止本次检查")
|
||||
break
|
||||
|
||||
batch = video_ids[i:i + batch_size]
|
||||
try:
|
||||
videos_response = youtube.videos().list(
|
||||
part="id,snippet,liveStreamingDetails",
|
||||
id=",".join(batch)
|
||||
).execute()
|
||||
except Exception as e:
|
||||
logger.error(f"批量获取视频详情失败: {e}")
|
||||
continue
|
||||
|
||||
for video in videos_response.get("items", []):
|
||||
if deleted_count >= MAX_DAILY_DELETES:
|
||||
break
|
||||
|
||||
video_id = video["id"]
|
||||
title = video["snippet"].get("title", "无标题")
|
||||
live_details = video.get("liveStreamingDetails", {})
|
||||
actual_end_time_str = live_details.get("actualEndTime")
|
||||
|
||||
if not actual_end_time_str:
|
||||
continue
|
||||
|
||||
# 解析结束时间
|
||||
try:
|
||||
end_time = datetime.fromisoformat(actual_end_time_str.replace("Z", "+00:00"))
|
||||
except:
|
||||
continue
|
||||
|
||||
# 如果开关开启,只删 48 小时内的
|
||||
if DELETE_ONLY_RECENT_48H and end_time < cutoff_time:
|
||||
continue
|
||||
|
||||
# 慢速删除
|
||||
try:
|
||||
delay = random.uniform(SHORT_DELAY_MIN, SHORT_DELAY_MAX)
|
||||
logger.info(f"找到直播存档: {title} | {video_id} | 结束于 {actual_end_time_str}")
|
||||
logger.info(f"等待 {delay:.1f} 秒后删除...")
|
||||
time.sleep(delay)
|
||||
|
||||
youtube.videos().delete(id=video_id).execute()
|
||||
logger.info(f"成功删除: {title} | {video_id}")
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count % LONG_DELAY_AFTER == 0:
|
||||
long_delay = random.uniform(LONG_DELAY_MIN, LONG_DELAY_MAX)
|
||||
logger.info(f"已删 {deleted_count} 个,长休息 {long_delay/60:.1f} 分钟...")
|
||||
time.sleep(long_delay)
|
||||
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
logger.error(f"删除失败 {video_id}: {err_str}")
|
||||
if any(word in err_str.lower() for word in ["quota", "rate", "limit", "exceeded"]):
|
||||
logger.warning(f"触发限流/配额,暂停 {LIMIT_PAUSE_SECONDS//60} 分钟...")
|
||||
time.sleep(LIMIT_PAUSE_SECONDS)
|
||||
|
||||
time.sleep(3) # 批次间额外间隔
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"本次检查删除了 {deleted_count} 个直播存档")
|
||||
else:
|
||||
logger.info("本次未发现或未删除任何直播存档")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("检查/删除过程中发生严重错误")
|
||||
|
||||
|
||||
def main():
|
||||
youtube = get_authenticated_service()
|
||||
logger.info("YouTube 直播存档自动删除任务启动...")
|
||||
logger.info(f"监控频道: {CHANNEL_ID}")
|
||||
logger.info(f"检查间隔: {CHECK_INTERVAL_SECONDS} 秒")
|
||||
logger.info(f"每日删除上限: {MAX_DAILY_DELETES} 个")
|
||||
logger.info(f"只删除最近 48 小时内存档: {'是' if DELETE_ONLY_RECENT_48H else '否'}\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
delete_live_archive_videos(youtube)
|
||||
except Exception as e:
|
||||
logger.exception("主循环异常")
|
||||
|
||||
time.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user