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:
snowy-yang committed 2026-09-26 00:13:05 +08:00
commit d209af847a
21 files changed
+20816

No files matched your search

+44
View File
@@ -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/
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
⚠️ 私有部署包——严禁外传、严禁上传网盘/代码仓库/发给任何人。
本包包含全部敏感数据:cookie.txt(网易云 SVIP 登录态)、data/site.db(用户库)、
data/secret.key(会话与音频签名根)、交接文档.md(含管理员凭据)。
泄露后果与应急流程见交接文档 §4/§5.7 A2/C1/C2/E4。
(本文件由 _mkpkg.py 私有模式自动生成;对外分发包由 --public 生成并剔除全部敏感物。)
+10
View File
@@ -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')
+99
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1785
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -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)
+13
View File
@@ -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
+4670
View File
File diff suppressed because it is too large. Load diff
+1468
View File
File diff suppressed because it is too large. Load diff
+1173
View File
File diff suppressed because it is too large. Load diff
+554
View File
@@ -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
View File
@@ -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()
+13
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+7656
View File
File diff suppressed because it is too large. Load diff
+170
View File
@@ -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`。
+190
View File
@@ -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:数据库损坏 / 恢复**
后台 →「数据库管理」→ 上传历史备份文件即可覆盖恢复。
---