This commit is contained in:
eric
2026-03-01 00:53:02 -06:00
parent 3072841db8
commit 3a84000f57
3 changed files with 254 additions and 0 deletions

11
client_secrets.json Normal file
View File

@@ -0,0 +1,11 @@
{
"installed": {
"client_id": "305371487651-iuqafultpqp0rvitojr70560k1foet0n.apps.googleusercontent.com",
"project_id": "live-473104",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "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",
"redirect_uris": ["http://localhost"]
}
}

243
main.py Normal file
View 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()

BIN
youtube_token.pickle Normal file

Binary file not shown.