Archived
chore: 基线快照 —— 纳入 git 版本控制(站点 v2.4.0 / Flask 单体)
在框架迁移与模块拆分前保存当前可运行状态: - 后端:main.py/auth_routes.py/auth_system.py/music_api.py/music_downloader.py/podcast_api.py/qr_login.py - 前端:templates/index.html + static/site_auth.js - 部署:Dockerfile/docker-compose.yml/entrypoint.sh/requirements.txt 敏感数据(data/、cookie.txt)与内部文档(交接文档/新会话引导)按 .gitignore 排除。
This commit is contained in:
commit
d209af847a
21 files changed
+20816
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# T1:构建镜像严禁带入的敏感/私有/无关内容
|
||||
LICENSE
|
||||
README.md
|
||||
.gitignore
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
downloads/
|
||||
*.part
|
||||
release/
|
||||
release.zip
|
||||
tests/
|
||||
_smoke_test_downloader.py
|
||||
_test_podcast_api.py
|
||||
部署文档.md
|
||||
功能介绍.md
|
||||
# T1 新增:真实凭据、用户数据库、密钥、日志、交接文档、运维脚本与审查工作目录
|
||||
cookie.txt
|
||||
user_cookie.txt
|
||||
# Cookie 备份(qr_login._backup_cookie 产生,含真实 MUSIC_U):
|
||||
# 原模式 *.cookie.backup.* 与实际文件名 cookie.backup.*.txt 不匹配,排除形同虚设
|
||||
cookie.*.txt
|
||||
user_cookie.*.txt
|
||||
data/
|
||||
data/backups/
|
||||
*.log
|
||||
# 第 97 条(P3):轮转件 music_api.log.1/2/3 不匹配 *.log——含 IP/UA 的历史日志
|
||||
# 曾随 _mkpkg 同类问题外泄(第 55 条),docker 轨道同步堵上
|
||||
music_api.log*
|
||||
*.log.*
|
||||
交接文档.md
|
||||
修复验收台账_F01-F117.md
|
||||
待修问题清单_三轮汇总.md
|
||||
# P2-9:补齐后产生的审计清单与内部引导文件(含内部路径/运维细节)
|
||||
待修问题清单_第四轮深挖.md
|
||||
待修问题清单_第五轮深挖.md
|
||||
新会话引导.md
|
||||
_pkg_ok.txt
|
||||
_css_ok.txt
|
||||
.zcode/
|
||||
云村小助手-网站开关.bat
|
||||
_audit_work/
|
||||
venv/
|
||||
@@ -0,0 +1,7 @@
|
||||
# 复制为 .env 后按需修改(F59:不提供 .env 时 docker-compose 使用默认 TZ,可直接运行)
|
||||
TZ=Asia/Shanghai
|
||||
# HTTPS 反代(Caddy/Nginx 等)上线后取消注释:会话 Cookie 仅经 HTTPS 传输
|
||||
# (纯 HTTP 部署不要开启,否则浏览器拒收 Cookie;详见交接文档 F11)
|
||||
# ENABLE_SECURE_COOKIE=1
|
||||
# 第 100 条:跨域来源控制——默认 `*`(历史口径);自用收敛同源可改为实际域名
|
||||
# CORS_ORIGINS=https://qf.linxingran.top
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
venv
|
||||
.venv
|
||||
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# P3-41:运行时敏感数据与私有文档——公开包买家若将本项目纳入 git 管理,
|
||||
# 以下内容绝不能进版本库(用户数据库/网易云 Cookie/日志/审计文档)
|
||||
data/
|
||||
cookie.txt
|
||||
cookie.*.txt
|
||||
user_cookie.txt
|
||||
user_cookie.*.txt
|
||||
downloads/
|
||||
*.log
|
||||
music_api.log*
|
||||
_pkg_ok.txt
|
||||
_css_ok.txt
|
||||
_audit_work/
|
||||
.zcode/
|
||||
.env
|
||||
新会话引导.md
|
||||
交接文档.md
|
||||
修复验收台账_F01-F117.md
|
||||
待修问题清单_三轮汇总.md
|
||||
待修问题清单_第四轮深挖.md
|
||||
待修问题清单_第五轮深挖.md
|
||||
_smoke_test_downloader.py
|
||||
云村小助手-网站开关.bat
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# T7:基础镜像升级到 Python 3.12(3.9 已于 2025-10 EOL);alpine + ffmpeg 组合保持不变
|
||||
FROM python:3.12-alpine3.21
|
||||
WORKDIR /app
|
||||
# 使用阿里云 apk 镜像源加速安装 ffmpeg
|
||||
# 第 97 条(P3):补 tzdata——alpine 无 zoneinfo,ENV TZ=Asia/Shanghai 不装它不生效
|
||||
# (容器内日志/展示本地时间差 8 小时;计费/会话全部 epoch 秒不受影响)
|
||||
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories && \
|
||||
apk add --no-cache tzdata ffmpeg
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
|
||||
pip3 install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
# 非 root 运行(容器安全基线)。注:挂载卷 ./downloads ./data 的宿主机目录
|
||||
# 需对 UID 10001 可写(见 docker-compose.yml 注释),否则下载与数据库写入会失败
|
||||
RUN adduser -D -u 10001 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
ENV TZ=Asia/Shanghai
|
||||
EXPOSE 5000
|
||||
# 容器健康检查(/health 为轻量本地端点,不触发上游请求)
|
||||
HEALTHCHECK --interval=60s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:5000/health || exit 1
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
@@ -0,0 +1,5 @@
|
||||
⚠️ 私有部署包——严禁外传、严禁上传网盘/代码仓库/发给任何人。
|
||||
本包包含全部敏感数据:cookie.txt(网易云 SVIP 登录态)、data/site.db(用户库)、
|
||||
data/secret.key(会话与音频签名根)、交接文档.md(含管理员凭据)。
|
||||
泄露后果与应急流程见交接文档 §4/§5.7 A2/C1/C2/E4。
|
||||
(本文件由 _mkpkg.py 私有模式自动生成;对外分发包由 --public 生成并剔除全部敏感物。)
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""CSS 完整性检查:花括号平衡 + 关键美化标记存活"""
|
||||
import sys
|
||||
src = open('templates/index.html', encoding='utf-8').read()
|
||||
style = src.split('<style>')[1].split('</style>')[0]
|
||||
ok = style.count('{') == style.count('}')
|
||||
marks = sum(1 for m in ['font-family: inherit', 'kw.on { text-shadow', 'uc-card:hover', 'theme-fading', 'textarea.input', 'prefers-reduced-motion', '美化层 v4', '美化层 v5', 'safe-area-inset'] if m in style)
|
||||
with open('_css_ok.txt', 'w', encoding='utf-8') as f:
|
||||
f.write(f'braces={style.count("{")}=={style.count("}")} {ok} | marks {marks}/9')
|
||||
print('ok')
|
||||
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""重新生成部署包——在主项目目录下运行:python _mkpkg.py [--public]
|
||||
|
||||
- 默认:生成 owner 私有部署包(含 data/site.db、data/secret.key、cookie.txt,
|
||||
供 owner 自己的服务器迁移使用,严禁对外分发)
|
||||
- --public:生成可对外分发的干净包(F01:剔除全部敏感数据——数据库/密钥/网易云 Cookie/
|
||||
日志),包内另附 _pkg_ok.txt 标记
|
||||
|
||||
F67:源/目标路径基于脚本自身位置推导,不再写死原作者桌面绝对路径。
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
SRC = os.path.dirname(os.path.abspath(__file__))
|
||||
DST = os.path.join(os.path.dirname(SRC), 'NeteaseUrl-部署包')
|
||||
|
||||
public = '--public' in sys.argv[1:]
|
||||
|
||||
ignore = shutil.ignore_patterns(
|
||||
'venv*', '__pycache__', 'downloads', 'release*', '*.log', 'music_api.log*',
|
||||
'_smoke_test_downloader.py', 'backups', '云村小助手-网站开关.bat',
|
||||
'_audit_work*',
|
||||
# 第 87 条:.zcode 为 AI 工具工作目录(内部规划/会话文件),不得进任何分发包
|
||||
'.zcode*',
|
||||
# Cookie 备份文件(qr_login._backup_cookie 产生,含真实 MUSIC_U):
|
||||
# 'cookie.*.txt' 匹配 cookie.prewrite/backup/logout.*.txt 但不匹配 cookie.txt 本身
|
||||
'cookie.*.txt', 'user_cookie.*.txt')
|
||||
|
||||
if os.path.exists(DST):
|
||||
shutil.rmtree(DST)
|
||||
shutil.copytree(SRC, DST, ignore=ignore)
|
||||
|
||||
# 双保险:显式删除目标里可能漏网的敏感文件(万一 ignore 模式被绕过)。
|
||||
# 含轮转日志 music_api.log.1/2/3——fnmatch 的 *.log 不匹配它们,曾会随包外泄(含 IP/UA)
|
||||
for _pat in ('cookie.*.txt', 'user_cookie.*.txt', 'music_api.log*'):
|
||||
for _p in glob.glob(os.path.join(DST, _pat)):
|
||||
try:
|
||||
os.remove(_p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if public:
|
||||
# F01:公开包必须剔除的敏感/私有内容(P1-1:补齐后产生的审计文档与内部引导文件——
|
||||
# 修复台账含全部 117 项漏洞细节,新会话引导含内部路径与运维细节,均不得对外)
|
||||
for rel in ('data', 'cookie.txt', 'user_cookie.txt', 'music_api.log',
|
||||
'交接文档.md', '待修问题清单_三轮汇总.md',
|
||||
'修复验收台账_F01-F117.md', '新会话引导.md',
|
||||
'待修问题清单_第四轮深挖.md',
|
||||
# 第 87 条:第五轮深挖清单同样含全部漏洞细节,公开包必须剔除
|
||||
'待修问题清单_第五轮深挖.md',
|
||||
'_pkg_ok.txt', '_css_ok.txt'):
|
||||
p = os.path.join(DST, rel)
|
||||
try:
|
||||
if os.path.isdir(p):
|
||||
shutil.rmtree(p)
|
||||
elif os.path.exists(p):
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
# 公开包同样剔除轮转日志与 Cookie 备份(双保险与上面同集)
|
||||
for _pat in ('music_api.log*', 'cookie.*.txt', 'user_cookie.*.txt'):
|
||||
for _p in glob.glob(os.path.join(DST, _pat)):
|
||||
try:
|
||||
os.remove(_p)
|
||||
except OSError:
|
||||
pass
|
||||
marker = 'public-clean package (no secrets)'
|
||||
else:
|
||||
# 第 100 条(运维审计):私有包严禁携带《README_公开包说明.txt》——它宣称
|
||||
# "不含任何敏感数据/由 --public 生成",而私有包含 cookie.txt/site.db/secret.key
|
||||
# 与交接文档(明文管理员凭据),接手运维按该 README 认知会整包外发造成泄露。
|
||||
# 私有包写入显式警告文件替代。
|
||||
_p = os.path.join(DST, 'README_公开包说明.txt')
|
||||
if os.path.exists(_p):
|
||||
try:
|
||||
os.remove(_p)
|
||||
except OSError:
|
||||
pass
|
||||
with open(os.path.join(DST, 'README_私有包警告.txt'), 'w', encoding='utf-8') as _f:
|
||||
_f.write(
|
||||
'⚠️ 私有部署包——严禁外传、严禁上传网盘/代码仓库/发给任何人。\n'
|
||||
'本包包含全部敏感数据:cookie.txt(网易云 SVIP 登录态)、data/site.db(用户库)、\n'
|
||||
'data/secret.key(会话与音频签名根)、交接文档.md(含管理员凭据)。\n'
|
||||
'泄露后果与应急流程见交接文档 §4/§5.7 A2/C1/C2/E4。\n'
|
||||
'(本文件由 _mkpkg.py 私有模式自动生成;对外分发包由 --public 生成并剔除全部敏感物。)\n')
|
||||
marker = None
|
||||
|
||||
n = 0
|
||||
sz = 0
|
||||
for r, _, fs in os.walk(DST):
|
||||
for f in fs:
|
||||
n += 1
|
||||
sz += os.path.getsize(os.path.join(r, f))
|
||||
with open(os.path.join(SRC, '_pkg_ok.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write(marker if marker else f'{n} files, {sz // 1024} KB')
|
||||
shutil.copy2(os.path.join(SRC, '_pkg_ok.txt'), os.path.join(DST, '_pkg_ok.txt'))
|
||||
print('ok:', 'public' if public else 'private', '-', n, 'files')
|
||||
+1050
File diff suppressed because it is too large.
Load diff
+1785
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
netease-url:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# F59:.env 缺失时不再启动失败(required 属性 compose v2.24+ 支持;老版本可删掉该行)
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
- TZ=Asia/Shanghai # 未提供 .env 时的默认时区(F59:文档"默认即可运行"现在成立)
|
||||
- ENABLE_SECURE_COOKIE=${ENABLE_SECURE_COOKIE:-} # HTTPS 反代上线后设 1(见 .env.example)
|
||||
ports:
|
||||
- "5001:5000" # 对外 5001 端口(容器内 Flask 监听 5000)
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./cookie.txt:/app/cookie.txt # 全局管理员 Cookie(持久化,重建不丢失;需先创建文件)
|
||||
- ./user_cookie.txt:/app/user_cookie.txt # 游客个人 Cookie(持久化;需先创建文件)
|
||||
- ./downloads:/app/downloads # 下载文件目录
|
||||
- ./data:/app/data # 站点数据库 / 备份(认证系统)
|
||||
# 注:Dockerfile 以非 root(appuser, UID 10001)运行;宿主机需保证
|
||||
# ./downloads 与 ./data 对 UID 10001 可写(如 sudo chown -R 10001:10001 downloads data)
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# F59:exec 让 python 成为 PID 1 直接接收 SIGTERM,容器停止不再经 shell 转发而超时
|
||||
# 第 93 条(P2-4):compose 挂载 ./user_cookie.txt 时若宿主不存在该文件,Docker 会
|
||||
# 在启动前创建同名**目录**并挂载——容器内 touch 只对无卷 docker run 有效。
|
||||
# 第 97 条(P3):检测到同名目录直接报错退出(fail-fast),不再静默带病运行
|
||||
for f in user_cookie.txt cookie.txt; do
|
||||
if [ -d "/app/$f" ]; then
|
||||
echo "FATAL: /app/$f 是目录(宿主机缺文件时 Docker 挂载会建同名目录)。请在宿主机先 touch 该文件再启动。" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -f "/app/$f" ] || touch "/app/$f"
|
||||
done
|
||||
exec python3 main.py
|
||||
+1468
File diff suppressed because it is too large.
Load diff
+1173
File diff suppressed because it is too large.
Load diff
+554
@@ -0,0 +1,554 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""网易云音乐播客(电台 / 单集声音)API 模块
|
||||
|
||||
提供网易云"播客"板块相关接口封装,包括:
|
||||
- 播客搜索(电台 / 单集声音)
|
||||
- 播客电台详情与单集列表(分页)
|
||||
- 单集(program)详情
|
||||
- 热门播客推荐
|
||||
- 单集音频直链解析(音质自动降级)
|
||||
|
||||
说明:
|
||||
- 播客单集的音频本质上是网易云曲库中的 song(program.mainSong),
|
||||
因此直链复用 music_api.url_v1(eapi song/enhance/player/url/v1)获取。
|
||||
- 支持格式:mp3 / m4a / aac 等(以接口返回 type 为准)。
|
||||
- 支持链接协议:music.163.com/djradio?id=(电台)、/program?id= 与 /sound?id=(单集)、
|
||||
以及 163cn.tv 短链。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from random import randrange
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import requests
|
||||
|
||||
from music_api import APIConstants, APIException, url_v1
|
||||
|
||||
|
||||
class PodcastConstants:
|
||||
"""播客接口相关常量"""
|
||||
|
||||
# 搜索(cloudsearch:type=1009 播客电台 / 2000 单集声音)
|
||||
SEARCH_API = 'https://music.163.com/api/cloudsearch/pc'
|
||||
# 电台(播客)详情
|
||||
DJRADIO_GET_API = 'https://music.163.com/api/djradio/get'
|
||||
# 电台下的单集列表(分页)
|
||||
DJ_PROGRAM_BYRADIO_API = 'https://music.163.com/api/dj/program/byradio'
|
||||
# 单集详情
|
||||
DJ_PROGRAM_DETAIL_API = 'https://music.163.com/api/dj/program/detail'
|
||||
# 热门播客(必须携带有效 cateId)
|
||||
DJRADIO_HOT_API = 'https://music.163.com/api/djradio/hot'
|
||||
# 播客分类列表
|
||||
DJRADIO_CATEGORY_API = 'https://music.163.com/api/djradio/category/get'
|
||||
|
||||
# 播客音频常用音质降级阶梯(无损对播客基本无意义)
|
||||
QUALITY_LADDER = ['exhigh', 'standard']
|
||||
|
||||
# 热门接口的兜底分类ID(生活 / 情感 / 脱口秀 / 有声书)
|
||||
DEFAULT_HOT_CATE_IDS = [6, 3, 8, 10001]
|
||||
|
||||
|
||||
# 第 97 条(对抗复审补漏):与 music_api 同口径的上游 JSON 响应体上限
|
||||
_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _post_json(url: str, data: Dict[str, Any], cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""发送表单 POST 请求并返回 JSON"""
|
||||
headers = {
|
||||
'User-Agent': APIConstants.USER_AGENT,
|
||||
'Referer': APIConstants.REFERER,
|
||||
}
|
||||
try:
|
||||
response = requests.post(url, data=data, headers=headers,
|
||||
cookies=cookies or {}, timeout=30)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||||
raise APIException('播客接口响应体异常(超过大小上限)')
|
||||
result = response.json()
|
||||
if not isinstance(result, dict):
|
||||
raise APIException('播客接口响应异常(非标准 JSON 对象)')
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"播客接口请求失败: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析播客接口响应失败: {e}")
|
||||
if result.get('code') not in (200, None):
|
||||
raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}")
|
||||
return result
|
||||
|
||||
|
||||
def _ts_to_date(ts: Optional[int]) -> str:
|
||||
"""毫秒时间戳 → 日期字符串"""
|
||||
try:
|
||||
if not ts:
|
||||
return ''
|
||||
return time.strftime('%Y-%m-%d', time.localtime(int(ts) / 1000))
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
return ''
|
||||
|
||||
|
||||
def _fmt_duration(ms: Optional[int]) -> str:
|
||||
"""毫秒 → mm:ss / hh:mm:ss"""
|
||||
try:
|
||||
sec = int((ms or 0) / 1000)
|
||||
except (TypeError, ValueError):
|
||||
return ''
|
||||
if sec <= 0:
|
||||
return ''
|
||||
h, rem = divmod(sec, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def _fmt_count(n: Optional[int]) -> str:
|
||||
"""数量 → 万单位简写"""
|
||||
try:
|
||||
n = int(n or 0)
|
||||
except (TypeError, ValueError):
|
||||
return '0'
|
||||
if n >= 100000000:
|
||||
v = n / 100000000
|
||||
return f"{v:.1f}亿".replace('.0', '')
|
||||
if n >= 10000:
|
||||
v = n / 10000
|
||||
return f"{v:.1f}万".replace('.0', '')
|
||||
return str(n)
|
||||
|
||||
|
||||
# ============ 数据规范化 ============
|
||||
|
||||
def _norm_radio(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""规范化电台(播客)信息"""
|
||||
dj = raw.get('dj') or {}
|
||||
return {
|
||||
'id': raw.get('id'),
|
||||
'name': raw.get('name', ''),
|
||||
'pic_url': raw.get('picUrl', ''),
|
||||
'desc': str(raw.get('desc') or raw.get('description') or '').strip(),
|
||||
'category': raw.get('category', ''),
|
||||
'sub_count': raw.get('subCount', 0),
|
||||
'sub_count_text': _fmt_count(raw.get('subCount')),
|
||||
'program_count': raw.get('programCount', 0),
|
||||
'play_count': raw.get('playCount', 0),
|
||||
'play_count_text': _fmt_count(raw.get('playCount')),
|
||||
'dj_name': dj.get('nickname', ''),
|
||||
'dj_avatar': dj.get('avatarUrl', ''),
|
||||
'create_date': _ts_to_date(raw.get('createTime')),
|
||||
'second_category': raw.get('secondCategory', ''),
|
||||
}
|
||||
|
||||
|
||||
def _norm_program(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""规范化单集(program)信息"""
|
||||
radio = raw.get('radio') or {}
|
||||
dj = raw.get('dj') or radio.get('dj') or {}
|
||||
main_song = raw.get('mainSong') or raw.get('mainMusic') or {}
|
||||
return {
|
||||
'id': raw.get('id'),
|
||||
'name': raw.get('name', ''),
|
||||
'cover_url': raw.get('coverUrl') or raw.get('cover') or radio.get('picUrl', ''),
|
||||
'description': str(raw.get('description') or raw.get('desc') or '').strip(),
|
||||
'duration': raw.get('duration', 0),
|
||||
'duration_text': _fmt_duration(raw.get('duration') or main_song.get('duration')),
|
||||
'create_date': _ts_to_date(raw.get('createTime')),
|
||||
'serial_num': raw.get('serialNum', 0),
|
||||
'listener_count': raw.get('listenerCount', 0),
|
||||
'listener_count_text': _fmt_count(raw.get('listenerCount')),
|
||||
'liked_count': raw.get('likedCount', 0),
|
||||
'comment_count': raw.get('commentCount', 0),
|
||||
'main_song_id': main_song.get('id'),
|
||||
'main_song_name': main_song.get('name', ''),
|
||||
'radio_id': radio.get('id'),
|
||||
'radio_name': radio.get('name', ''),
|
||||
'dj_name': dj.get('nickname', ''),
|
||||
'category': raw.get('category') or radio.get('category', ''),
|
||||
}
|
||||
|
||||
|
||||
# ============ 链接 / ID 解析 ============
|
||||
|
||||
_SHORTLINK_CACHE: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _resolve_short_link(url: str) -> str:
|
||||
"""解析 163cn.tv 短链,返回跳转后的真实链接(T4:仅接受整串短链,子串不触发请求)"""
|
||||
if not re.match(r'^https?://(www\.)?163cn\.tv/\S+$', str(url or '').strip(), re.IGNORECASE):
|
||||
return url
|
||||
# 第 91 条(P3):带浏览器 UA(短链 CDN 对默认 UA 区别对待)+ 模块级缓存
|
||||
#(此前仅音乐路径有缓存,播客同一分享链接每次解析都发真实 GET)
|
||||
global _SHORTLINK_CACHE
|
||||
cached = _SHORTLINK_CACHE.get(url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.get(url, allow_redirects=False, timeout=10,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||
real = resp.headers.get('Location', url)
|
||||
except requests.RequestException:
|
||||
return url
|
||||
finally:
|
||||
if resp is not None:
|
||||
resp.close()
|
||||
# 第 99 条:与 main._expand_shortlink 同口径——展开失败(200 无 Location/5xx,
|
||||
# real==url)不写缓存,一次 CDN 抖动不再让该链接整个缓存存续期必然失败
|
||||
if real == url:
|
||||
return real
|
||||
if len(_SHORTLINK_CACHE) > 500:
|
||||
_SHORTLINK_CACHE.clear()
|
||||
_SHORTLINK_CACHE[url] = real
|
||||
return real
|
||||
|
||||
|
||||
def extract_podcast_ref(raw: str) -> Dict[str, str]:
|
||||
"""从用户输入中提取播客引用(T4:正规 URL 解析,替代子串匹配与非数字剔除)
|
||||
|
||||
Returns:
|
||||
{'kind': 'radio'|'program'|'unknown', 'id': '...'}
|
||||
- radio: djradio?id= / radio?id= 链接
|
||||
- program: program?id= / sound?id= 链接
|
||||
- unknown: 纯数字 ID(需依次尝试单集/电台)
|
||||
"""
|
||||
from urllib.parse import urlsplit, parse_qs
|
||||
text = str(raw or '').strip()
|
||||
# 短链先还原(仅当整串就是一个短链 URL)
|
||||
if '163cn.tv' in text.lower():
|
||||
text = _resolve_short_link(text)
|
||||
|
||||
# 有 URL 结构:按路径判断类型,query 正规解析 id(参数顺序无关)
|
||||
if '://' in text or text.startswith('//'):
|
||||
try:
|
||||
parts = urlsplit(text if '://' in text else 'https:' + text)
|
||||
q = parse_qs(parts.query)
|
||||
# 兼容老式 #/program?id= 片段路由
|
||||
if not q.get('id'):
|
||||
m = re.search(r'[?&#]id=(\d+)', text)
|
||||
if m:
|
||||
q = {'id': [m.group(1)]}
|
||||
pid = (q.get('id') or [''])[0]
|
||||
if pid.isdigit():
|
||||
path_l = parts.path.lower()
|
||||
if 'djradio' in path_l or 'radio' in path_l:
|
||||
return {'kind': 'radio', 'id': pid}
|
||||
if 'program' in path_l or 'sound' in path_l or 'song' in path_l:
|
||||
return {'kind': 'program', 'id': pid}
|
||||
# 路径无类型线索(如 y2027.d.popchan.cn 之类播客域名):按 unknown 走探测
|
||||
return {'kind': 'unknown', 'id': pid}
|
||||
except ValueError:
|
||||
pass
|
||||
raise APIException('无法识别的播客链接,请粘贴电台/单集链接或直接输入纯数字 ID')
|
||||
|
||||
# 纯数字 ID
|
||||
if text.isdigit():
|
||||
return {'kind': 'unknown', 'id': text}
|
||||
raise APIException('无法识别的播客链接或ID,支持 电台链接(djradio?id=)、单集链接(program?id=) 或纯数字ID')
|
||||
|
||||
|
||||
# ============ 核心 API ============
|
||||
|
||||
def search_podcast(keywords: str, cookies: Optional[Dict[str, str]] = None,
|
||||
limit: int = 20, offset: int = 0,
|
||||
search_type: str = 'all') -> Dict[str, Any]:
|
||||
"""搜索播客
|
||||
|
||||
Args:
|
||||
keywords: 关键词
|
||||
cookies: 请求 Cookie(可选)
|
||||
limit: 每类返回数量(1-50)
|
||||
offset: 偏移量
|
||||
search_type: all=电台+单集 / radio=仅电台 / voice=仅单集
|
||||
|
||||
Returns:
|
||||
{'radios': [...], 'voices': [...]}
|
||||
"""
|
||||
keywords = str(keywords or '').strip()
|
||||
if not keywords:
|
||||
raise APIException('搜索关键词不能为空')
|
||||
limit = max(1, min(int(limit or 20), 50))
|
||||
|
||||
result: Dict[str, Any] = {'radios': [], 'voices': []}
|
||||
|
||||
def _do_search(stype: int) -> Dict[str, Any]:
|
||||
return _post_json(PodcastConstants.SEARCH_API,
|
||||
{'s': keywords, 'type': stype, 'limit': limit, 'offset': offset},
|
||||
cookies)
|
||||
|
||||
if search_type in ('all', 'radio'):
|
||||
res = _do_search(1009)
|
||||
radios = (res.get('result') or {}).get('djRadios') or []
|
||||
result['radios'] = [_norm_radio(r) for r in radios]
|
||||
|
||||
if search_type in ('all', 'voice'):
|
||||
res = _do_search(2000)
|
||||
rdata = res.get('result') or {}
|
||||
voices = rdata.get('songs') or rdata.get('voices') or rdata.get('programs') or []
|
||||
result['voices'] = [_norm_program(v) for v in voices]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_radio_detail(radio_id: Any, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""获取播客电台(djradio)详情"""
|
||||
try:
|
||||
rid = int(str(radio_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的电台ID')
|
||||
result = _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies)
|
||||
# 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空
|
||||
data = result.get('djRadio') or result.get('data') or {}
|
||||
if not data:
|
||||
raise APIException('未找到该播客电台(可能已下架或ID错误)')
|
||||
return _norm_radio(data)
|
||||
|
||||
|
||||
def get_radio_programs(radio_id: Any, cookies: Optional[Dict[str, str]] = None,
|
||||
limit: int = 30, offset: int = 0, asc: bool = True) -> Dict[str, Any]:
|
||||
"""获取电台下的单集列表(分页)
|
||||
|
||||
Args:
|
||||
radio_id: 电台ID
|
||||
limit: 每页数量(1-100)
|
||||
offset: 偏移量
|
||||
asc: True=从第1集开始(正序),False=最新在前
|
||||
|
||||
Returns:
|
||||
{'programs': [...], 'count': 总数, 'has_more': bool}
|
||||
"""
|
||||
try:
|
||||
rid = int(str(radio_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的电台ID')
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
result = _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API,
|
||||
{'radioId': rid, 'limit': limit, 'offset': max(0, int(offset or 0)),
|
||||
'asc': 'true' if asc else 'false'},
|
||||
cookies)
|
||||
programs = [_norm_program(p) for p in (result.get('programs') or [])]
|
||||
count = _to_int(result.get('count'))
|
||||
offset_val = max(0, int(offset or 0))
|
||||
return {
|
||||
'programs': programs,
|
||||
'count': count,
|
||||
'has_more': (offset_val + len(programs)) < count if count else len(programs) >= limit,
|
||||
}
|
||||
|
||||
|
||||
def get_program_detail(program_id: Any, cookies: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""获取单集(program)详情"""
|
||||
try:
|
||||
pid = int(str(program_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的单集ID')
|
||||
result = _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies)
|
||||
program = result.get('program') or {}
|
||||
if not program:
|
||||
raise APIException('未找到该播客单集(可能已下架或ID错误)')
|
||||
return _norm_program(program)
|
||||
|
||||
|
||||
def get_categories() -> List[Dict[str, Any]]:
|
||||
"""获取播客分类列表(GET 接口)
|
||||
|
||||
Returns:
|
||||
[{'id': 6, 'name': '生活'}, ...]
|
||||
"""
|
||||
headers = {
|
||||
'User-Agent': APIConstants.USER_AGENT,
|
||||
'Referer': APIConstants.REFERER,
|
||||
}
|
||||
try:
|
||||
response = requests.get(PodcastConstants.DJRADIO_CATEGORY_API,
|
||||
headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||||
raise APIException('播客接口响应体异常(超过大小上限)')
|
||||
result = response.json()
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取播客分类失败: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析播客分类响应失败: {e}")
|
||||
if not isinstance(result, dict):
|
||||
# 第 97 条(P3):非 dict 响应(null/数组)显式报错,不再 AttributeError
|
||||
raise APIException('解析播客分类响应失败(非标准 JSON 对象)')
|
||||
cats = []
|
||||
for c in (result.get('categories') or []):
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
try:
|
||||
cid = int(c.get('id') or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue # 垃圾 id 跳过该条,不再拖死整页分类
|
||||
name = c.get('name')
|
||||
if cid and name:
|
||||
cats.append({'id': cid, 'name': str(name)})
|
||||
if not cats:
|
||||
raise APIException('播客分类列表为空')
|
||||
return cats
|
||||
|
||||
|
||||
def get_hot_radios(cookies: Optional[Dict[str, str]] = None,
|
||||
limit: int = 30, offset: int = 0,
|
||||
cate_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""获取分类下热门播客
|
||||
|
||||
Args:
|
||||
cate_id: 播客分类ID;F84:用户明确指定分类时只查该分类
|
||||
(即便为空也不悄悄混入其他分类的内容),未指定时按默认分类依次尝试
|
||||
|
||||
Returns:
|
||||
电台列表(已规范化)
|
||||
"""
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
cate_ids: List[int] = []
|
||||
user_cate = False
|
||||
if cate_id:
|
||||
try:
|
||||
cate_ids.append(int(cate_id))
|
||||
user_cate = True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not user_cate:
|
||||
for cid in PodcastConstants.DEFAULT_HOT_CATE_IDS:
|
||||
if cid not in cate_ids:
|
||||
cate_ids.append(cid)
|
||||
|
||||
for cid in cate_ids:
|
||||
result = _post_json(PodcastConstants.DJRADIO_HOT_API,
|
||||
{'limit': limit, 'offset': max(0, int(offset or 0)), 'cateId': cid},
|
||||
cookies)
|
||||
radios = [_norm_radio(r) for r in (result.get('djRadios') or [])]
|
||||
if radios:
|
||||
return radios
|
||||
if user_cate:
|
||||
break # F84:指定分类为空就返回空,不注入默认分类内容
|
||||
return []
|
||||
|
||||
|
||||
def resolve_episode_url(song_id: Any, cookies: Optional[Dict[str, str]] = None,
|
||||
level: str = 'auto') -> Optional[Dict[str, Any]]:
|
||||
"""解析单集音频直链(带音质降级)
|
||||
|
||||
Args:
|
||||
song_id: 单集 mainSong 的歌曲ID
|
||||
cookies: 全局账号 Cookie
|
||||
level: 期望音质(auto=按阶梯自动尝试)
|
||||
|
||||
Returns:
|
||||
{'url','level','type','size','br','md5'} 或 None(全部失败)
|
||||
"""
|
||||
# F83:降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh
|
||||
_FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard']
|
||||
order: List[str] = []
|
||||
level = str(level or 'auto').strip()
|
||||
if level and level != 'auto':
|
||||
order.append(level)
|
||||
# 追加"低于"请求音质的等级(hires > lossless > exhigh > standard)
|
||||
if level in _FALLDOWN_LADDER:
|
||||
idx = _FALLDOWN_LADDER.index(level)
|
||||
for lv in _FALLDOWN_LADDER[idx + 1:]:
|
||||
order.append(lv)
|
||||
else:
|
||||
# 未知等级:退到通用阶梯
|
||||
order.extend(PodcastConstants.QUALITY_LADDER)
|
||||
else:
|
||||
order.extend(PodcastConstants.QUALITY_LADDER)
|
||||
# 去重保序
|
||||
dedup: List[str] = []
|
||||
for lv in order:
|
||||
if lv not in dedup:
|
||||
dedup.append(lv)
|
||||
order = dedup
|
||||
|
||||
for lv in order:
|
||||
try:
|
||||
# song_id 可能是 None/垃圾值(防御:int 失败按无音频处理,不再 TypeError 500)
|
||||
sid = int(str(song_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
try:
|
||||
res = url_v1(sid, lv, cookies or {})
|
||||
except (APIException, ValueError):
|
||||
continue
|
||||
data = (res or {}).get('data') or []
|
||||
if data and data[0] and data[0].get('url'):
|
||||
d = data[0]
|
||||
return {
|
||||
'url': d.get('url'),
|
||||
'level': d.get('level') or lv,
|
||||
'type': d.get('type') or 'mp3',
|
||||
'size': _to_int(d.get('size')),
|
||||
'br': d.get('br'),
|
||||
'md5': d.get('md5', ''),
|
||||
'requested_level': lv,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
# ============ 组合能力 ============
|
||||
|
||||
def parse_podcast_ref(raw: str, cookies: Optional[Dict[str, str]] = None,
|
||||
page_size: int = 30) -> Dict[str, Any]:
|
||||
"""智能解析用户输入:电台链接 → 电台详情+单集列表;单集链接 → 单集详情
|
||||
|
||||
Returns:
|
||||
{'kind': 'radio', 'radio': {...}, 'programs': [...], 'count': n, 'has_more': bool}
|
||||
或 {'kind': 'program', 'program': {...}}
|
||||
"""
|
||||
ref = extract_podcast_ref(raw)
|
||||
|
||||
if ref['kind'] == 'radio':
|
||||
radio = get_radio_detail(ref['id'], cookies)
|
||||
page = get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True)
|
||||
return {'kind': 'radio', 'radio': radio, **page}
|
||||
|
||||
if ref['kind'] == 'program':
|
||||
return {'kind': 'program', 'program': get_program_detail(ref['id'], cookies)}
|
||||
|
||||
# 纯数字:先试单集,再试电台
|
||||
try:
|
||||
return {'kind': 'program', 'program': get_program_detail(ref['id'], cookies)}
|
||||
except APIException:
|
||||
pass
|
||||
radio = get_radio_detail(ref['id'], cookies)
|
||||
page = get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True)
|
||||
return {'kind': 'radio', 'radio': radio, **page}
|
||||
|
||||
|
||||
def list_radio_all_program_ids(radio_id: Any, cookies: Optional[Dict[str, str]] = None,
|
||||
max_count: int = 200) -> List[Dict[str, Any]]:
|
||||
"""拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}]
|
||||
|
||||
最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。
|
||||
F98:按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止,
|
||||
不再固定 offset+=100 盲目翻页。
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
seen_ids = set()
|
||||
offset = 0
|
||||
page_size = 100
|
||||
max_pages = 30 # 有界保护:即使上游 has_more 恒真也最多翻 30 页
|
||||
for _ in range(max_pages):
|
||||
page = get_radio_programs(radio_id, cookies, limit=page_size, offset=offset, asc=True)
|
||||
new_count = 0
|
||||
for p in page['programs']:
|
||||
if p['id'] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(p['id'])
|
||||
out.append({
|
||||
'id': p['id'],
|
||||
'name': p['name'],
|
||||
'main_song_id': p['main_song_id'],
|
||||
'serial_num': p['serial_num'],
|
||||
'cover_url': p['cover_url'],
|
||||
})
|
||||
new_count += 1
|
||||
if len(out) >= max_count:
|
||||
break
|
||||
if len(out) >= max_count:
|
||||
break
|
||||
# 本页无新增(重复页/短页)或上游明确说没有更多 → 停止
|
||||
if new_count == 0 or not page['has_more'] or not page['programs']:
|
||||
break
|
||||
offset += page_size
|
||||
return out
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
"""网易云音乐二维码登录模块
|
||||
|
||||
提供网易云音乐二维码登录功能,包括:
|
||||
- 二维码生成和显示
|
||||
- 登录状态检查
|
||||
- Cookie 获取和保存(直接读写 cookie.txt)
|
||||
- 用户友好的交互界面
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
import logging
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
try:
|
||||
from music_api import QRLoginManager, APIException, load_cookies
|
||||
except ImportError as e:
|
||||
print(f"导入模块失败: {e}")
|
||||
print("请确保 music_api.py 文件存在且可用")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# 判断 cookie 是否"有效":包含任一关键字段即可
|
||||
IMPORTANT_COOKIE_KEYS = ("MUSIC_U", "__csrf", "NMTID")
|
||||
|
||||
|
||||
class QRLoginClient:
|
||||
"""二维码登录客户端"""
|
||||
|
||||
def __init__(self, cookie_file: str = "cookie.txt"):
|
||||
"""
|
||||
Args:
|
||||
cookie_file: Cookie 保存文件路径
|
||||
"""
|
||||
self.cookie_file = Path(cookie_file)
|
||||
self.qr_manager = QRLoginManager()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
if not self.logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
self.logger.addHandler(handler)
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
# ----------- Cookie 文件直接操作 -----------
|
||||
|
||||
def _read_cookie(self) -> str:
|
||||
if not self.cookie_file.exists():
|
||||
return ""
|
||||
try:
|
||||
return self.cookie_file.read_text(encoding="utf-8").strip()
|
||||
except OSError as e:
|
||||
self.logger.error(f"读取 Cookie 失败: {e}")
|
||||
return ""
|
||||
|
||||
def _write_cookie(self, cookie: str) -> bool:
|
||||
"""F86:临时文件 + 原子替换写入;失败自动恢复备份,杜绝半截文件毁掉在用 Cookie。
|
||||
临时文件名带随机后缀:同进程多线程并发写同一 Cookie 文件不再互相覆盖 .tmp"""
|
||||
if not cookie or not cookie.strip():
|
||||
self.logger.error("Cookie 内容不能为空")
|
||||
return False
|
||||
backup = self._backup_cookie(suffix="prewrite")
|
||||
tmp = None
|
||||
try:
|
||||
import tempfile
|
||||
fd, tmpname = tempfile.mkstemp(
|
||||
prefix=self.cookie_file.name + '.tmp-', dir=str(self.cookie_file.parent))
|
||||
os.close(fd)
|
||||
tmp = Path(tmpname)
|
||||
tmp.write_text(cookie.strip() + "\n", encoding="utf-8")
|
||||
tmp.replace(self.cookie_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
self.logger.error(f"写入 Cookie 失败: {e}")
|
||||
# 写失败:恢复备份,保证在用文件仍是旧的有效内容
|
||||
try:
|
||||
if tmp is not None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
if backup is not None and backup.exists() and not self.cookie_file.exists():
|
||||
shutil.copy2(backup, self.cookie_file)
|
||||
return False
|
||||
|
||||
def _backup_cookie(self, suffix: str = "backup") -> Optional[Path]:
|
||||
if not self.cookie_file.exists():
|
||||
return None
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 第 91 条(P3):微秒防同秒覆盖
|
||||
backup_path = self.cookie_file.with_name(
|
||||
f"{self.cookie_file.stem}.{suffix}.{ts}{self.cookie_file.suffix}"
|
||||
)
|
||||
try:
|
||||
shutil.copy2(self.cookie_file, backup_path)
|
||||
except OSError as e:
|
||||
self.logger.warning(f"备份 Cookie 失败: {e}")
|
||||
return None
|
||||
# 备份保留上限:同一 suffix 只留最近 3 份——备份含真实 MUSIC_U,
|
||||
# 无限累积既占磁盘也会跟着部署包外泄(_mkpkg ignore 已同步排除)
|
||||
try:
|
||||
siblings = sorted(
|
||||
self.cookie_file.parent.glob(
|
||||
f"{self.cookie_file.stem}.{suffix}.*{self.cookie_file.suffix}"),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
for old in siblings[:-3]:
|
||||
old.unlink(missing_ok=True)
|
||||
# 顺手清理崩溃遗留的原子写临时文件(cookie.txt.tmp-xxxx,超过 1 小时的必是残骸)
|
||||
now = time.time()
|
||||
for stale in self.cookie_file.parent.glob(f"{self.cookie_file.name}.tmp-*"):
|
||||
try:
|
||||
if now - stale.stat().st_mtime > 3600:
|
||||
stale.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return backup_path
|
||||
|
||||
def _clear_cookie(self) -> bool:
|
||||
if not self.cookie_file.exists():
|
||||
return True
|
||||
try:
|
||||
self.cookie_file.unlink()
|
||||
return True
|
||||
except OSError as e:
|
||||
self.logger.error(f"清除 Cookie 失败: {e}")
|
||||
return False
|
||||
|
||||
def _is_cookie_valid(self) -> bool:
|
||||
# F61:登录态凭据是 MUSIC_U 且必须非空——
|
||||
# 只有 __csrf/NMTID 这类匿名 cookie 也存在时不能算"有效登录"
|
||||
cookies = load_cookies(str(self.cookie_file))
|
||||
return bool((cookies.get('MUSIC_U') or '').strip())
|
||||
|
||||
def _get_cookie_info(self) -> dict:
|
||||
cookies = load_cookies(str(self.cookie_file))
|
||||
present = [k for k in IMPORTANT_COOKIE_KEYS
|
||||
if k == 'MUSIC_U' or (cookies.get(k) or '').strip()]
|
||||
valid = bool((cookies.get('MUSIC_U') or '').strip()) # F61:以 MUSIC_U 为准
|
||||
info = {
|
||||
"file_path": str(self.cookie_file.absolute()),
|
||||
"file_exists": self.cookie_file.exists(),
|
||||
"cookie_count": len(cookies),
|
||||
"is_valid": valid,
|
||||
"important_cookies_present": present,
|
||||
"missing_important_cookies": [k for k in IMPORTANT_COOKIE_KEYS
|
||||
if not (cookies.get(k) or '').strip()],
|
||||
}
|
||||
if self.cookie_file.exists():
|
||||
try:
|
||||
mtime = self.cookie_file.stat().st_mtime
|
||||
info["last_modified"] = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except OSError:
|
||||
pass
|
||||
return info
|
||||
|
||||
# ----------- 业务方法 -----------
|
||||
|
||||
def check_existing_login(self) -> bool:
|
||||
"""检查是否已有有效登录"""
|
||||
try:
|
||||
if self._is_cookie_valid():
|
||||
self.logger.info("检测到有效的登录 Cookie")
|
||||
return True
|
||||
self.logger.info("未检测到有效的登录 Cookie")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"检查登录状态失败: {e}")
|
||||
return False
|
||||
|
||||
def interactive_login(self) -> Tuple[bool, Optional[str]]:
|
||||
"""交互式二维码登录"""
|
||||
try:
|
||||
print("\n=== 网易云音乐二维码登录 ===")
|
||||
|
||||
if self.check_existing_login():
|
||||
choice = input("检测到已有有效登录,是否重新登录?(y/N): ").strip().lower()
|
||||
if choice not in ('y', 'yes', '是'):
|
||||
print("使用现有登录状态")
|
||||
return True, None
|
||||
|
||||
print("\n开始二维码登录流程...")
|
||||
print("正在生成二维码...")
|
||||
# create_qr_login 返回 unikey 字符串(失败为 None),并已在控制台打印二维码
|
||||
qr_key = self.qr_manager.create_qr_login()
|
||||
|
||||
if not qr_key:
|
||||
error_msg = "生成二维码失败"
|
||||
self.logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
print("\n二维码已生成!")
|
||||
print("请使用网易云音乐手机 APP 扫描二维码进行登录")
|
||||
print("二维码有效期: 3 分钟")
|
||||
print("\n等待扫码中...")
|
||||
|
||||
max_attempts = 60 # 最多 5 分钟
|
||||
attempt = 0
|
||||
while attempt < max_attempts:
|
||||
try:
|
||||
# check_qr_login 返回 (状态码, cookie字典):801等待/802已扫/803成功/800过期
|
||||
code, cookie_dict = self.qr_manager.check_qr_login(qr_key)
|
||||
|
||||
if code == 803:
|
||||
musu_u = cookie_dict.get('MUSIC_U', '')
|
||||
if not musu_u:
|
||||
return False, "登录成功但未获取到 Cookie"
|
||||
# 第 97 条(P2-8):序列化带上 __csrf——第 94 条只修了 main.py
|
||||
# 的两条 web 路径,CLI 换号后全站 __csrf 恒空、weapi 发评论必败
|
||||
serialized = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;"
|
||||
csrf = cookie_dict.get('__csrf', '')
|
||||
if csrf:
|
||||
serialized += f"__csrf={csrf};"
|
||||
if self.save_cookie(serialized):
|
||||
# 注:不用 emoji——中文 Windows 控制台默认 GBK 编码不了它,
|
||||
# print 抛 UnicodeEncodeError 会把"保存成功"误报成"未知错误"
|
||||
print("\n[成功] 登录成功!Cookie 已保存")
|
||||
return True, None
|
||||
return False, "登录成功但 Cookie 保存失败"
|
||||
|
||||
if code == 802:
|
||||
print("二维码已扫描,请在手机上确认登录")
|
||||
elif code == 800:
|
||||
print("\n[过期] 二维码已过期,请重新尝试")
|
||||
return False, "二维码已过期"
|
||||
elif code != 801:
|
||||
print(f"\n登录状态异常({code})")
|
||||
|
||||
if code == 801 and attempt % 10 == 0:
|
||||
print(f"等待扫码中... ({attempt + 1}/{max_attempts})")
|
||||
|
||||
time.sleep(5)
|
||||
attempt += 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n用户取消登录")
|
||||
return False, "用户取消登录"
|
||||
except Exception as e:
|
||||
self.logger.error(f"检查登录状态时发生错误: {e}")
|
||||
time.sleep(5)
|
||||
attempt += 1
|
||||
|
||||
print("\n[超时] 登录超时,请重新尝试")
|
||||
return False, "登录超时"
|
||||
|
||||
except APIException as e:
|
||||
self.logger.error(f"API 调用失败: {e}")
|
||||
return False, f"API 调用失败: {e}"
|
||||
except Exception as e:
|
||||
self.logger.error(f"登录过程中发生未知错误: {e}")
|
||||
return False, f"登录过程中发生未知错误: {e}"
|
||||
|
||||
def save_cookie(self, cookie: str) -> bool:
|
||||
"""保存 Cookie 到文件"""
|
||||
try:
|
||||
if self.cookie_file.exists():
|
||||
backup = self._backup_cookie()
|
||||
if backup:
|
||||
self.logger.info(f"已备份现有 Cookie 到: {backup}")
|
||||
|
||||
if not self._write_cookie(cookie):
|
||||
return False
|
||||
|
||||
if self._is_cookie_valid():
|
||||
self.logger.info("Cookie 保存并验证成功")
|
||||
return True
|
||||
|
||||
self.logger.warning("Cookie 保存成功但验证失败(缺少关键字段)")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"保存 Cookie 时发生错误: {e}")
|
||||
return False
|
||||
|
||||
def show_login_info(self) -> None:
|
||||
"""显示登录信息"""
|
||||
try:
|
||||
info = self._get_cookie_info()
|
||||
print("\n=== 登录状态信息 ===")
|
||||
print(f"Cookie 文件: {info['file_path']}")
|
||||
print(f"文件存在: {'是' if info['file_exists'] else '否'}")
|
||||
print(f"Cookie 数量: {info['cookie_count']}")
|
||||
print(f"登录状态: {'有效' if info['is_valid'] else '无效'}")
|
||||
if info.get("last_modified"):
|
||||
print(f"最后更新: {info['last_modified']}")
|
||||
if info["is_valid"]:
|
||||
present = info.get("important_cookies_present", [])
|
||||
print(f"重要 Cookie: {', '.join(present)}")
|
||||
else:
|
||||
missing = info.get("missing_important_cookies", [])
|
||||
if missing:
|
||||
print(f"缺少 Cookie: {', '.join(missing)}")
|
||||
except Exception as e:
|
||||
print(f"获取登录信息失败: {e}")
|
||||
|
||||
def logout(self) -> bool:
|
||||
"""登出(清除 Cookie)"""
|
||||
try:
|
||||
if self.cookie_file.exists():
|
||||
backup = self._backup_cookie("logout")
|
||||
if backup:
|
||||
print(f"Cookie 已备份到: {backup}")
|
||||
if self._clear_cookie():
|
||||
print("已成功登出")
|
||||
return True
|
||||
print("登出失败")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"登出时发生错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
client = QRLoginClient()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
command = sys.argv[1].lower()
|
||||
|
||||
if command == 'login':
|
||||
success, error = client.interactive_login()
|
||||
if success:
|
||||
print("\n登录完成!")
|
||||
client.show_login_info()
|
||||
sys.exit(0)
|
||||
print(f"\n登录失败: {error}")
|
||||
sys.exit(1)
|
||||
|
||||
elif command in ('status', 'info'):
|
||||
client.show_login_info()
|
||||
sys.exit(0)
|
||||
|
||||
elif command == 'logout':
|
||||
sys.exit(0 if client.logout() else 1)
|
||||
|
||||
elif command in ('help', '-h', '--help'):
|
||||
print("网易云音乐二维码登录工具")
|
||||
print("\n用法: python qr_login.py [命令]")
|
||||
print("\n命令:")
|
||||
print(" login - 执行二维码登录")
|
||||
print(" status - 显示登录状态")
|
||||
print(" logout - 登出(清除 Cookie)")
|
||||
print(" help - 显示此帮助信息")
|
||||
print("\n如果不提供命令,将进入交互模式")
|
||||
sys.exit(0)
|
||||
|
||||
else:
|
||||
print(f"未知命令: {command}")
|
||||
print("使用 'python qr_login.py help' 查看帮助")
|
||||
sys.exit(1)
|
||||
|
||||
# 交互模式
|
||||
try:
|
||||
while True:
|
||||
print("\n=== 网易云音乐登录工具 ===")
|
||||
print("1. 二维码登录")
|
||||
print("2. 查看登录状态")
|
||||
print("3. 登出")
|
||||
print("4. 退出")
|
||||
|
||||
choice = input("\n请选择操作 (1-4): ").strip()
|
||||
|
||||
if choice == '1':
|
||||
success, error = client.interactive_login()
|
||||
if success:
|
||||
print("\n登录成功!")
|
||||
client.show_login_info()
|
||||
else:
|
||||
print(f"\n登录失败: {error}")
|
||||
elif choice == '2':
|
||||
client.show_login_info()
|
||||
elif choice == '3':
|
||||
client.logout()
|
||||
elif choice == '4':
|
||||
print("再见!")
|
||||
break
|
||||
else:
|
||||
print("无效选择,请重试")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n程序已退出")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\n程序运行出错: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
# F68/F62:与实测运行环境一致的依赖清单(Python 3.12 / 2026-09 实测版本)。
|
||||
# 代码实际直接依赖为以下 5 个包(其余为传递依赖,随包管理自动解析)。
|
||||
# 第 95 条(P2-6):加上界——防上游大版本静默破坏生产(Flask 3→4、Werkzeug 接口变更等)。
|
||||
# 升级前先在开发机全量验证后再放宽上界。
|
||||
# 第 102 条(owner 拍板):新增 waitress 生产服务器(未安装时 main.py 自动回退
|
||||
# Werkzeug 开发服务器,开发机可不装)。
|
||||
# 第 100 条(P2-5):cryptography 区间以开发机 venv 实装 50.0.1 为准重定——旧 <44
|
||||
# 与实测环境矛盾,新机按旧清单会装到未验证的 43.x。
|
||||
Flask>=3.0,<4.0
|
||||
requests>=2.31,<3.0
|
||||
cryptography>=50.0,<51.0
|
||||
qrcode>=7.0,<8.3
|
||||
waitress>=3.0,<4.0
|
||||
+1434
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,170 @@
|
||||
# 云村小助手 · 功能介绍
|
||||
|
||||
> 一个基于 Flask + 原生前端的云村小助手。
|
||||
> 支持搜索、单曲/歌单/专辑/歌手解析、官方榜单、个人歌单、多账号体系、批量下载、ZIP 打包、
|
||||
> 歌词逐字高亮、评论区、接口统计、隐私协议 Cookie 管理等完整功能。
|
||||
|
||||
---
|
||||
|
||||
## 一、核心功能(菜单栏 Tab)
|
||||
|
||||
| Tab | 功能 | 说明 |
|
||||
| ---- | ---- | ---- |
|
||||
| 搜索 | 多类型搜索 | 支持按 歌曲 / 歌单 / 专辑 / 歌手 搜索;结果点击直接跳转对应功能解析 |
|
||||
| 单曲 | 单曲解析 | 输入歌曲 ID / 链接,自动按账号实测最高音质解析;含封面、歌词、直链、试听播放、下载 |
|
||||
| 歌单 | 歌单解析 | 输入歌单 ID / 链接,展示歌单信息与歌曲列表(10 首/页),可逐首或批量下载 |
|
||||
| 专辑 | 专辑解析 | 输入专辑 ID / 链接,展示专辑信息与歌曲列表,可批量下载 |
|
||||
| 播客 | 播客解析 | 支持播客电台(djradio)与单集(program/sound)链接、短链或 ID 解析;在线试听、音频直链复制、单集服务器下载(可选 ZIP)、整台播客打包 ZIP;支持播客搜索与 19 个分类的热门播客卡片墙;单集列表分页加载、正序/倒序切换 |
|
||||
| 管理员设置 | 管理员功能 | 站点管理员登录(账号+密码+绑定邮箱验证码两步验证):全局账号二维码登录 / 删除 Cookie / 用户 IP 解析下载记录(分页) |
|
||||
| 全局账号歌单 | 全局歌单展示 | 展示全局账号(`cookie.txt`)收藏的歌单列表与「我喜欢的音乐」,可查看歌曲、公开播放 API 一键播放 |
|
||||
| 歌手 | 歌手解析 | 输入歌手 ID / 链接,展示歌手信息与热门歌曲,可批量下载 |
|
||||
| 榜单 | 官方榜单 | 63 个网易云官方榜单卡片墙,点击即可加载榜单歌曲 |
|
||||
| 个人歌单 | 个人账号歌单 | 游客扫码登录自己的网易云账号,查看收藏/创建的歌单(含「我喜欢的音乐」);Cookie 仅保存在本机浏览器 |
|
||||
| 关于网站 | 项目信息 | 项目介绍、功能说明、完整更新日志 |
|
||||
|
||||
## 二、账号体系
|
||||
|
||||
- **全局账号(管理员)**:`cookie.txt`,负责默认的解析与下载;可在「管理员设置」扫码登录黑胶会员后解析更高音质。
|
||||
- **个人账号(游客)**:在「个人歌单」扫码登录自己的账号,**登录信息仅存储在用户自己的电脑浏览器(localStorage)**,其他人无法访问别人的登录账号;仅用于查看个人歌单。
|
||||
- **解析账号切换**:登录个人账号后,可在个人歌单内「切换为个人账号解析」;切换后当前浏览器所有菜单的解析与下载都使用个人账号,顶部「解析账号」栏实时显示当前账号 VIP 类型(普通/VIP/SVIP)与**实测可解析音质列表**。
|
||||
|
||||
## 三、音质体系
|
||||
|
||||
- 音质等级:标准 / 极高 / 无损 / Hi-Res / 沉浸环绕声 / 高清环绕声 / 超清母带 / 杜比全景声。
|
||||
- **最高音质(自动)**:默认选项,按当前账号实测可解析列表从高到低自动尝试(要求返回音质与请求一致),保证拿到该账号能解析的最高音质。
|
||||
- **实测音质探测**:账号栏展示的可解析音质为接口实测结果(如:请求 Hi-Res 实际只能拿到无损时会如实显示),避免虚标。
|
||||
|
||||
## 四、下载能力
|
||||
|
||||
- 单曲 / 批量下载(歌单、专辑、歌手、个人歌单全部歌曲)。
|
||||
- **文件命名格式**:仅歌曲名 / 歌手 - 歌曲名 / 歌曲名 - 歌手。
|
||||
- **元数据写入**(ffmpeg):可按字段勾选写入 歌曲名/歌手/专辑/封面/歌词/发布日期,未勾选字段保持原文件值。
|
||||
- **ZIP 打包**:单曲下载可打包 音频 + 歌词 + 封面 + 信息文件;批量下载可一次打包整个歌单/专辑为 ZIP。
|
||||
- **歌词格式**:LRC / SRT(下载歌词与打包歌词通用)。
|
||||
- 下载文件保存于 `/app/downloads`(宿主机 `downloads/` 持久卷)。
|
||||
|
||||
## 五、播放器与歌词
|
||||
|
||||
- 自定义深色播放器:播放/暂停、重新播放、进度拖拽、音量/静音、播放进度记忆(续播)。
|
||||
- **SMTC**:开启后在系统媒体控制面板(Windows 锁屏/音量)显示歌曲信息并支持控制。
|
||||
- **歌词**:LRC 毫秒级解析、翻译合并、当前句**居中标蓝加粗、其他灰色**、点击跳转、KTV 逐字渐变高亮(有 YRC 逐字轴的歌曲;无逐字数据的歌曲整句点亮,不做仿逐字动画)、翻译分两行显示、手动偏移校准(±0.25s)。
|
||||
|
||||
## 六、评论区
|
||||
|
||||
- 单曲下方评论区:热门评论 + 全部评论,**10 条/页分页**,展示昵称、VIP/SVIP/音乐包标识、IP 归属地、点赞数、回复引用;可在设置中关闭。
|
||||
- **发表/回复评论**:扫码登录**个人网易云账号**后可用(140 字以内、支持回复指定评论);**仅限个人账号**——服务器全局账号在发评论/收藏等个人身份操作上零权限零入口(提交全局账号 Cookie 会被拒绝并记录审计日志)。
|
||||
|
||||
## 七、设置中心(⚙️)
|
||||
|
||||
- **外观**:浅色 / 深色 / 跟随系统 + 8 种主题色(默认蓝/红/橙/火山/青/绿/粉/紫罗兰)。
|
||||
- **下载配置**:文件命名格式、写入元数据开关、写入字段多选、ZIP 打包(各自带「保存」按钮)。
|
||||
- **批量下载配置**:写入元数据、ZIP 打包(保存按钮)。
|
||||
- **歌词**:LRC/SRT 格式、翻译分两行、KTV 逐字模式(保存按钮)。
|
||||
- **播放**:播放进度记忆。
|
||||
- **其他配置**:SMTC、显示评论区、接口请求统计(`/api/topen` 实时统计弹窗,10 秒自动刷新)、隐私与协议(Cookie 偏好管理)。
|
||||
|
||||
## 八、隐私与协议
|
||||
|
||||
- 新用户首次打开网站自动弹出「隐私与协议设置」:需勾选同意《用户协议》《隐私政策》后才能使用。
|
||||
- Cookie 类型:
|
||||
- **必要 Cookie**(始终启用):CDN 性能监控等。
|
||||
- **功能性 Cookie**(可关闭):解析与下载服务、播放进度记忆、历史记录;关闭后无法解析与下载音乐。
|
||||
- 可随时在「设置 → 隐私与协议」修改偏好或**撤回同意**。
|
||||
- 所有偏好、历史记录、个人登录 Cookie 均只存储在**用户本地浏览器**(localStorage),不上传服务器。
|
||||
|
||||
## 九、管理员功能
|
||||
|
||||
- 全局账号状态与 VIP 类型、生成登录二维码(自动保存 Cookie)、删除 Cookie(重新扫码)。
|
||||
- **用户 IP 解析/下载记录**:记录每位访问者的 解析/下载 操作(样式:`解析|下载 - IP - 歌曲名 - 时间`),支持分页查看。
|
||||
|
||||
## 十、接口请求统计
|
||||
|
||||
- 后端实时统计各真实业务接口的请求数(仅统计搜索/解析/歌单/专辑/榜单/歌手/评论/下载等业务接口,页面刷新与内部探测不计入)。
|
||||
- 面板展示:总请求数、不同端点数、最高端点请求数,以及按请求数倒序的端点明细(名称、路径、次数、百分比、最近活动),每 10 秒自动刷新。
|
||||
|
||||
## 十一、接口 API(速览)
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
| ---- | ---- | ---- |
|
||||
| `/search` | POST | 搜索(type: 1歌曲/10专辑/100歌手/1000歌单) |
|
||||
| `/song` | POST | 单曲解析(url/level/type/account/user_cookie) |
|
||||
| `/playlist` `/album` | POST | 歌单 / 专辑解析 |
|
||||
| `/toplist` | POST | 官方榜单列表 |
|
||||
| `/artist_songs` | POST | 歌手信息与热门歌曲 |
|
||||
| `/comments` | POST | 歌曲评论(id/limit/offset) |
|
||||
| `/podcast/parse` | POST | 播客解析(电台/单集链接、短链或 ID;单集附带音频直链) |
|
||||
| `/podcast/programs` | POST | 播客单集列表(radio_id/limit/offset/asc 分页) |
|
||||
| `/podcast/search` | POST | 播客搜索(keyword/type=all,radio,voice) |
|
||||
| `/podcast/hot` | POST | 热门播客与分类列表(cate_id/limit/offset) |
|
||||
| `/podcast/download` | POST | 播客单集服务器下载(id/quality/zip,返回 task_id) |
|
||||
| `/podcast/download/batch_zip` | POST | 整台播客打包 ZIP(radio_id 或 ids/limit/quality) |
|
||||
| `/download` | POST | 下载(含 filename_format/write_meta/meta_fields/lyric_format/zip) |
|
||||
| `/download/batch_zip` | POST | 批量下载并打包 ZIP |
|
||||
| `/account/vip` | POST | 当前解析账号 VIP 类型与实测可解析音质 |
|
||||
| `/api/topen` | GET/POST | 接口请求统计 |
|
||||
| `/auth/*` | POST | 站点账号系统:注册 / 登录 / 退出 / 邮箱验证码 / 免费体验 / 卡密兑换 / 会话查询;`/auth/admin/*` 为后台管理接口 |
|
||||
| `/admin/*` | POST | 管理员接口(两步验证登录后的会话鉴权):二维码登录、Cookie 管理、操作记录 |
|
||||
| `/global/playlists` | GET/POST | 全局账号歌单列表(收藏歌单 + 「我喜欢的音乐」) |
|
||||
| `/global/playlist` | GET/POST | 全局账号歌单详情(使用全局 Cookie,支持私有歌单) |
|
||||
| `/api/playlist/play` | GET/POST | 歌单播放 API:返回歌单信息 + 每首歌可播放直链(id/level/limit);**需 `&key=` 密钥**(第 78 条起强制,管理后台「播放API密钥」获取/刷新) |
|
||||
| `/user/*` | POST | 个人账号接口(Cookie 由浏览器传入 user_cookie) |
|
||||
| `/health` | GET | 健康检查 |
|
||||
|
||||
## 十二、收藏功能
|
||||
|
||||
- 登录个人账号后,歌单/专辑解析结果头部显示「♡ 收藏」按钮,个人歌单列表显示「取消收藏」。
|
||||
- 使用**网易云官方收藏接口**真实收藏到个人账号:
|
||||
- 歌单:`POST /api/playlist/subscribe`(id + t=1 收藏 / t=2 取消)
|
||||
- 专辑:`POST /api/album/sub`(id + t=1 收藏 / t=2 取消)
|
||||
- 收藏成功后个人歌单界面自动刷新,新收藏的歌单立即出现在列表。
|
||||
- ⚠️ 已知限制:网易云对「歌单订阅」接口有风控(返回 `操作过于频繁,请稍后再试`),
|
||||
数据中心/云服务器 IP 及操作频繁的账号更容易被拦(本工具在阿里云 IP 上验证时即被拦截,
|
||||
而专辑收藏不受影响)。若遇到该提示,请稍后重试或更换网络环境(家庭宽带等非数据中心 IP)后使用。
|
||||
|
||||
## 十三、账号与权限系统(站点登录 · 按次计费)
|
||||
|
||||
- **强制登录**:所有解析 / 搜索 / 下载接口需登录后使用,未登录返回 401。
|
||||
- **按次计费(仅下载)**:解析、搜索、在线试听、榜单、播客浏览**全部免费**;**下载消耗次数**——单曲下载 1 次,批量打包按歌曲数量扣减(如 100 首歌单打包 = 100 次);请求失败不扣费;管理员不限次数。
|
||||
- **未登录**:点击解析/下载时弹窗提示「解析功能需要登录后才能使用」并打开登录框(提示 5 秒内不重复弹出)。
|
||||
- **次数不足预检**:歌单 / 专辑 / 歌手 / 个人歌单 / 播客打包下载前自动检查剩余次数,不足时弹出选择框——「仅下载前 N 首(集)」或「取消下载」。
|
||||
- **次数用尽**:弹窗提示「下载次数已用尽」,支持一键跳转个人中心充值。
|
||||
- **注册**:邮箱验证码验证(需管理员先在后台配置 SMTP);**注册成功直接赠送 3 次下载次数**,无需领取;注册前须阅读并同意《用户协议》与《隐私政策》(16 秒强制阅读计时)。
|
||||
- **登录**:支持**用户名+密码**或**注册邮箱+密码**两种方式;自动识别角色——管理员登录跳转后台管理界面,普通用户跳转个人中心;会话有效期 7 天(活跃自动滚动续期,连续 7 天不访问才过期)。
|
||||
- **找回密码**:登录弹窗「忘记密码?」——通过注册邮箱验证码自助重置(10 分钟有效验证码,重置后全设备下线);管理员账号不走自助找回。
|
||||
- **顶号机制**:同一账号在新设备登录后,旧设备会话立即失效(强制下线),防止一号多卖。
|
||||
- **防倒卖封禁**:每次登录记录 IP 归属省份,**24 小时内出现 ≥3 个不同省份自动封禁**;管理员可手动封禁(填写原因展示给用户)与解封;被封账号登录即提示封禁原因。
|
||||
- **初始管理员**:新装实例在服务首次启动时于控制台**随机打印初始密码**(账号 `admin`,请立即登录后修改);新版已升级为**两步验证登录**:正确的用户名+密码 → 输入绑定邮箱 → 邮箱收取验证码 → 验证通过才可进入后台。
|
||||
- **导航栏**:右上角登录 / 注册按钮,登录后显示用户名首字母头像与剩余次数,点击进入个人中心或后台。
|
||||
- **次数卡密**:管理员后台生成次数卡(3 / 10 / 50 / 100 / 500 / 2000 次或**自定义次数**,支持前缀 / 备注 / 有效期),用户在个人中心兑换;管理员可对**未使用的卡密禁用 / 启用 / 删除**(带分页管理),已使用的卡密永久保留兑换凭证。
|
||||
|
||||
### 音频接口多重防护
|
||||
|
||||
- 所有接口返回的音频地址替换为服务器签名代理地址,**真实 CDN 直链不出现在任何响应中**。
|
||||
- 代理地址采用 **HMAC-SHA256 签名 + 36 分钟时效 + 随机 nonce**,伪造 / 过期即失效。
|
||||
- 音频流经服务器中转(支持进度拖动),浏览器不与 CDN 直连;已移除「复制 JSON」「复制直链」入口。
|
||||
|
||||
### 个人中心(普通用户)
|
||||
|
||||
- 剩余次数查看、退出登录、卡密充值、公告查看、系统版本与更新日志。
|
||||
|
||||
### 后台管理(管理员)
|
||||
|
||||
| 模块 | 功能 |
|
||||
| ---- | ---- |
|
||||
| 用户管理 | 添加用户 / 删除 / 修改剩余次数 / 重置密码 / 禁用启用 / **封禁·解封**(显示注册 IP、设备与最近登录省份)/ 查看用户网易云 Cookie,管理员账号受保护不可自锁 |
|
||||
| 全局 Cookie | 全局网易云账号扫码登录、删除 Cookie(自前端迁移至后台) |
|
||||
| 邮箱注册管理 | SMTP 服务器 / 端口(默认 587)/ 发件邮箱 / 授权码配置与发信测试 |
|
||||
| 日志管理 | 成功 / 失败日志 **10 条每页分页**查看,支持清除;记录封禁、解封、自动封禁、顶号下线、扣费明细等关键事件(不记录普通登录;注册成功因防刷号审计会记录一条) |
|
||||
| 卡密生成 | 次数卡密生成(预设 + **自定义次数**、前缀、备注、有效期)与使用状态查看 |
|
||||
| 公告设置 | 公告编辑与弹出策略(每次登录弹出 / 仅一次 / 不弹出) |
|
||||
| 数据库管理 | 定时自动备份、备份文件下载、上传与恢复 |
|
||||
| 退出登录 / 返回解析 | 快速切换后台与解析页 |
|
||||
|
||||
- **全局账号歌单**菜单仅管理员登录后可见,普通用户不可查看管理员歌单内容。
|
||||
|
||||
## 十四、Docker 部署要点
|
||||
|
||||
- 端口映射 `5001:5000`(对外 5001;勿用 6000,浏览器会拦截 `ERR_UNSAFE_PORT`)。
|
||||
- 持久卷:`cookie.txt`(全局账号)、`user_cookie.txt`(兼容)、`downloads/`(下载文件)、`data/`(站点数据库与备份)。
|
||||
- 镜像内已配置阿里云 apk/pip 源加速,并安装 tzdata(TZ=Asia/Shanghai 生效)。
|
||||
- 详细步骤见 `部署文档.md`。
|
||||
@@ -0,0 +1,190 @@
|
||||
# 云村小助手 · 部署文档(仅 Docker/Linux 轨道)
|
||||
|
||||
> **轨道定位(2026-09-25)**:本文档**只覆盖 Docker/Linux 一键部署**这一条轨道。
|
||||
> 正式部署轨道(Windows 工作站 + NSSM 服务 + PassNAT/Caddy HTTPS)以 `交接文档.md` 第 5/6 节为准——两份文档不要混用。
|
||||
> 涵盖:Docker 一键部署、站点配置、安全机制说明与常见问题。
|
||||
|
||||
---
|
||||
|
||||
## 一、环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
| ---- | ---- |
|
||||
| 操作系统 | Linux(推荐 CentOS 7+ / Ubuntu 18+ / Debian 10+) |
|
||||
| Docker | 20.10+ |
|
||||
| Docker Compose | v2.24+(`docker compose` 命令;compose 文件用了 env_file.path/required 语法,v2.24 以下请删掉 docker-compose.yml 中该两行) |
|
||||
| 内存 | ≥ 1GB(含 ffmpeg 元数据写入建议 2GB) |
|
||||
| 磁盘 | ≥ 5GB(下载文件与数据库备份) |
|
||||
| 网络 | 服务器需能访问网易 API 与外网(IP 归属地检测、音频代理) |
|
||||
|
||||
---
|
||||
|
||||
## 二、文件说明
|
||||
|
||||
```
|
||||
Netease_url/
|
||||
├── main.py # Flask 主程序(路由、计费、音频代理防护)
|
||||
├── auth_system.py # 站点账号系统(SQLite:用户/卡密/公告/日志/封禁/顶号)
|
||||
├── auth_routes.py # 认证与管理后台路由
|
||||
├── music_api.py # 网易云音乐 API 封装
|
||||
├── music_downloader.py # 下载器(ffmpeg 元数据 / ZIP 打包)
|
||||
├── podcast_api.py # 播客解析 API
|
||||
├── qr_login.py # 网易云扫码登录
|
||||
├── templates/index.html # 前端页面(原生 HTML/CSS/JS)
|
||||
├── static/site_auth.js # 前端认证 / 个人中心 / 管理后台交互
|
||||
├── requirements.txt # Python 依赖(Python 3.12)
|
||||
├── Dockerfile # 镜像构建文件
|
||||
├── docker-compose.yml # 编排配置(端口 / 持久卷)
|
||||
├── entrypoint.sh # 容器启动脚本
|
||||
├── cookie.txt # 全局网易云账号 Cookie(部署后扫码生成,勿提交)
|
||||
├── user_cookie.txt # 游客个人 Cookie(浏览器端,勿提交)
|
||||
├── data/ # 站点数据库 site.db 与备份(运行时生成)
|
||||
├── downloads/ # 下载文件目录(运行时生成)
|
||||
├── 功能介绍.md # 功能清单
|
||||
└── 部署文档.md # 本文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、快速部署(Docker Compose)
|
||||
|
||||
### 1. 上传源码
|
||||
|
||||
将整个项目目录上传至服务器,例如 `/www/wwwroot/Netease_url`。
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
项目根目录创建 `.env` 文件(也可直接使用,默认即可运行):
|
||||
|
||||
```ini
|
||||
TZ=Asia/Shanghai
|
||||
```
|
||||
|
||||
### 3. 构建并启动
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/Netease_url
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
首次构建约 3-5 分钟(已配置阿里云加速源)。启动后检查:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:5001/health
|
||||
# 返回 {"status":200,...} 即部署成功
|
||||
```
|
||||
|
||||
### 4. 访问
|
||||
|
||||
浏览器打开 `http://服务器IP:5001`(对外端口 5001,容器内 5000;勿改用 6000——浏览器会拦截 `ERR_UNSAFE_PORT`)。
|
||||
|
||||
---
|
||||
|
||||
## 四、初始配置(必读)
|
||||
|
||||
### 1. 管理员账号
|
||||
|
||||
新装实例的服务**首次启动时会在控制台随机打印初始管理员密码**(账号 `admin`,请翻阅启动日志获取)。
|
||||
登录后自动进入后台 → 请立即在「用户管理」中**重置密码**。
|
||||
**注意:管理员账号已升级两步验证登录(2026-09-24)**——输入正确的用户名+密码后,还需输入管理员绑定的邮箱并通过邮箱验证码验证,才能进入后台;请先在数据库或后台为管理员账号绑定可用邮箱。
|
||||
|
||||
### 2. 网易云全局账号
|
||||
|
||||
后台 →「全局 Cookie」→ 生成二维码 → 用网易云 App 扫码登录。
|
||||
全局账号负责默认解析与下载,黑胶/SVIP 账号可解析更高音质。
|
||||
|
||||
### 3. SMTP 邮箱(开启用户注册必须配置)
|
||||
|
||||
后台 →「邮箱注册管理」→ 填写:
|
||||
|
||||
| 项 | 说明(以 QQ 邮箱为例) |
|
||||
| ---- | ---- |
|
||||
| SMTP 服务器 | `smtp.qq.com` |
|
||||
| 端口 | `587` |
|
||||
| 发件邮箱 | 你的 QQ 邮箱 |
|
||||
| 邮箱密码/授权码 | QQ 邮箱「设置 → 账户 → 开启 SMTP」获取的**授权码**(非 QQ 密码) |
|
||||
|
||||
保存后用「发送测试邮件」验证。
|
||||
|
||||
### 4. 公告与卡密
|
||||
|
||||
- 后台 →「公告设置」:编辑公告内容与弹出策略(每次登录弹出 / 仅一次 / 不弹出)。
|
||||
- 后台 →「次数卡密」:生成次数卡(张数、类型或自定义次数、前缀、备注、有效天数),用户在个人中心兑换充值。
|
||||
|
||||
---
|
||||
|
||||
## 五、站点机制说明
|
||||
|
||||
| 机制 | 说明 |
|
||||
| ---- | ---- |
|
||||
| 强制登录 | 所有解析 / 下载接口需登录;未登录弹窗引导注册/登录 |
|
||||
| 按次计费 | 解析 / 试听 / 浏览免费;**下载扣次数**:单曲 1 次,打包按歌曲数(100 首歌单 = 100 次);失败不扣费;管理员不限 |
|
||||
| 注册赠送 | 注册成功自动赠送 3 次下载次数 |
|
||||
| 批量预检 | 打包下载前检查次数,不足可选择「仅下载前 N 首(集)」或取消 |
|
||||
| 顶号机制 | 同一账号新设备登录后旧会话立即失效 |
|
||||
| 防倒卖封禁 | 登录记录 IP 归属省份,24h 内 ≥3 个不同省份自动封禁;管理员可手动封禁 / 解封 |
|
||||
| 音频防护 | 音频直链经服务器签名代理(HMAC + 36 分钟时效),真实 CDN 链接不暴露 |
|
||||
| 日志审计 | 成功 / 失败日志 10 条每页分页,可清除;记录封禁、顶号、扣费等关键事件(不含普通登录注册) |
|
||||
|
||||
---
|
||||
|
||||
## 六、数据持久化
|
||||
|
||||
`docker-compose.yml` 已配置挂载,**重建容器数据不丢失**:
|
||||
|
||||
| 宿主机路径 | 容器路径 | 内容 |
|
||||
| ---- | ---- | ---- |
|
||||
| `./cookie.txt` | `/app/cookie.txt` | 全局网易云 Cookie |
|
||||
| `./user_cookie.txt` | `/app/user_cookie.txt` | 游客个人 Cookie(兼容) |
|
||||
| `./downloads/` | `/app/downloads` | 下载文件 |
|
||||
| `./data/` | `/app/data` | 站点数据库 `site.db`(用户/卡密/日志/公告)与自动备份 |
|
||||
|
||||
> 建议定期在 后台 →「数据库管理」下载备份;更换服务器时将整个目录拷贝即可完整迁移。
|
||||
|
||||
---
|
||||
|
||||
## 七、常用运维命令
|
||||
|
||||
```bash
|
||||
# 启动 / 重建(代码更新后)
|
||||
docker compose up -d --build
|
||||
|
||||
# 查看运行日志
|
||||
docker logs -f netease_url-netease-url-1
|
||||
|
||||
# 重启
|
||||
docker compose restart
|
||||
|
||||
# 停止
|
||||
docker compose down # 数据保留(挂载卷)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
**Q1:端口被占用(5001 already allocated)**
|
||||
`docker ps` 查看占用容器并 `docker rm -f 容器名`,或修改 `docker-compose.yml` 端口映射。
|
||||
|
||||
**Q2:页面样式/功能异常**
|
||||
浏览器 **Ctrl+F5** 强制刷新缓存(前端资源带版本号,一般自动生效)。
|
||||
|
||||
**Q3:收不到邮箱验证码**
|
||||
检查 SMTP 配置与授权码;确认服务器出站 587 端口可用;查看失败日志定位报错。
|
||||
|
||||
**Q4:用户反馈「已被封禁」**
|
||||
后台 →「用户管理」查看该用户最近登录省份与封禁原因,确认无误后可解封。
|
||||
|
||||
**Q5:忘记管理员密码**
|
||||
```bash
|
||||
docker exec -it netease_url-netease-url-1 python3 -c "
|
||||
import auth_system as a
|
||||
# 若实例管理员已由 admin 更名(如 qfbsh),请把 'admin' 替换为实际用户名
|
||||
a.set_user_password(a.get_user_by_name('qfbsh')['id'], '新密码至少8位')
|
||||
print('已重置')"
|
||||
```
|
||||
|
||||
**Q6:数据库损坏 / 恢复**
|
||||
后台 →「数据库管理」→ 上传历史备份文件即可覆盖恢复。
|
||||
|
||||
---
|
||||
Reference in new issue
Block a user