From fbd2c491b2af6ed8c54e8bbe12258884f9c760e2 Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Sun, 30 Nov 2025 19:49:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 29 +- CLAUDE.md | 16 + app.py | 4 + database/api_key_tables.sql | 35 ++ database/new_platforms.sql | 23 + database/redeem_codes.sql | 37 ++ models/__init__.py | 70 +++ parsers/base.py | 5 +- parsers/bilibili.py | 9 +- parsers/douyin.py | 16 +- parsers/factory.py | 66 +++ parsers/kuaishou.py | 89 ++++ parsers/pipixia.py | 91 ++++ parsers/tiktok.py | 15 +- parsers/weibo.py | 105 ++++ routes/admin.py | 286 +++++++++- routes/api_v1.py | 212 ++++++++ routes/apikey.py | 174 ++++++ routes/auth.py | 85 ++- routes/main.py | 112 +++- routes/parser.py | 7 + static/css/ui-components.css | 129 ++++- static/js/ui-components.js | 119 +++++ templates/admin_apis.html | 4 + templates/admin_config.html | 1 + templates/admin_dashboard.html | 1 + templates/admin_health_checks.html | 4 + templates/admin_logs.html | 4 + templates/admin_profile.html | 1 + templates/admin_redeem_codes.html | 521 ++++++++++++++++++ templates/admin_smtp.html | 1 + templates/admin_users.html | 52 +- templates/api_docs.html | 557 ++++++++++++++++++++ templates/apikey/manage.html | 465 ++++++++++++++++ templates/index.html | 221 +++++++- templates/profile.html | 367 +++++++++++++ utils/api_auth.py | 104 ++++ 开发记录/2025-11-30-会话记录.md | 99 ++++ 解析接口/微博解析.postman_collection.json | 71 +++ 解析接口/快手解析.postman_collection.json | 97 ++++ 解析接口/皮皮虾解析.postman_collection.json | 65 +++ 41 files changed, 4293 insertions(+), 76 deletions(-) create mode 100644 database/api_key_tables.sql create mode 100644 database/new_platforms.sql create mode 100644 database/redeem_codes.sql create mode 100644 parsers/kuaishou.py create mode 100644 parsers/pipixia.py create mode 100644 parsers/weibo.py create mode 100644 routes/api_v1.py create mode 100644 routes/apikey.py create mode 100644 templates/admin_redeem_codes.html create mode 100644 templates/api_docs.html create mode 100644 templates/apikey/manage.html create mode 100644 utils/api_auth.py create mode 100644 开发记录/2025-11-30-会话记录.md create mode 100644 解析接口/微博解析.postman_collection.json create mode 100644 解析接口/快手解析.postman_collection.json create mode 100644 解析接口/皮皮虾解析.postman_collection.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ab891c2..edd49e5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,31 +1,10 @@ { "permissions": { "allow": [ - "mcp__chrome-devtools__list_pages", - "mcp__chrome-devtools__navigate_page", - "mcp__chrome-devtools__take_snapshot", - "mcp__chrome-devtools__fill", - "mcp__chrome-devtools__click", - "mcp__chrome-devtools__list_console_messages", - "mcp__chrome-devtools__list_network_requests", - "mcp__chrome-devtools__get_console_message", - "mcp__chrome-devtools__get_network_request", - "Bash(python:*)", - "mcp__chrome-devtools__fill_form", - "mcp__chrome-devtools__handle_dialog", - "Bash(grep:*)", - "mcp__sequential-thinking__sequentialthinking", - "mcp__chrome-devtools__wait_for", - "Bash(curl:*)", - "Bash(mysql:*)", - "Bash(dir:*)", - "WebSearch", - "Bash(findstr:*)", - "Bash(cmd /c \"del NUL\")", - "Bash(find:*)", - "Bash(for file in *.html)", - "Bash(do sed -i 's/ui-components.css?v=2/ui-components.css?v=3/g' \"$file\")", - "Bash(done)" + "Bash(python -m py_compile:*)", + "Bash(curl -s \"https://api.bugpk.com/api/kuaishou?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx\")", + "Bash(curl -s \"https://api.bugpk.com/api/pipixia?url=https://h5.pipix.com/s/kLUS2a4iJ_M/\")", + "Bash(curl:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index d3e1fb1..0174282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,6 +173,22 @@ redis-server.exe redis.windows.conf - 站点配置(Logo、标题、公告等)存储在 `site_configs` 表中 - 首次运行前必须执行 `init_admin.py` 和 `init_data.py` 初始化数据 +## 解析请求流程 + +1. 用户提交视频URL → `routes/parser.py:parse_video()` +2. 限流检查 → `utils/limiter.py:RateLimiter.check_limit()` +3. 平台检测 → `parsers/factory.py:ParserFactory.detect_platform()` +4. 并发检查 → `utils/queue.py:ConcurrencyController.can_process()` + - 可处理:立即执行 `_process_task()` + - 队列满:返回 `task_id`,前端轮询 `/api/task/` +5. 解析执行 → 遍历该平台所有启用的API,依次尝试直到成功(failover机制) +6. 结果存储 → Redis(1小时过期)或内存队列 + +**API Failover 机制**(`routes/parser.py:110-170`): +- 按顺序尝试所有启用的API(不考虑健康状态) +- 任一成功即返回,失败则继续下一个 +- 全部失败才返回错误 + ## 访问地址 - 前台首页:`http://localhost:5000` diff --git a/app.py b/app.py index 287525c..c14ea63 100644 --- a/app.py +++ b/app.py @@ -47,11 +47,15 @@ def create_app(): from routes.parser import parser_bp from routes.admin import admin_bp from routes.main import main_bp + from routes.api_v1 import api_v1_bp + from routes.apikey import apikey_bp app.register_blueprint(auth_bp, url_prefix='/auth') app.register_blueprint(parser_bp, url_prefix='/api') app.register_blueprint(admin_bp, url_prefix='/admin') app.register_blueprint(main_bp) + app.register_blueprint(api_v1_bp) # /api/v1/* + app.register_blueprint(apikey_bp) # /user/apikey/* # 初始化定时任务(仅在非调试模式或主进程中启动) # 注意:初始化脚本运行时不启动调度器 diff --git a/database/api_key_tables.sql b/database/api_key_tables.sql new file mode 100644 index 0000000..57530cc --- /dev/null +++ b/database/api_key_tables.sql @@ -0,0 +1,35 @@ +-- API Key 相关表结构 +-- 执行方式: mysql -u root -p video_parser < database/api_key_tables.sql + +-- 用户 API Key 表 +CREATE TABLE IF NOT EXISTS `user_api_keys` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `user_id` INT NOT NULL, + `name` VARCHAR(100) NOT NULL COMMENT 'Key名称', + `api_key` VARCHAR(64) NOT NULL UNIQUE COMMENT 'API Key', + `is_active` TINYINT(1) DEFAULT 1 COMMENT '是否启用', + `daily_limit` INT DEFAULT 100 COMMENT '每日调用限制', + `total_calls` INT DEFAULT 0 COMMENT '总调用次数', + `last_used_at` DATETIME COMMENT '最后使用时间', + `last_used_ip` VARCHAR(45) COMMENT '最后使用IP', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + INDEX `idx_api_key` (`api_key`), + INDEX `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户API Key表'; + +-- API Key 每日统计表 +CREATE TABLE IF NOT EXISTS `api_key_daily_stats` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `api_key_id` INT NOT NULL, + `date` DATE NOT NULL, + `call_count` INT DEFAULT 0 COMMENT '调用次数', + `success_count` INT DEFAULT 0 COMMENT '成功次数', + `fail_count` INT DEFAULT 0 COMMENT '失败次数', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`api_key_id`) REFERENCES `user_api_keys`(`id`) ON DELETE CASCADE, + UNIQUE KEY `uk_api_key_date` (`api_key_id`, `date`), + INDEX `idx_date` (`date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='API Key每日统计表'; diff --git a/database/new_platforms.sql b/database/new_platforms.sql new file mode 100644 index 0000000..038726b --- /dev/null +++ b/database/new_platforms.sql @@ -0,0 +1,23 @@ +-- 新平台解析接口初始化数据 +-- 执行方式: mysql -u root -p video_parser < database/new_platforms.sql + +-- 快手解析接口 +INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES +('BugPK快手API', 'kuaishou', 'https://api.bugpk.com', '', 1, 1, 1), +('优创快手API', 'kuaishou', 'http://apis.uctb.cn', '', 1, 1, 1); + +-- 皮皮虾解析接口 +INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES +('BugPK皮皮虾API', 'pipixia', 'https://api.bugpk.com', '', 1, 1, 1), +('优创皮皮虾API', 'pipixia', 'http://apis.uctb.cn', '', 1, 1, 1); + +-- 微博解析接口 +INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES +('优创微博API', 'weibo', 'http://apis.uctb.cn', '', 1, 1, 1), +('妖狐微博API', 'weibo', 'https://api.yaohud.cn', 'SM227DLC0ZgJ6DXJhAx', 1, 1, 1); + +-- 健康检查配置 +INSERT INTO `health_check_config` (`platform`, `test_url`, `check_interval`, `is_enabled`) VALUES +('kuaishou', 'https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx', 300, 1), +('pipixia', 'https://h5.pipix.com/s/kLUS2a4iJ_M/', 300, 1), +('weibo', 'https://video.weibo.com/show?fid=1034:5233299304415254', 300, 1); diff --git a/database/redeem_codes.sql b/database/redeem_codes.sql new file mode 100644 index 0000000..a5ac3ca --- /dev/null +++ b/database/redeem_codes.sql @@ -0,0 +1,37 @@ +-- 兑换码功能数据库表 +-- 执行方式: mysql -u root -p video_parser < database/redeem_codes.sql + +-- 兑换码表 +CREATE TABLE IF NOT EXISTS `redeem_codes` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `code` VARCHAR(32) NOT NULL UNIQUE COMMENT '兑换码', + `batch_id` VARCHAR(32) COMMENT '批次ID', + `target_group_id` INT NOT NULL COMMENT '目标用户组ID', + `duration_days` INT DEFAULT 30 COMMENT '有效期天数', + `is_used` TINYINT(1) DEFAULT 0 COMMENT '是否已使用', + `used_by` INT COMMENT '使用者用户ID', + `used_at` DATETIME COMMENT '使用时间', + `expires_at` DATETIME COMMENT '兑换码过期时间', + `remark` VARCHAR(255) COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`target_group_id`) REFERENCES `user_groups`(`id`), + FOREIGN KEY (`used_by`) REFERENCES `users`(`id`), + INDEX `idx_code` (`code`), + INDEX `idx_batch_id` (`batch_id`), + INDEX `idx_is_used` (`is_used`), + INDEX `idx_expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='兑换码表'; + +-- 用户组到期时间表 +CREATE TABLE IF NOT EXISTS `user_group_expiry` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `user_id` INT NOT NULL UNIQUE COMMENT '用户ID', + `group_id` INT NOT NULL COMMENT '用户组ID', + `expires_at` DATETIME NOT NULL COMMENT '到期时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`), + FOREIGN KEY (`group_id`) REFERENCES `user_groups`(`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_expires_at` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户组到期时间表'; diff --git a/models/__init__.py b/models/__init__.py index 54146e6..9c7c022 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -159,3 +159,73 @@ class HealthCheckLog(db.Model): response_time = db.Column(db.Integer) error_message = db.Column(db.Text) checked_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class UserApiKey(db.Model): + """用户 API Key""" + __tablename__ = 'user_api_keys' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + name = db.Column(db.String(100), nullable=False) # Key 名称,方便用户区分 + api_key = db.Column(db.String(64), unique=True, nullable=False) # API Key + is_active = db.Column(db.Boolean, default=True) + daily_limit = db.Column(db.Integer, default=100) # 每日调用限制 + total_calls = db.Column(db.Integer, default=0) # 总调用次数 + last_used_at = db.Column(db.DateTime) # 最后使用时间 + last_used_ip = db.Column(db.String(45)) # 最后使用IP + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = db.relationship('User', backref='api_keys') + + +class ApiKeyDailyStat(db.Model): + """API Key 每日统计""" + __tablename__ = 'api_key_daily_stats' + + id = db.Column(db.Integer, primary_key=True) + api_key_id = db.Column(db.Integer, db.ForeignKey('user_api_keys.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + call_count = db.Column(db.Integer, default=0) + success_count = db.Column(db.Integer, default=0) + fail_count = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + api_key = db.relationship('UserApiKey', backref='daily_stats') + + +class RedeemCode(db.Model): + """兑换码""" + __tablename__ = 'redeem_codes' + + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(32), unique=True, nullable=False) # 兑换码 + batch_id = db.Column(db.String(32)) # 批次ID,用于批量管理 + target_group_id = db.Column(db.Integer, db.ForeignKey('user_groups.id'), nullable=False) # 兑换后的用户组 + duration_days = db.Column(db.Integer, default=30) # 有效期天数 + is_used = db.Column(db.Boolean, default=False) # 是否已使用 + used_by = db.Column(db.Integer, db.ForeignKey('users.id')) # 使用者 + used_at = db.Column(db.DateTime) # 使用时间 + expires_at = db.Column(db.DateTime) # 兑换码过期时间 + remark = db.Column(db.String(255)) # 备注 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + target_group = db.relationship('UserGroup', backref='redeem_codes') + user = db.relationship('User', backref='redeemed_codes') + + +class UserGroupExpiry(db.Model): + """用户组到期时间记录""" + __tablename__ = 'user_group_expiry' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, unique=True) + group_id = db.Column(db.Integer, db.ForeignKey('user_groups.id'), nullable=False) + expires_at = db.Column(db.DateTime, nullable=False) # 到期时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = db.relationship('User', backref='group_expiry') + group = db.relationship('UserGroup') diff --git a/parsers/base.py b/parsers/base.py index cabd9da..c60c53c 100644 --- a/parsers/base.py +++ b/parsers/base.py @@ -44,11 +44,12 @@ class BaseParser(ABC): except requests.RequestException as e: raise Exception(f"请求失败: {str(e)}") - def _normalize_response(self, cover: str, video_url: str, title: str, description: str) -> Dict: + def _normalize_response(self, cover: str, video_url: str, title: str, description: str, author: str = "") -> Dict: """标准化返回数据""" return { "cover": cover or "", "video_url": video_url or "", "title": title or "", - "description": description or "" + "description": description or "", + "author": author or "" } diff --git a/parsers/bilibili.py b/parsers/bilibili.py index 44f516b..83492c1 100644 --- a/parsers/bilibili.py +++ b/parsers/bilibili.py @@ -32,8 +32,9 @@ class BilibiliMirParser(BaseParser): video_url = video_data.get("url", "") or video_data.get("video_url", "") title = video_data.get("title", "") description = video_data.get("desc", "") or video_data.get("description", "") + author = video_data.get("author", "") or video_data.get("owner", {}).get("name", "") - return self._normalize_response(cover, video_url, title, description) + return self._normalize_response(cover, video_url, title, description, author) else: raise Exception(f"解析失败: {data.get('msg', '未知错误')}") except Exception as e: @@ -70,8 +71,9 @@ class BilibiliBugPKParser(BaseParser): video_url = video_data.get("url", "") or video_data.get("video_url", "") title = video_data.get("title", "") description = video_data.get("desc", "") or video_data.get("description", "") + author = video_data.get("author", "") or video_data.get("owner", {}).get("name", "") - return self._normalize_response(cover, video_url, title, description) + return self._normalize_response(cover, video_url, title, description, author) else: raise Exception(f"解析失败: {data.get('msg', '未知错误')}") except Exception as e: @@ -105,6 +107,7 @@ class BilibiliYaohuParser(BaseParser): cover = basic.get("cover", "") title = basic.get("title", "") description = basic.get("description", "") + author = basic.get("author", "") or basic.get("owner", {}).get("name", "") # 提取视频URL - 优先使用data.video_url,其次使用videos[0].url video_url = video_data.get("video_url", "") @@ -113,7 +116,7 @@ class BilibiliYaohuParser(BaseParser): if isinstance(videos, list) and videos: video_url = videos[0].get("url", "") - return self._normalize_response(cover, video_url, title, description) + return self._normalize_response(cover, video_url, title, description, author) else: raise Exception(f"解析失败: 不支持的类型 {data.get('parse_type')}") except Exception as e: diff --git a/parsers/douyin.py b/parsers/douyin.py index 2690314..cc6fbd1 100644 --- a/parsers/douyin.py +++ b/parsers/douyin.py @@ -57,15 +57,15 @@ class DouyinParser(BaseParser): # 提取标题(描述) title = video_info.get("desc", "") - # 提取作者信息作为简介 - author = video_info.get("author", {}) - author_name = author.get("nickname", "") - author_signature = author.get("signature", "") - description = f"作者: {author_name}" - if author_signature: - description += f" | {author_signature}" + # 提取作者信息 + author_info = video_info.get("author", {}) + author_name = author_info.get("nickname", "") + author_signature = author_info.get("signature", "") - return self._normalize_response(cover, video_url, title, description) + # 简介使用作者签名 + description = author_signature or "" + + return self._normalize_response(cover, video_url, title, description, author_name) except Exception as e: raise Exception(f"数据提取失败: {str(e)}") diff --git a/parsers/factory.py b/parsers/factory.py index eb6c19a..c0da8cf 100644 --- a/parsers/factory.py +++ b/parsers/factory.py @@ -1,12 +1,48 @@ from parsers.douyin import DouyinParser from parsers.tiktok import TikTokParser from parsers.bilibili import BilibiliMirParser, BilibiliBugPKParser, BilibiliYaohuParser +from parsers.kuaishou import KuaishouBugPKParser, KuaishouUctbParser +from parsers.pipixia import PipixiaBugPKParser, PipixiaUctbParser +from parsers.weibo import WeiboUctbParser, WeiboYaohuParser from models import ParserAPI import random +import requests class ParserFactory: """解析器工厂类""" + @staticmethod + def expand_short_url(url: str) -> str: + """展开短链接,获取真实URL""" + short_domains = ['b23.tv', 'v.douyin.com', 't.cn'] + + # 检查是否是短链接 + is_short = any(domain in url.lower() for domain in short_domains) + if not is_short: + return url + + try: + # 发送请求,不跟随重定向,获取 Location 头 + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + response = requests.head(url, headers=headers, allow_redirects=False, timeout=10) + + if response.status_code in [301, 302, 303, 307, 308]: + location = response.headers.get('Location', '') + if location: + # 如果还是短链接,继续展开 + if any(domain in location.lower() for domain in short_domains): + return ParserFactory.expand_short_url(location) + return location + + # 如果 HEAD 请求不行,尝试 GET 请求跟随重定向 + response = requests.get(url, headers=headers, allow_redirects=True, timeout=10) + return response.url + except Exception: + # 展开失败,返回原URL + return url + @staticmethod def create_parser(api_config: ParserAPI): """根据API配置创建解析器实例""" @@ -28,6 +64,30 @@ class ParserFactory: return BilibiliYaohuParser(api_url, api_key) else: return BilibiliMirParser(api_url, api_key) + elif platform == 'kuaishou': + # 快手解析器 + if 'bugpk' in api_url: + return KuaishouBugPKParser(api_url, api_key) + elif 'uctb' in api_url: + return KuaishouUctbParser(api_url, api_key) + else: + return KuaishouBugPKParser(api_url, api_key) + elif platform == 'pipixia': + # 皮皮虾解析器 + if 'bugpk' in api_url: + return PipixiaBugPKParser(api_url, api_key) + elif 'uctb' in api_url: + return PipixiaUctbParser(api_url, api_key) + else: + return PipixiaBugPKParser(api_url, api_key) + elif platform == 'weibo': + # 微博解析器 + if 'uctb' in api_url: + return WeiboUctbParser(api_url, api_key) + elif 'yaohud' in api_url: + return WeiboYaohuParser(api_url, api_key) + else: + return WeiboUctbParser(api_url, api_key) else: raise ValueError(f"不支持的平台: {platform}") @@ -83,5 +143,11 @@ class ParserFactory: return 'tiktok' elif 'bilibili.com' in url_lower or 'b23.tv' in url_lower: return 'bilibili' + elif 'kuaishou.com' in url_lower or 'gifshow.com' in url_lower: + return 'kuaishou' + elif 'pipix.com' in url_lower or 'pipixia.com' in url_lower or 'h5.pipix.com' in url_lower: + return 'pipixia' + elif 'weibo.com' in url_lower or 'weibo.cn' in url_lower: + return 'weibo' else: raise ValueError("无法识别的视频平台") diff --git a/parsers/kuaishou.py b/parsers/kuaishou.py new file mode 100644 index 0000000..0b254ea --- /dev/null +++ b/parsers/kuaishou.py @@ -0,0 +1,89 @@ +from parsers.base import BaseParser +from typing import Dict +from urllib.parse import urlencode + + +class KuaishouBugPKParser(BaseParser): + """快手解析器 - BugPK API""" + + def parse(self, video_url: str) -> Dict: + """解析快手视频""" + try: + url = f"{self.api_url}/api/kuaishou?{urlencode({'url': video_url})}" + response = self._make_request(url) + data = response.json() + return self._extract_data(data) + except Exception as e: + raise Exception(f"快手解析失败(BugPK API): {str(e)}") + + def _extract_data(self, data: Dict) -> Dict: + """提取并标准化数据 + 实际返回格式: + { + "code": 200, + "msg": "解析成功", + "data": { + "title": "视频标题", + "cover": "封面URL", + "url": "视频URL" + } + } + """ + try: + if data.get("code") == 200: + video_data = data.get("data", {}) + + cover = video_data.get("cover", "") + video_url = video_data.get("url", "") + title = video_data.get("title", "") + description = "" # BugPK API 不返回简介 + author = "" # BugPK API 不返回作者 + + return self._normalize_response(cover, video_url, title, description, author) + else: + raise Exception(f"解析失败: {data.get('msg', '未知错误')}") + except Exception as e: + raise Exception(f"数据提取失败: {str(e)}") + + +class KuaishouUctbParser(BaseParser): + """快手解析器 - 优创 API""" + + def parse(self, video_url: str) -> Dict: + """解析快手视频""" + try: + url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}" + response = self._make_request(url) + data = response.json() + return self._extract_data(data) + except Exception as e: + raise Exception(f"快手解析失败(优创 API): {str(e)}") + + def _extract_data(self, data: Dict) -> Dict: + """提取并标准化数据 + 实际返回格式: + { + "code": 200, + "data": { + "desc": "视频描述", + "cover": "封面URL", + "playurl": "视频URL" + }, + "msg": "请求成功" + } + """ + try: + if data.get("code") == 200: + video_data = data.get("data", {}) + + cover = video_data.get("cover", "") + video_url = video_data.get("playurl", "") + title = video_data.get("desc", "") + description = "" + author = "" # 优创API不返回作者 + + return self._normalize_response(cover, video_url, title, description, author) + else: + raise Exception(f"解析失败: {data.get('msg', '未知错误')}") + except Exception as e: + raise Exception(f"数据提取失败: {str(e)}") diff --git a/parsers/pipixia.py b/parsers/pipixia.py new file mode 100644 index 0000000..cb5b145 --- /dev/null +++ b/parsers/pipixia.py @@ -0,0 +1,91 @@ +from parsers.base import BaseParser +from typing import Dict +from urllib.parse import urlencode + + +class PipixiaBugPKParser(BaseParser): + """皮皮虾解析器 - BugPK API""" + + def parse(self, video_url: str) -> Dict: + """解析皮皮虾视频""" + try: + url = f"{self.api_url}/api/pipixia?{urlencode({'url': video_url})}" + response = self._make_request(url) + data = response.json() + return self._extract_data(data) + except Exception as e: + raise Exception(f"皮皮虾解析失败(BugPK API): {str(e)}") + + def _extract_data(self, data: Dict) -> Dict: + """提取并标准化数据 + 实际返回格式: + { + "code": 200, + "msg": "解析成功", + "data": { + "author": "作者名", + "avatar": "头像URL", + "title": "标题", + "cover": "封面URL", + "url": "视频URL" + } + } + """ + try: + if data.get("code") == 200: + video_data = data.get("data", {}) + + cover = video_data.get("cover", "") + video_url = video_data.get("url", "") + title = video_data.get("title", "") + description = "" + author = video_data.get("author", "") + + return self._normalize_response(cover, video_url, title, description, author) + else: + raise Exception(f"解析失败: {data.get('msg', '未知错误')}") + except Exception as e: + raise Exception(f"数据提取失败: {str(e)}") + + +class PipixiaUctbParser(BaseParser): + """皮皮虾解析器 - 优创 API""" + + def parse(self, video_url: str) -> Dict: + """解析皮皮虾视频""" + try: + url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}" + response = self._make_request(url) + data = response.json() + return self._extract_data(data) + except Exception as e: + raise Exception(f"皮皮虾解析失败(优创 API): {str(e)}") + + def _extract_data(self, data: Dict) -> Dict: + """提取并标准化数据 + 实际返回格式: + { + "code": 200, + "data": { + "desc": null, + "cover": "封面URL", + "playurl": "视频URL" + }, + "msg": "请求成功" + } + """ + try: + if data.get("code") == 200: + video_data = data.get("data", {}) + + cover = video_data.get("cover", "") + video_url = video_data.get("playurl", "") + title = video_data.get("desc", "") or "" + description = "" + author = "" # 优创API不返回作者 + + return self._normalize_response(cover, video_url, title, description, author) + else: + raise Exception(f"解析失败: {data.get('msg', '未知错误')}") + except Exception as e: + raise Exception(f"数据提取失败: {str(e)}") diff --git a/parsers/tiktok.py b/parsers/tiktok.py index 175574e..2754962 100644 --- a/parsers/tiktok.py +++ b/parsers/tiktok.py @@ -58,13 +58,14 @@ class TikTokParser(BaseParser): title = video_info.get("desc", "") - author = video_info.get("author", {}) - author_name = author.get("nickname", "") - author_signature = author.get("signature", "") - description = f"Author: {author_name}" - if author_signature: - description += f" | {author_signature}" + # 提取作者信息 + author_info = video_info.get("author", {}) + author_name = author_info.get("nickname", "") + author_signature = author_info.get("signature", "") - return self._normalize_response(cover, video_url, title, description) + # 简介使用作者签名 + description = author_signature or "" + + return self._normalize_response(cover, video_url, title, description, author_name) except Exception as e: raise Exception(f"数据提取失败: {str(e)}") diff --git a/parsers/weibo.py b/parsers/weibo.py new file mode 100644 index 0000000..17ee41b --- /dev/null +++ b/parsers/weibo.py @@ -0,0 +1,105 @@ +from parsers.base import BaseParser +from typing import Dict +from urllib.parse import urlencode + + +class WeiboUctbParser(BaseParser): + """微博解析器 - 优创 API""" + + def parse(self, video_url: str) -> Dict: + """解析微博视频""" + try: + url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}" + response = self._make_request(url) + data = response.json() + return self._extract_data(data) + except Exception as e: + raise Exception(f"微博解析失败(优创 API): {str(e)}") + + def _extract_data(self, data: Dict) -> Dict: + """提取并标准化数据 + 实际返回格式: + { + "code": 200, + "data": { + "desc": "视频描述", + "cover": "封面URL", + "playurl": "视频URL" + }, + "msg": "请求成功" + } + """ + try: + if data.get("code") == 200: + video_data = data.get("data", {}) + + cover = video_data.get("cover", "") + video_url = video_data.get("playurl", "") + title = video_data.get("desc", "") or "" + description = "" + author = "" # 优创API不返回作者 + + return self._normalize_response(cover, video_url, title, description, author) + else: + raise Exception(f"解析失败: {data.get('msg', '未知错误')}") + except Exception as e: + raise Exception(f"数据提取失败: {str(e)}") + + +class WeiboYaohuParser(BaseParser): + """微博解析器 - 妖狐 API""" + + def parse(self, video_url: str) -> Dict: + """解析微博视频""" + try: + url = f"{self.api_url}/api/v6/video/weibo?{urlencode({'key': self.api_key, 'url': video_url})}" + response = self._make_request(url, verify=False) + data = response.json() + return self._extract_data(data) + except Exception as e: + raise Exception(f"微博解析失败(妖狐 API): {str(e)}") + + def _extract_data(self, data: Dict) -> Dict: + """提取并标准化数据 + 实际返回格式: + { + "code": 200, + "msg": "解析成功", + "data": { + "author": "作者名", + "title": "标题", + "cover": "封面URL", + "quality_urls": { + "超清 2K60": "视频URL", + "高清 1080P": "视频URL", + ... + } + } + } + """ + try: + if data.get("code") == 200: + video_data = data.get("data", {}) + + cover = video_data.get("cover", "") + title = video_data.get("title", "") + description = "" + author = video_data.get("author", "") + + # 从 quality_urls 中获取最高质量的视频URL + quality_urls = video_data.get("quality_urls", {}) + video_url = "" + # 按优先级选择:超清 > 高清1080P > 高清720P > 其他 + for quality in ["超清 2K60", "高清 1080P", "高清 720P"]: + if quality in quality_urls: + video_url = quality_urls[quality] + break + # 如果没找到,取第一个可用的 + if not video_url and quality_urls: + video_url = list(quality_urls.values())[0] + + return self._normalize_response(cover, video_url, title, description, author) + else: + raise Exception(f"解析失败: {data.get('msg', '未知错误')}") + except Exception as e: + raise Exception(f"数据提取失败: {str(e)}") diff --git a/routes/admin.py b/routes/admin.py index fd9a279..98d7c98 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -113,6 +113,8 @@ def users_page(): @admin_required def get_users(): """获取用户列表API""" + from models import UserGroupExpiry + page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 20, type=int) group_id = request.args.get('group_id', type=int) @@ -126,16 +128,28 @@ def get_users(): page=page, per_page=per_page, error_out=False ) - users = [{ - 'id': u.id, - 'username': u.username, - 'email': u.email, - 'group_id': u.group_id, - 'group_name': u.group.name if u.group else '', - 'total_parse_count': u.total_parse_count, - 'is_active': u.is_active, - 'created_at': u.created_at.isoformat() - } for u in pagination.items] + users = [] + for u in pagination.items: + # 获取套餐到期时间 + expiry = UserGroupExpiry.query.filter_by(user_id=u.id).first() + expires_at = None + is_expired = False + if expiry and expiry.expires_at: + expires_at = expiry.expires_at.strftime('%Y-%m-%dT%H:%M') + is_expired = expiry.expires_at < datetime.utcnow() + + users.append({ + 'id': u.id, + 'username': u.username, + 'email': u.email, + 'group_id': u.group_id, + 'group_name': u.group.name if u.group else '', + 'total_parse_count': u.total_parse_count, + 'is_active': u.is_active, + 'created_at': u.created_at.isoformat(), + 'expires_at': expires_at, + 'is_expired': is_expired + }) return jsonify({ 'success': True, @@ -152,6 +166,8 @@ def get_users(): @admin_required def update_user(user_id): """更新用户信息""" + from models import UserGroupExpiry + user = User.query.get_or_404(user_id) data = request.get_json() @@ -160,6 +176,29 @@ def update_user(user_id): if 'is_active' in data: user.is_active = data['is_active'] + # 处理套餐到期时间 + if 'expires_at' in data: + expiry = UserGroupExpiry.query.filter_by(user_id=user_id).first() + + if data['expires_at']: + # 解析时间字符串 + expires_at = datetime.strptime(data['expires_at'], '%Y-%m-%dT%H:%M') + + if expiry: + expiry.group_id = user.group_id + expiry.expires_at = expires_at + else: + expiry = UserGroupExpiry( + user_id=user_id, + group_id=user.group_id, + expires_at=expires_at + ) + db.session.add(expiry) + else: + # 清空到期时间(游客/普通用户) + if expiry: + db.session.delete(expiry) + db.session.commit() return jsonify({'success': True, 'message': '更新成功'}) @@ -766,3 +805,230 @@ def change_email(): admin.email = email db.session.commit() return jsonify({'success': True, 'message': '邮箱修改成功'}) + + +# ==================== 用户组 API ==================== + +@admin_bp.route('/api/user-groups', methods=['GET']) +@admin_required +def get_user_groups(): + """获取用户组列表(仅返回可兑换的套餐,排除游客和普通用户)""" + # 排除游客(id=1)和普通用户(id=2) + groups = UserGroup.query.filter(UserGroup.id > 2).all() + return jsonify({ + 'success': True, + 'data': [{'id': g.id, 'name': g.name, 'daily_limit': g.daily_limit} for g in groups] + }) + + +# ==================== 兑换码管理 ==================== + +@admin_bp.route('/redeem-codes') +@admin_required +def redeem_codes_page(): + """兑换码管理页面""" + from flask import render_template + return render_template('admin_redeem_codes.html') + + +@admin_bp.route('/api/redeem-codes', methods=['GET']) +@admin_required +def get_redeem_codes(): + """获取兑换码列表""" + from models import RedeemCode + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + batch_id = request.args.get('batch_id', '') + status = request.args.get('status', '') # unused, used, expired + + query = RedeemCode.query + + if batch_id: + query = query.filter(RedeemCode.batch_id == batch_id) + + if status == 'unused': + query = query.filter(RedeemCode.is_used == False) + elif status == 'used': + query = query.filter(RedeemCode.is_used == True) + elif status == 'expired': + query = query.filter(RedeemCode.is_used == False, RedeemCode.expires_at < datetime.utcnow()) + + pagination = query.order_by(RedeemCode.created_at.desc()).paginate(page=page, per_page=per_page) + + codes = [] + for code in pagination.items: + codes.append({ + 'id': code.id, + 'code': code.code, + 'batch_id': code.batch_id, + 'target_group': code.target_group.name if code.target_group else '', + 'target_group_id': code.target_group_id, + 'duration_days': code.duration_days, + 'is_used': code.is_used, + 'used_by': code.user.username if code.user else None, + 'used_at': code.used_at.strftime('%Y-%m-%d %H:%M') if code.used_at else None, + 'expires_at': code.expires_at.strftime('%Y-%m-%d %H:%M') if code.expires_at else None, + 'is_expired': code.expires_at and code.expires_at < datetime.utcnow() and not code.is_used, + 'remark': code.remark, + 'created_at': code.created_at.strftime('%Y-%m-%d %H:%M') + }) + + return jsonify({ + 'success': True, + 'data': codes, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total': pagination.total, + 'pages': pagination.pages + } + }) + + +@admin_bp.route('/api/redeem-codes/generate', methods=['POST']) +@admin_required +def generate_redeem_codes(): + """批量生成兑换码""" + from models import RedeemCode + import secrets + import string + + data = request.get_json() + count = data.get('count', 1) + target_group_id = data.get('target_group_id') + duration_days = data.get('duration_days', 30) + expires_days = data.get('expires_days') # 兑换码有效期(天) + remark = data.get('remark', '') + prefix = data.get('prefix', '') # 兑换码前缀 + + if not target_group_id: + return jsonify({'success': False, 'message': '请选择目标用户组'}), 400 + + if count < 1 or count > 1000: + return jsonify({'success': False, 'message': '生成数量必须在1-1000之间'}), 400 + + # 验证用户组存在 + group = UserGroup.query.get(target_group_id) + if not group: + return jsonify({'success': False, 'message': '用户组不存在'}), 400 + + # 生成批次ID + batch_id = datetime.now().strftime('%Y%m%d%H%M%S') + secrets.token_hex(4) + + # 计算过期时间 + expires_at = None + if expires_days: + expires_at = datetime.utcnow() + timedelta(days=expires_days) + + # 生成兑换码 + codes = [] + chars = string.ascii_uppercase + string.digits + + for _ in range(count): + # 生成随机码 + random_part = ''.join(secrets.choice(chars) for _ in range(12)) + code_str = f"{prefix}{random_part}" if prefix else random_part + + code = RedeemCode( + code=code_str, + batch_id=batch_id, + target_group_id=target_group_id, + duration_days=duration_days, + expires_at=expires_at, + remark=remark + ) + db.session.add(code) + codes.append(code_str) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'成功生成 {count} 个兑换码', + 'data': { + 'batch_id': batch_id, + 'codes': codes + } + }) + + +@admin_bp.route('/api/redeem-codes/', methods=['DELETE']) +@admin_required +def delete_redeem_code(code_id): + """删除兑换码""" + from models import RedeemCode + + code = RedeemCode.query.get(code_id) + if not code: + return jsonify({'success': False, 'message': '兑换码不存在'}), 404 + + if code.is_used: + return jsonify({'success': False, 'message': '已使用的兑换码不能删除'}), 400 + + db.session.delete(code) + db.session.commit() + + return jsonify({'success': True, 'message': '删除成功'}) + + +@admin_bp.route('/api/redeem-codes/batch/', methods=['DELETE']) +@admin_required +def delete_batch_codes(batch_id): + """删除整批兑换码""" + from models import RedeemCode + + # 只删除未使用的 + deleted = RedeemCode.query.filter_by(batch_id=batch_id, is_used=False).delete() + db.session.commit() + + return jsonify({'success': True, 'message': f'成功删除 {deleted} 个未使用的兑换码'}) + + +@admin_bp.route('/api/redeem-codes/batches', methods=['GET']) +@admin_required +def get_batch_list(): + """获取批次列表""" + from models import RedeemCode + + batches = db.session.query( + RedeemCode.batch_id, + func.count(RedeemCode.id).label('total'), + func.sum(db.case((RedeemCode.is_used == True, 1), else_=0)).label('used'), + func.min(RedeemCode.created_at).label('created_at') + ).group_by(RedeemCode.batch_id).order_by(func.min(RedeemCode.created_at).desc()).all() + + result = [] + for batch in batches: + result.append({ + 'batch_id': batch.batch_id, + 'total': batch.total, + 'used': int(batch.used or 0), + 'unused': batch.total - int(batch.used or 0), + 'created_at': batch.created_at.strftime('%Y-%m-%d %H:%M') if batch.created_at else '' + }) + + return jsonify({'success': True, 'data': result}) + + +@admin_bp.route('/api/redeem-codes/export/', methods=['GET']) +@admin_required +def export_batch_codes(batch_id): + """导出批次兑换码""" + from models import RedeemCode + + codes = RedeemCode.query.filter_by(batch_id=batch_id).all() + + if not codes: + return jsonify({'success': False, 'message': '批次不存在'}), 404 + + code_list = [code.code for code in codes] + + return jsonify({ + 'success': True, + 'data': { + 'batch_id': batch_id, + 'codes': code_list, + 'total': len(code_list) + } + }) diff --git a/routes/api_v1.py b/routes/api_v1.py new file mode 100644 index 0000000..a177007 --- /dev/null +++ b/routes/api_v1.py @@ -0,0 +1,212 @@ +from flask import Blueprint, request, jsonify +from models import ParserAPI, ParseLog, UserApiKey, ApiKeyDailyStat, db +from parsers.factory import ParserFactory +from utils.security import get_client_ip +from datetime import datetime, date +import time + +api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1') + + +def validate_and_get_key(api_key): + """验证 API Key 并返回 key 对象""" + if not api_key: + return None, 'API Key 不能为空' + + key_obj = UserApiKey.query.filter_by(api_key=api_key).first() + if not key_obj: + return None, 'API Key 无效' + if not key_obj.is_active: + return None, 'API Key 已被禁用' + if not key_obj.user.is_active: + return None, '用户账号已被禁用' + + # 检查每日限额 + today_stat = ApiKeyDailyStat.query.filter_by( + api_key_id=key_obj.id, + date=date.today() + ).first() + if today_stat and today_stat.call_count >= key_obj.daily_limit: + return None, f'已达到每日调用限额({key_obj.daily_limit}次)' + + return key_obj, None + + +def record_api_call(key_obj, ip_address, success=True): + """记录 API 调用""" + key_obj.total_calls = (key_obj.total_calls or 0) + 1 + key_obj.last_used_at = datetime.utcnow() + key_obj.last_used_ip = ip_address + + today_stat = ApiKeyDailyStat.query.filter_by( + api_key_id=key_obj.id, + date=date.today() + ).first() + + if not today_stat: + today_stat = ApiKeyDailyStat(api_key_id=key_obj.id, date=date.today()) + db.session.add(today_stat) + + today_stat.call_count = (today_stat.call_count or 0) + 1 + if success: + today_stat.success_count = (today_stat.success_count or 0) + 1 + else: + today_stat.fail_count = (today_stat.fail_count or 0) + 1 + + +@api_v1_bp.route('/parse', methods=['GET']) +def parse_video(): + """ + 对外解析API - 简化版 + + 请求方式: GET + 参数: + key: API Key + url: 视频链接 + + 示例: /api/v1/parse?key=sk_xxx&url=https://v.douyin.com/xxx + + 返回: + { + "code": 200, + "msg": "解析成功", + "data": { + "cover": "封面URL", + "title": "标题", + "description": "简介", + "author": "作者", + "video_url": "无水印视频链接" + } + } + """ + # 获取参数 + api_key = request.args.get('key') + video_url = request.args.get('url') + + # 验证 API Key + key_obj, error = validate_and_get_key(api_key) + if error: + return jsonify({'code': 401, 'msg': error}) + + # 验证 URL + if not video_url: + return jsonify({'code': 400, 'msg': '请提供视频链接'}) + + # 检测平台 + try: + platform = ParserFactory.detect_platform(video_url) + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}) + + # 展开短链接 + video_url = ParserFactory.expand_short_url(video_url) + + # 获取客户端IP + ip_address = get_client_ip(request) + start_time = time.time() + + # 获取该平台所有可用的API + available_apis = ParserAPI.query.filter_by( + platform=platform.lower(), + is_enabled=True + ).all() + + if not available_apis: + return jsonify({'code': 503, 'msg': f'没有可用的{platform}解析接口'}) + + last_error = None + user_id = key_obj.user_id + + # 尝试所有可用的API(failover机制) + for api_config in available_apis: + try: + parser = ParserFactory.create_parser(api_config) + result = parser.parse(video_url) + + # 验证解析结果,video_url 不能为空 + if not result.get('video_url'): + raise Exception('未能获取到视频链接') + + response_time = int((time.time() - start_time) * 1000) + + # 记录成功日志 + log = ParseLog( + user_id=user_id, + ip_address=ip_address, + platform=platform, + video_url=video_url, + parser_api_id=api_config.id, + status='success', + response_time=response_time + ) + db.session.add(log) + + # 更新API统计 + api_config.total_calls = (api_config.total_calls or 0) + 1 + api_config.success_calls = (api_config.success_calls or 0) + 1 + avg_time = api_config.avg_response_time or 0 + api_config.avg_response_time = int( + (avg_time * (api_config.total_calls - 1) + response_time) / api_config.total_calls + ) + api_config.fail_count = 0 + + # 记录 API Key 调用 + record_api_call(key_obj, ip_address, success=True) + db.session.commit() + + return jsonify({ + 'code': 200, + 'msg': '解析成功', + 'data': { + 'cover': result.get('cover', ''), + 'title': result.get('title', ''), + 'description': result.get('description', ''), + 'author': result.get('author', ''), + 'video_url': result.get('video_url', '') + } + }) + + except Exception as e: + last_error = str(e) + api_config.total_calls = (api_config.total_calls or 0) + 1 + api_config.fail_count = (api_config.fail_count or 0) + 1 + db.session.commit() + continue + + # 所有API都失败 + response_time = int((time.time() - start_time) * 1000) + + log = ParseLog( + user_id=user_id, + ip_address=ip_address, + platform=platform, + video_url=video_url, + status='failed', + error_message=last_error or '所有接口都失败', + response_time=response_time + ) + db.session.add(log) + record_api_call(key_obj, ip_address, success=False) + db.session.commit() + + return jsonify({ + 'code': 500, + 'msg': last_error or '解析失败,请稍后重试' + }) + + +@api_v1_bp.route('/platforms', methods=['GET']) +def get_platforms(): + """获取支持的平台列表""" + return jsonify({ + 'code': 200, + 'msg': '获取成功', + 'data': [ + {'name': 'douyin', 'display_name': '抖音'}, + {'name': 'tiktok', 'display_name': 'TikTok'}, + {'name': 'bilibili', 'display_name': '哔哩哔哩'}, + {'name': 'kuaishou', 'display_name': '快手'}, + {'name': 'pipixia', 'display_name': '皮皮虾'}, + {'name': 'weibo', 'display_name': '微博'} + ] + }) diff --git a/routes/apikey.py b/routes/apikey.py new file mode 100644 index 0000000..6b8c730 --- /dev/null +++ b/routes/apikey.py @@ -0,0 +1,174 @@ +from flask import Blueprint, request, jsonify, render_template +from flask_login import login_required, current_user +from models import db, UserApiKey, ApiKeyDailyStat +from datetime import datetime, date +import secrets + +apikey_bp = Blueprint('apikey', __name__, url_prefix='/user/apikey') + + +def generate_api_key(): + """生成 API Key""" + return f"sk_{secrets.token_hex(24)}" + + +@apikey_bp.route('/') +@login_required +def manage_page(): + """API Key 管理页面""" + return render_template('apikey/manage.html') + + +@apikey_bp.route('/list', methods=['GET']) +@login_required +def list_keys(): + """获取用户的 API Key 列表""" + keys = UserApiKey.query.filter_by(user_id=current_user.id).order_by(UserApiKey.created_at.desc()).all() + + result = [] + for key in keys: + # 获取今日调用次数 + today_stat = ApiKeyDailyStat.query.filter_by( + api_key_id=key.id, + date=date.today() + ).first() + + result.append({ + 'id': key.id, + 'name': key.name, + 'api_key': key.api_key[:12] + '...' + key.api_key[-4:], # 隐藏中间部分 + 'is_active': key.is_active, + 'daily_limit': key.daily_limit, + 'total_calls': key.total_calls, + 'today_calls': today_stat.call_count if today_stat else 0, + 'last_used_at': key.last_used_at.strftime('%Y-%m-%d %H:%M:%S') if key.last_used_at else None, + 'created_at': key.created_at.strftime('%Y-%m-%d %H:%M:%S') + }) + + return jsonify({ + 'success': True, + 'data': result + }) + + +@apikey_bp.route('/create', methods=['POST']) +@login_required +def create_key(): + """创建新的 API Key""" + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify({'success': False, 'message': '请输入 Key 名称'}), 400 + + if len(name) > 100: + return jsonify({'success': False, 'message': 'Key 名称不能超过100个字符'}), 400 + + # 检查用户已有的 Key 数量(限制每个用户最多5个) + key_count = UserApiKey.query.filter_by(user_id=current_user.id).count() + if key_count >= 5: + return jsonify({'success': False, 'message': '每个用户最多创建5个 API Key'}), 400 + + # 生成新的 API Key + api_key = generate_api_key() + + # 根据用户等级设置每日限制 + daily_limit = 100 # 默认 + if current_user.group: + if current_user.group.id == 3: # VIP + daily_limit = 500 + elif current_user.group.id == 4: # SVIP + daily_limit = 2000 + + new_key = UserApiKey( + user_id=current_user.id, + name=name, + api_key=api_key, + daily_limit=daily_limit + ) + + db.session.add(new_key) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'API Key 创建成功', + 'data': { + 'id': new_key.id, + 'name': new_key.name, + 'api_key': api_key, # 只在创建时返回完整的 Key + 'daily_limit': new_key.daily_limit + } + }) + + +@apikey_bp.route('/delete/', methods=['DELETE']) +@login_required +def delete_key(key_id): + """删除 API Key""" + key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first() + + if not key: + return jsonify({'success': False, 'message': 'API Key 不存在'}), 404 + + db.session.delete(key) + db.session.commit() + + return jsonify({ + 'success': True, + 'message': 'API Key 已删除' + }) + + +@apikey_bp.route('/toggle/', methods=['POST']) +@login_required +def toggle_key(key_id): + """启用/禁用 API Key""" + key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first() + + if not key: + return jsonify({'success': False, 'message': 'API Key 不存在'}), 404 + + key.is_active = not key.is_active + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f"API Key 已{'启用' if key.is_active else '禁用'}", + 'is_active': key.is_active + }) + + +@apikey_bp.route('/stats/', methods=['GET']) +@login_required +def get_stats(key_id): + """获取 API Key 统计数据""" + key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first() + + if not key: + return jsonify({'success': False, 'message': 'API Key 不存在'}), 404 + + # 获取最近7天的统计 + from datetime import timedelta + stats = ApiKeyDailyStat.query.filter( + ApiKeyDailyStat.api_key_id == key_id, + ApiKeyDailyStat.date >= date.today() - timedelta(days=6) + ).order_by(ApiKeyDailyStat.date.asc()).all() + + result = [] + for stat in stats: + result.append({ + 'date': stat.date.strftime('%Y-%m-%d'), + 'call_count': stat.call_count, + 'success_count': stat.success_count, + 'fail_count': stat.fail_count + }) + + return jsonify({ + 'success': True, + 'data': { + 'key_name': key.name, + 'total_calls': key.total_calls, + 'daily_stats': result + } + }) diff --git a/routes/auth.py b/routes/auth.py index b0f5c53..1120c63 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -219,14 +219,22 @@ def profile(): @login_required def get_profile(): """获取用户个人中心数据""" - from models import UserGroup, DailyParseStat, ParseLog - from datetime import date + from models import UserGroup, DailyParseStat, ParseLog, UserGroupExpiry + from datetime import date, datetime # 获取用户组信息 user_group = UserGroup.query.get(current_user.group_id) daily_limit = user_group.daily_limit if user_group else 10 group_name = user_group.name if user_group else '普通用户' + # 获取套餐到期时间 + group_expiry = UserGroupExpiry.query.filter_by(user_id=current_user.id).first() + expires_at = None + is_expired = False + if group_expiry: + expires_at = group_expiry.expires_at.strftime('%Y-%m-%d %H:%M') if group_expiry.expires_at else None + is_expired = group_expiry.expires_at < datetime.utcnow() if group_expiry.expires_at else False + # 获取今日使用次数 today = date.today() today_stat = DailyParseStat.query.filter_by( @@ -263,7 +271,9 @@ def get_profile(): 'group': { 'id': current_user.group_id, 'name': group_name, - 'daily_limit': daily_limit + 'daily_limit': daily_limit, + 'expires_at': expires_at, + 'is_expired': is_expired }, 'usage': { 'daily_limit': daily_limit, @@ -274,3 +284,72 @@ def get_profile(): 'parse_logs': logs_data } }) + + +@auth_bp.route('/api/redeem', methods=['POST']) +@login_required +def redeem_code(): + """用户兑换码""" + from models import RedeemCode, UserGroupExpiry + from datetime import datetime, timedelta + + data = request.get_json() + code_str = data.get('code', '').strip().upper() + + if not code_str: + return jsonify({'success': False, 'message': '请输入兑换码'}), 400 + + # 查找兑换码 + code = RedeemCode.query.filter_by(code=code_str).first() + + if not code: + return jsonify({'success': False, 'message': '兑换码不存在'}), 404 + + if code.is_used: + return jsonify({'success': False, 'message': '兑换码已被使用'}), 400 + + if code.expires_at and code.expires_at < datetime.utcnow(): + return jsonify({'success': False, 'message': '兑换码已过期'}), 400 + + # 执行兑换 + user = current_user + + # 更新用户组 + old_group_id = user.group_id + user.group_id = code.target_group_id + + # 计算到期时间 + expires_at = datetime.utcnow() + timedelta(days=code.duration_days) + + # 更新或创建到期记录 + expiry = UserGroupExpiry.query.filter_by(user_id=user.id).first() + if expiry: + # 如果当前套餐未过期且是同一套餐,则叠加时间 + if expiry.group_id == code.target_group_id and expiry.expires_at > datetime.utcnow(): + expires_at = expiry.expires_at + timedelta(days=code.duration_days) + expiry.group_id = code.target_group_id + expiry.expires_at = expires_at + else: + expiry = UserGroupExpiry( + user_id=user.id, + group_id=code.target_group_id, + expires_at=expires_at + ) + db.session.add(expiry) + + # 标记兑换码已使用 + code.is_used = True + code.used_by = user.id + code.used_at = datetime.utcnow() + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'兑换成功!您已升级为 {code.target_group.name},有效期至 {expires_at.strftime("%Y-%m-%d %H:%M")}', + 'data': { + 'group_name': code.target_group.name, + 'expires_at': expires_at.strftime('%Y-%m-%d %H:%M'), + 'duration_days': code.duration_days + } + }) diff --git a/routes/main.py b/routes/main.py index 137397f..1558d3c 100644 --- a/routes/main.py +++ b/routes/main.py @@ -1,4 +1,5 @@ -from flask import Blueprint, render_template, redirect +from flask import Blueprint, render_template, redirect, request, Response +import requests main_bp = Blueprint('main', __name__) @@ -11,6 +12,12 @@ def index(): config[c.config_key] = c.config_value return render_template('index.html', config=config) +@main_bp.route('/api-docs') +def api_docs(): + """API 文档页面""" + return render_template('api_docs.html') + + @main_bp.route('/favicon.ico') def favicon(): """处理 favicon 请求""" @@ -24,3 +31,106 @@ def favicon(): return redirect(logo_config.config_value) # 返回空响应避免 404 return '', 204 + + +@main_bp.route('/proxy/download') +def proxy_download(): + """代理下载视频/图片,绕过防盗链,支持 Range 请求""" + url = request.args.get('url') + if not url: + return 'Missing url parameter', 400 + + # 限制只能代理特定域名 + allowed_domains = [ + 'weibocdn.com', 'sinaimg.cn', 'weibo.com', + 'ppxvod.com', 'byteimg.com', 'pipix.com', '365yg.com', 'ixigua.com', + 'kwimgs.com', 'kwaicdn.com', 'kuaishou.com', 'gifshow.com', 'yximgs.com', + 'douyinvod.com', 'douyin.com', 'tiktokcdn.com', 'bytedance.com', + 'bilivideo.com', 'bilibili.com', 'hdslb.com', + 'zjcdn.com', 'douyinpic.com' + ] + + from urllib.parse import urlparse + parsed = urlparse(url) + domain = parsed.netloc.lower() + + is_allowed = any(d in domain for d in allowed_domains) + if not is_allowed: + return f'Domain not allowed: {domain}', 403 + + # 根据域名设置不同的 Referer + referer_map = { + 'weibo': 'https://weibo.com/', + 'sina': 'https://weibo.com/', + 'pipix': 'https://www.pipix.com/', + '365yg': 'https://www.pipix.com/', + 'ixigua': 'https://www.ixigua.com/', + 'kuaishou': 'https://www.kuaishou.com/', + 'kwimgs': 'https://www.kuaishou.com/', + 'kwaicdn': 'https://www.kuaishou.com/', + 'yximgs': 'https://www.kuaishou.com/', + 'douyin': 'https://www.douyin.com/', + 'bytedance': 'https://www.douyin.com/', + 'zjcdn': 'https://www.douyin.com/', + 'douyinpic': 'https://www.douyin.com/', + 'bilibili': 'https://www.bilibili.com/', + 'hdslb': 'https://www.bilibili.com/', + } + + referer = 'https://www.google.com/' + for key, ref in referer_map.items(): + if key in domain: + referer = ref + break + + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': referer + } + + # 转发 Range 请求头,支持视频进度条拖动 + range_header = request.headers.get('Range') + if range_header: + headers['Range'] = range_header + + resp = requests.get(url, headers=headers, stream=True, timeout=30) + + # 获取文件类型 + content_type = resp.headers.get('Content-Type', 'application/octet-stream') + + # 设置下载文件名 + if 'video' in content_type: + filename = f'video_{int(__import__("time").time())}.mp4' + elif 'image' in content_type: + filename = f'image_{int(__import__("time").time())}.jpg' + else: + filename = f'file_{int(__import__("time").time())}' + + def generate(): + for chunk in resp.iter_content(chunk_size=8192): + yield chunk + + # 构建响应头 + response_headers = { + 'Content-Type': content_type, + 'Accept-Ranges': 'bytes' + } + + # 转发相关响应头 + if resp.headers.get('Content-Length'): + response_headers['Content-Length'] = resp.headers.get('Content-Length') + if resp.headers.get('Content-Range'): + response_headers['Content-Range'] = resp.headers.get('Content-Range') + + # 根据是否是 Range 请求返回不同状态码 + status_code = resp.status_code + + return Response( + generate(), + status=status_code, + headers=response_headers + ) + + except Exception as e: + return f'Download failed: {str(e)}', 500 diff --git a/routes/parser.py b/routes/parser.py index fbface2..14f3b66 100644 --- a/routes/parser.py +++ b/routes/parser.py @@ -40,6 +40,9 @@ def parse_video(): except ValueError as e: return jsonify({'success': False, 'message': str(e)}), 400 + # 展开短链接 + video_url = ParserFactory.expand_short_url(video_url) + # 生成任务ID task_id = str(uuid.uuid4()) @@ -116,6 +119,10 @@ def _process_task(task_id, video_url, platform, user_id, ip_address): # 执行解析 result = parser.parse(video_url) + # 验证解析结果,video_url 不能为空 + if not result.get('video_url'): + raise Exception('未能获取到视频链接') + # 计算响应时间 response_time = int((time.time() - start_time) * 1000) diff --git a/static/css/ui-components.css b/static/css/ui-components.css index 9bad1ac..2645b4c 100644 --- a/static/css/ui-components.css +++ b/static/css/ui-components.css @@ -136,9 +136,130 @@ h1, h2, h3, h4, h5, h6 { box-shadow: 0 0 0 3px var(--primary-100); } -/* Select / Dropdown */ +/* Custom Select / Dropdown */ +.ui-select-wrapper { + position: relative; + width: 100%; +} + +.ui-select-trigger { + width: 100%; + padding: 0.625rem 2.5rem 0.625rem 1rem; + border: 1px solid var(--secondary-200); + border-radius: var(--radius-md); + background-color: white; + color: var(--text-main); + font-size: 0.875rem; + cursor: pointer; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; +} + +.ui-select-trigger::after { + content: ''; + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid var(--secondary-500); + transition: transform 0.2s; +} + +.ui-select-wrapper.open .ui-select-trigger::after { + transform: translateY(-50%) rotate(180deg); +} + +.ui-select-trigger:hover { + border-color: var(--secondary-300); +} + +.ui-select-wrapper.open .ui-select-trigger, +.ui-select-trigger:focus { + outline: none; + border-color: var(--primary-500); + box-shadow: 0 0 0 3px var(--primary-100); +} + +.ui-select-trigger .placeholder { + color: var(--text-muted); +} + +.ui-select-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: white; + border: 1px solid var(--secondary-200); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: 1000; + max-height: 240px; + overflow-y: auto; + opacity: 0; + visibility: hidden; + transform: translateY(-8px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.ui-select-wrapper.open .ui-select-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.ui-select-option { + padding: 0.625rem 1rem; + cursor: pointer; + transition: background 0.15s; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ui-select-option:hover { + background: var(--secondary-50); +} + +.ui-select-option.selected { + background: var(--primary-50); + color: var(--primary-700); + font-weight: 500; +} + +.ui-select-option.selected::before { + content: '✓'; + font-size: 0.75rem; + color: var(--primary-600); +} + +.ui-select-option:first-child { + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.ui-select-option:last-child { + border-radius: 0 0 var(--radius-md) var(--radius-md); +} + +/* 隐藏原生 select */ +.ui-select-wrapper select.ui-select-native { + position: absolute; + opacity: 0; + pointer-events: none; + width: 100%; + height: 100%; +} + +/* 兼容旧的 select.ui-input 样式 */ select.ui-input, -.ui-select { +select.ui-select { width: 100%; padding: 0.625rem 2.5rem 0.625rem 1rem; border: 1px solid var(--secondary-200); @@ -158,14 +279,14 @@ select.ui-input, } select.ui-input:focus, -.ui-select:focus { +select.ui-select:focus { outline: none; border-color: var(--primary-500); box-shadow: 0 0 0 3px var(--primary-100); } select.ui-input:hover, -.ui-select:hover { +select.ui-select:hover { border-color: var(--secondary-300); } diff --git a/static/js/ui-components.js b/static/js/ui-components.js index 72cc2e6..ce59384 100644 --- a/static/js/ui-components.js +++ b/static/js/ui-components.js @@ -1,5 +1,107 @@ // 现代化UI组件库 const UI = { + // 初始化所有自定义下拉框 + initSelects() { + document.querySelectorAll('select.ui-input, select.ui-select').forEach(select => { + if (select.dataset.uiInit) return; // 已初始化 + this.createCustomSelect(select); + }); + }, + + // 创建自定义下拉框 + createCustomSelect(select) { + select.dataset.uiInit = 'true'; + + // 创建包装器 + const wrapper = document.createElement('div'); + wrapper.className = 'ui-select-wrapper'; + select.parentNode.insertBefore(wrapper, select); + + // 隐藏原生 select + select.classList.add('ui-select-native'); + wrapper.appendChild(select); + + // 创建触发器 + const trigger = document.createElement('div'); + trigger.className = 'ui-select-trigger'; + trigger.tabIndex = 0; + wrapper.appendChild(trigger); + + // 创建下拉菜单 + const dropdown = document.createElement('div'); + dropdown.className = 'ui-select-dropdown'; + wrapper.appendChild(dropdown); + + // 更新选项 + const updateOptions = () => { + dropdown.innerHTML = ''; + const selectedValue = select.value; + let selectedText = ''; + + Array.from(select.options).forEach(option => { + const optionEl = document.createElement('div'); + optionEl.className = 'ui-select-option'; + optionEl.dataset.value = option.value; + optionEl.textContent = option.textContent; + + if (option.value === selectedValue) { + optionEl.classList.add('selected'); + selectedText = option.textContent; + } + + optionEl.addEventListener('click', () => { + select.value = option.value; + select.dispatchEvent(new Event('change', { bubbles: true })); + wrapper.classList.remove('open'); + updateTrigger(); + }); + + dropdown.appendChild(optionEl); + }); + + return selectedText; + }; + + // 更新触发器显示 + const updateTrigger = () => { + const selectedText = updateOptions(); + if (selectedText) { + trigger.innerHTML = `${selectedText}`; + } else { + trigger.innerHTML = `${select.options[0]?.textContent || '请选择'}`; + } + }; + + // 初始化 + updateTrigger(); + + // 点击触发器 + trigger.addEventListener('click', (e) => { + e.stopPropagation(); + // 关闭其他下拉框 + document.querySelectorAll('.ui-select-wrapper.open').forEach(w => { + if (w !== wrapper) w.classList.remove('open'); + }); + wrapper.classList.toggle('open'); + }); + + // 键盘支持 + trigger.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + wrapper.classList.toggle('open'); + } else if (e.key === 'Escape') { + wrapper.classList.remove('open'); + } + }); + + // 监听原生 select 变化 + select.addEventListener('change', updateTrigger); + + // 监听选项变化(用于动态更新) + const observer = new MutationObserver(updateTrigger); + observer.observe(select, { childList: true, subtree: true }); + }, // 显示通知消息 notify(message, type = 'info', duration = 3000) { const container = document.getElementById('notification-container') || this.createNotificationContainer(); @@ -118,3 +220,20 @@ const UI = { return icons[type] || icons.info; } }; + +// 点击外部关闭下拉框 +document.addEventListener('click', (e) => { + if (!e.target.closest('.ui-select-wrapper')) { + document.querySelectorAll('.ui-select-wrapper.open').forEach(w => { + w.classList.remove('open'); + }); + } +}); + +// 页面加载完成后自动初始化下拉框 +document.addEventListener('DOMContentLoaded', () => { + UI.initSelects(); +}); + +// 提供手动刷新方法(用于动态加载的内容) +UI.refreshSelects = UI.initSelects; diff --git a/templates/admin_apis.html b/templates/admin_apis.html index 3bf374b..ac9cdfd 100644 --- a/templates/admin_apis.html +++ b/templates/admin_apis.html @@ -19,6 +19,7 @@ 仪表板 用户管理 接口管理 + 兑换码 系统配置 日志审计 @@ -63,6 +64,9 @@ + + +
diff --git a/templates/admin_config.html b/templates/admin_config.html index d18af41..6919155 100644 --- a/templates/admin_config.html +++ b/templates/admin_config.html @@ -19,6 +19,7 @@ 仪表板 用户管理 接口管理 + 兑换码 系统配置 日志审计 diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index fc62fc9..bf47b0c 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -19,6 +19,7 @@ 仪表板 用户管理 接口管理 + 兑换码 系统配置 日志审计 diff --git a/templates/admin_health_checks.html b/templates/admin_health_checks.html index 35dcc65..3167da4 100644 --- a/templates/admin_health_checks.html +++ b/templates/admin_health_checks.html @@ -19,6 +19,7 @@ 仪表板 用户管理 接口管理 + 兑换码 系统配置 日志审计 @@ -75,6 +76,9 @@ + + +
diff --git a/templates/admin_logs.html b/templates/admin_logs.html index f55988f..4c1fea8 100644 --- a/templates/admin_logs.html +++ b/templates/admin_logs.html @@ -19,6 +19,7 @@ 仪表板 用户管理 接口管理 + 兑换码 系统配置 日志审计 @@ -39,6 +40,9 @@ + + + + + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + +
兑换码目标套餐有效期状态使用者使用时间过期时间操作
加载中...
+
+ +
+ + + + + + + + + + + + + + + diff --git a/templates/admin_smtp.html b/templates/admin_smtp.html index e798ca1..78e48e8 100644 --- a/templates/admin_smtp.html +++ b/templates/admin_smtp.html @@ -19,6 +19,7 @@ 仪表板 用户管理 接口管理 + 兑换码 系统配置 日志审计 diff --git a/templates/admin_users.html b/templates/admin_users.html index 7c31d9a..3fe406e 100644 --- a/templates/admin_users.html +++ b/templates/admin_users.html @@ -19,6 +19,7 @@ 仪表板 用户管理 接口管理 + 兑换码 系统配置 日志审计 @@ -105,7 +106,12 @@
- + +
+
@@ -234,7 +240,10 @@ #${u.id} ${u.username} ${u.email} - ${u.group_name} + + ${u.group_name} + ${u.group_id > 2 && u.expires_at ? `
${u.is_expired ? '已过期' : '至 ' + u.expires_at}` : ''} + ${u.total_parse_count} @@ -243,8 +252,8 @@ ${new Date(u.created_at).toLocaleString('zh-CN')} - @@ -268,13 +277,35 @@ } } - function editUser(id, groupId, isActive) { + function editUser(id, groupId, isActive, expiresAt) { document.getElementById('editUserId').value = id; document.getElementById('editGroupId').value = groupId; document.getElementById('editIsActive').value = isActive; + + // 设置到期时间 + if (expiresAt) { + document.getElementById('editExpiresAt').value = expiresAt.slice(0, 16); + } else { + document.getElementById('editExpiresAt').value = ''; + } + + // 根据用户组显示/隐藏到期时间 + onGroupChange(); document.getElementById('editModal').classList.add('show'); } + function onGroupChange() { + const groupId = parseInt(document.getElementById('editGroupId').value); + const expiryGroup = document.getElementById('expiryGroup'); + // 游客(1)和普通用户(2)不显示到期时间 + if (groupId > 2) { + expiryGroup.style.display = 'block'; + } else { + expiryGroup.style.display = 'none'; + document.getElementById('editExpiresAt').value = ''; + } + } + function closeModal() { document.getElementById('editModal').classList.remove('show'); } @@ -337,11 +368,19 @@ document.getElementById('editForm').addEventListener('submit', async (e) => { e.preventDefault(); const userId = document.getElementById('editUserId').value; + const groupId = parseInt(document.getElementById('editGroupId').value); + const expiresAt = document.getElementById('editExpiresAt').value; + const data = { - group_id: parseInt(document.getElementById('editGroupId').value), + group_id: groupId, is_active: document.getElementById('editIsActive').value === 'true' }; + // VIP/SVIP 需要设置到期时间 + if (groupId > 2) { + data.expires_at = expiresAt || null; + } + try { const response = await fetch(`/admin/api/users/${userId}`, { method: 'PUT', @@ -354,6 +393,7 @@ UI.notify('更新成功', 'success'); closeModal(); loadUsers(currentPage); + loadUserStats(); } else { UI.notify(result.message || '更新失败', 'error'); } diff --git a/templates/api_docs.html b/templates/api_docs.html new file mode 100644 index 0000000..dd270f6 --- /dev/null +++ b/templates/api_docs.html @@ -0,0 +1,557 @@ + + + + + + API 文档 - 短视频解析平台 + + + + +
+ + + +
+

快速开始

+

短视频解析 API 支持抖音、TikTok、哔哩哔哩等平台的视频解析,返回无水印视频链接、封面、标题、作者等信息。

+ +

1. 获取 API Key

+

登录后访问 API Key 管理 页面创建您的 API Key。

+ +

2. 发起请求

+
+ +
https://your-domain.com/api/v1/parse?key=sk_xxx&url=视频链接
+
+ +

3. 获取结果

+
+ +
{
+  "code": 200,
+  "msg": "解析成功",
+  "data": {
+    "cover": "封面URL",
+    "title": "视频标题",
+    "description": "视频简介",
+    "author": "作者名",
+    "video_url": "无水印视频链接"
+  }
+}
+
+
+ + +
+

认证方式

+

通过 URL 参数 key 传递 API Key:

+ +
+ +
/api/v1/parse?key=您的API_Key&url=视频链接
+
+ +
+ 请妥善保管您的 API Key,不要在客户端代码中暴露。建议通过后端服务调用 API。 +
+
+ + +
+

解析接口

+ +
+ GET + /api/v1/parse +
+ +

请求参数

+ + + + + + + + + + + + + + + + + + + + + + + +
参数类型必填说明
keystring您的 API Key
urlstring视频链接(支持短链接和完整链接)
+ +

成功响应

+
+ +
{
+  "code": 200,
+  "msg": "解析成功",
+  "data": {
+    "cover": "封面图片URL",
+    "title": "视频标题",
+    "description": "视频简介",
+    "author": "作者名称",
+    "video_url": "无水印视频URL"
+  }
+}
+
+ +

错误响应

+
+ +
{
+  "code": 401,
+  "msg": "错误描述"
+}
+
+
+ + +
+

支持平台

+
+ GET + /api/v1/platforms +
+

此接口无需认证,可获取当前支持的平台列表。

+ +
+
+
抖音
+
douyin.com, v.douyin.com
+
+
+
TikTok
+
tiktok.com
+
+
+
哔哩哔哩
+
bilibili.com, b23.tv
+
+
+
快手
+
kuaishou.com
+
+
+
皮皮虾
+
pipix.com, h5.pipix.com
+
+
+
微博
+
weibo.com, weibo.cn
+
+
+
+ + +
+

错误码说明

+
+
+ UNAUTHORIZED + API Key 无效、已禁用或超出限额 +
+
+ INVALID_REQUEST + 请求格式错误,需要 JSON 格式 +
+
+ MISSING_URL + 缺少 url 参数 +
+
+ UNSUPPORTED_PLATFORM + 不支持的视频平台 +
+
+ NO_AVAILABLE_API + 该平台暂无可用解析接口 +
+
+ PARSE_FAILED + 解析失败,请检查链接或稍后重试 +
+
+
+ + +
+

代码示例

+ +

Python

+
+ +
import requests
+
+api_key = "sk_your_api_key_here"
+video_url = "https://v.douyin.com/xxxxx"
+
+response = requests.get(
+    "https://your-domain.com/api/v1/parse",
+    params={"key": api_key, "url": video_url}
+)
+result = response.json()
+
+if result["code"] == 200:
+    print("视频标题:", result["data"]["title"])
+    print("作者:", result["data"]["author"])
+    print("视频链接:", result["data"]["video_url"])
+else:
+    print("错误:", result["msg"])
+
+ +

JavaScript

+
+ +
const apiKey = 'sk_your_api_key_here';
+const videoUrl = 'https://v.douyin.com/xxxxx';
+
+fetch(`https://your-domain.com/api/v1/parse?key=${apiKey}&url=${encodeURIComponent(videoUrl)}`)
+  .then(res => res.json())
+  .then(result => {
+    if (result.code === 200) {
+      console.log('视频标题:', result.data.title);
+      console.log('作者:', result.data.author);
+      console.log('视频链接:', result.data.video_url);
+    } else {
+      console.error('错误:', result.msg);
+    }
+  });
+
+ +

cURL

+
+ +
curl "https://your-domain.com/api/v1/parse?key=sk_xxx&url=https://v.douyin.com/xxxxx"
+
+
+ + +
+

使用限制

+ + + + + + + + + + + + + + + + + + + + + +
用户等级每日限额
普通用户100 次/天/Key
VIP 用户500 次/天/Key
SVIP 用户2000 次/天/Key
+
+ 每个用户最多可创建 5 个 API Key,每日限额按单个 Key 计算。 +
+
+
+ + + + + diff --git a/templates/apikey/manage.html b/templates/apikey/manage.html new file mode 100644 index 0000000..6197008 --- /dev/null +++ b/templates/apikey/manage.html @@ -0,0 +1,465 @@ + + + + + + API Key 管理 - 短视频解析平台 + + + + +
+ + +
+

创建新的 API Key

+
+ API Key 用于调用解析接口,请妥善保管。每个用户最多创建 5 个 Key。 +
+
+ + +
+
+ +
+

我的 API Keys

+
+
加载中...
+
+
+
+ + + + + + + + diff --git a/templates/index.html b/templates/index.html index ad085a6..c8aa6ec 100644 --- a/templates/index.html +++ b/templates/index.html @@ -201,6 +201,73 @@ @keyframes spin { to { transform: rotate(360deg); } } + + /* 输入框按钮组 */ + .input-group { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + } + + .input-group .url-input { + flex: 1; + margin-bottom: 0; + } + + .input-btn { + padding: 0 1.25rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 12px; + border: 2px solid var(--primary-200); + background: white; + color: var(--primary-600); + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + } + + .input-btn:hover { + background: var(--primary-50); + border-color: var(--primary-400); + } + + .input-btn:active { + transform: scale(0.96); + } + + /* 进度条 */ + .progress-container { + display: none; + margin-top: 1.5rem; + } + + .progress-container.show { + display: block; + } + + .progress-label { + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--secondary-600); + } + + .progress-bar { + height: 8px; + background: var(--secondary-100); + border-radius: 4px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%); + border-radius: 4px; + width: 0%; + transition: width 0.3s ease; + } @@ -217,22 +284,36 @@
- +
+ + + +
+
+
+ 正在解析... + 0% +
+
+
+
+
+

- + @@ -240,12 +321,14 @@ 复制链接
+
@@ -258,13 +341,103 @@ diff --git a/utils/api_auth.py b/utils/api_auth.py new file mode 100644 index 0000000..1732582 --- /dev/null +++ b/utils/api_auth.py @@ -0,0 +1,104 @@ +from functools import wraps +from flask import request, jsonify +from models import db, UserApiKey, ApiKeyDailyStat +from datetime import datetime, date + + +def get_api_key_from_request(): + """从请求中获取 API Key""" + # 优先从 Header 获取 + api_key = request.headers.get('X-API-Key') + if api_key: + return api_key + + # 其次从 Authorization Bearer 获取 + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer '): + return auth_header[7:] + + # 最后从查询参数获取 + return request.args.get('api_key') + + +def validate_api_key(api_key): + """ + 验证 API Key + 返回: (is_valid, key_obj_or_error_msg) + """ + if not api_key: + return False, 'API Key 不能为空' + + key_obj = UserApiKey.query.filter_by(api_key=api_key).first() + + if not key_obj: + return False, 'API Key 无效' + + if not key_obj.is_active: + return False, 'API Key 已被禁用' + + if not key_obj.user.is_active: + return False, '用户账号已被禁用' + + # 检查每日限额 + today_stat = ApiKeyDailyStat.query.filter_by( + api_key_id=key_obj.id, + date=date.today() + ).first() + + if today_stat and today_stat.call_count >= key_obj.daily_limit: + return False, f'已达到每日调用限额({key_obj.daily_limit}次)' + + return True, key_obj + + +def record_api_call(key_obj, ip_address, success=True): + """记录 API 调用""" + # 更新 Key 统计 + key_obj.total_calls += 1 + key_obj.last_used_at = datetime.utcnow() + key_obj.last_used_ip = ip_address + + # 更新每日统计 + today_stat = ApiKeyDailyStat.query.filter_by( + api_key_id=key_obj.id, + date=date.today() + ).first() + + if not today_stat: + today_stat = ApiKeyDailyStat( + api_key_id=key_obj.id, + date=date.today() + ) + db.session.add(today_stat) + + today_stat.call_count += 1 + if success: + today_stat.success_count += 1 + else: + today_stat.fail_count += 1 + + db.session.commit() + + +def api_key_required(f): + """API Key 鉴权装饰器""" + @wraps(f) + def decorated_function(*args, **kwargs): + api_key = get_api_key_from_request() + + is_valid, result = validate_api_key(api_key) + + if not is_valid: + return jsonify({ + 'success': False, + 'error': { + 'code': 'UNAUTHORIZED', + 'message': result + } + }), 401 + + # 将 key 对象传递给视图函数 + request.api_key_obj = result + return f(*args, **kwargs) + + return decorated_function diff --git a/开发记录/2025-11-30-会话记录.md b/开发记录/2025-11-30-会话记录.md new file mode 100644 index 0000000..8988160 --- /dev/null +++ b/开发记录/2025-11-30-会话记录.md @@ -0,0 +1,99 @@ +# 开发会话记录 - 2025-11-30 + +## 本次会话完成的功能 + +### 1. API Key 内联管理(个人中心) +- 将 API Key 管理功能直接嵌入到个人中心页面 +- 支持创建、查看、启用/禁用、删除 API Key +- 新创建的 Key 只显示一次,提示用户立即复制 +- 文件修改:`templates/profile.html` + +### 2. 用户套餐到期时间管理 +- 管理后台编辑用户时可设置套餐到期时间 +- VIP/SVIP 用户显示到期时间输入框 +- 游客和普通用户不显示(永久有效) +- 用户列表显示套餐到期状态 +- 文件修改: + - `templates/admin_users.html` + - `routes/admin.py`(get_users、update_user) + +### 3. 自定义下拉框组件 +- 创建美观的自定义下拉框替代浏览器原生 select +- 自动初始化所有 `.ui-input` 和 `.ui-select` 下拉框 +- 支持键盘操作(Enter/Space 打开,Escape 关闭) +- 点击外部自动关闭 +- 动态选项更新支持 +- 文件修改: + - `static/css/ui-components.css` + - `static/js/ui-components.js` + +### 4. 平台选项更新 +- 管理后台所有平台下拉框添加新平台选项 +- 新增:快手(kuaishou)、皮皮虾(pipixia)、微博(weibo) +- 文件修改: + - `templates/admin_apis.html` + - `templates/admin_logs.html` + - `templates/admin_health_checks.html` + +--- + +## 之前会话完成的功能(摘要) + +### 兑换码系统 +- 管理员批量生成兑换码 +- 用户在个人中心兑换升级套餐 +- 支持套餐时间叠加 +- 文件:`models/__init__.py`、`routes/admin.py`、`routes/auth.py`、`templates/admin_redeem_codes.html` + +### 视频代理下载 +- 解决防盗链问题(Referer) +- 支持多平台域名白名单 +- 文件:`routes/main.py` + +### B站短链接支持 +- 自动展开 b23.tv 短链接 +- 文件:`parsers/factory.py` + +### 前端优化 +- 粘贴按钮、清空按钮 +- 解析进度条 +- 视频播放器缓存修复 +- 文件:`templates/index.html` + +--- + +## 项目当前支持的平台 +1. 抖音 (douyin) +2. TikTok (tiktok) +3. 哔哩哔哩 (bilibili) +4. 快手 (kuaishou) +5. 皮皮虾 (pipixia) +6. 微博 (weibo) + +--- + +## 关键文件清单 + +### 后端路由 +- `routes/auth.py` - 用户认证、个人中心、兑换码 +- `routes/admin.py` - 管理后台 API +- `routes/parser.py` - 视频解析 API +- `routes/api_v1.py` - 外部 API(API Key 认证) +- `routes/apikey.py` - API Key 管理 +- `routes/main.py` - 主页、代理下载 + +### 前端模板 +- `templates/index.html` - 首页 +- `templates/profile.html` - 个人中心 +- `templates/admin_*.html` - 管理后台页面 + +### 静态资源 +- `static/css/ui-components.css` - UI 组件样式 +- `static/js/ui-components.js` - UI 组件脚本 + +### 数据模型 +- `models/__init__.py` - 所有数据库模型 + +### 解析器 +- `parsers/factory.py` - 解析器工厂 +- `parsers/base.py` - 基础解析器类 diff --git a/解析接口/微博解析.postman_collection.json b/解析接口/微博解析.postman_collection.json new file mode 100644 index 0000000..951826d --- /dev/null +++ b/解析接口/微博解析.postman_collection.json @@ -0,0 +1,71 @@ +{ + "info": { + "_postman_id": "38e90715-747a-4f04-89b2-194e902cdbdf", + "name": "微博解析", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "49242368", + "_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-38e90715-747a-4f04-89b2-194e902cdbdf?action=share&source=collection_link&creator=49242368" + }, + "item": [ + { + "name": "优创微博API", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://apis.uctb.cn/api/videojx?url=https://video.weibo.com/show?fid=1034:5233299304415254", + "protocol": "http", + "host": [ + "apis", + "uctb", + "cn" + ], + "path": [ + "api", + "videojx" + ], + "query": [ + { + "key": "url", + "value": "https://video.weibo.com/show?fid=1034:5233299304415254" + } + ] + } + }, + "response": [] + }, + { + "name": "妖狐微博API", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://api.yaohud.cn/api/v6/video/weibo?key=SM227DLC0ZgJ6DXJhAx&url=https://video.weibo.com/show?fid=1034:5233299304415254", + "protocol": "https", + "host": [ + "api", + "yaohud", + "cn" + ], + "path": [ + "api", + "v6", + "video", + "weibo" + ], + "query": [ + { + "key": "key", + "value": "SM227DLC0ZgJ6DXJhAx" + }, + { + "key": "url", + "value": "https://video.weibo.com/show?fid=1034:5233299304415254" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/解析接口/快手解析.postman_collection.json b/解析接口/快手解析.postman_collection.json new file mode 100644 index 0000000..cae9d38 --- /dev/null +++ b/解析接口/快手解析.postman_collection.json @@ -0,0 +1,97 @@ +{ + "info": { + "_postman_id": "f31a94d4-4545-4d7f-a5d8-1f9058a5e2f8", + "name": "快手解析", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "49242368", + "_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-f31a94d4-4545-4d7f-a5d8-1f9058a5e2f8?action=share&source=collection_link&creator=49242368" + }, + "item": [ + { + "name": "BugPk快手", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://api.bugpk.com/api/kuaishou?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx", + "protocol": "https", + "host": [ + "api", + "bugpk", + "com" + ], + "path": [ + "api", + "kuaishou" + ], + "query": [ + { + "key": "url", + "value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx" + } + ] + } + }, + "response": [] + }, + { + "name": "Nice猫快手API", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://api.nsmao.net/api/kuaishou/query?key=UhjPIwpNdljUSdRUGCTxsuDEMm&url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx", + "protocol": "https", + "host": [ + "api", + "nsmao", + "net" + ], + "path": [ + "api", + "kuaishou", + "query" + ], + "query": [ + { + "key": "key", + "value": "UhjPIwpNdljUSdRUGCTxsuDEMm" + }, + { + "key": "url", + "value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx" + } + ] + } + }, + "response": [] + }, + { + "name": "优创快手API", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://apis.uctb.cn/api/videojx?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx", + "protocol": "http", + "host": [ + "apis", + "uctb", + "cn" + ], + "path": [ + "api", + "videojx" + ], + "query": [ + { + "key": "url", + "value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/解析接口/皮皮虾解析.postman_collection.json b/解析接口/皮皮虾解析.postman_collection.json new file mode 100644 index 0000000..ea6a8ef --- /dev/null +++ b/解析接口/皮皮虾解析.postman_collection.json @@ -0,0 +1,65 @@ +{ + "info": { + "_postman_id": "31f6f547-0cb0-4610-b2b6-130ec8e75811", + "name": "皮皮虾解析", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "49242368", + "_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-31f6f547-0cb0-4610-b2b6-130ec8e75811?action=share&source=collection_link&creator=49242368" + }, + "item": [ + { + "name": "BugPK皮皮虾API", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://api.bugpk.com/api/pipixia?url=https://h5.pipix.com/s/kLUS2a4iJ_M/", + "protocol": "https", + "host": [ + "api", + "bugpk", + "com" + ], + "path": [ + "api", + "pipixia" + ], + "query": [ + { + "key": "url", + "value": "https://h5.pipix.com/s/kLUS2a4iJ_M/" + } + ] + } + }, + "response": [] + }, + { + "name": "优创皮皮虾API", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://apis.uctb.cn/api/videojx?url=https://h5.pipix.com/s/wrDsplDprWs/", + "protocol": "http", + "host": [ + "apis", + "uctb", + "cn" + ], + "path": [ + "api", + "videojx" + ], + "query": [ + { + "key": "url", + "value": "https://h5.pipix.com/s/wrDsplDprWs/" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file