From fafb0aee4f32e0d88b7744dc8f8bebfb114e1e45 Mon Sep 17 00:00:00 2001 From: xbh <6726613@qq.com> Date: Thu, 30 Oct 2025 16:47:10 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.vue | 94 +++++++------------ .../src/images/logo.svg | 0 frontend/src/images/剧场名icon.svg | 3 + frontend/src/images/承制icon.svg | 3 + .../src/images/播放icon.svg | 0 .../src/images/点赞icon.svg | 0 .../src/images/热度icon.svg | 0 frontend/src/images/版权icon.svg | 3 + 8 files changed, 41 insertions(+), 62 deletions(-) rename .figma/image/mhcsnkh7-payvxas.svg => frontend/src/images/logo.svg (100%) create mode 100644 frontend/src/images/剧场名icon.svg create mode 100644 frontend/src/images/承制icon.svg rename .figma/image/mhcsnkh7-djzd19r.svg => frontend/src/images/播放icon.svg (100%) rename .figma/image/mhcsnkh7-5xbxskt.svg => frontend/src/images/点赞icon.svg (100%) rename .figma/image/mhcsnkh7-zac36w2.svg => frontend/src/images/热度icon.svg (100%) create mode 100644 frontend/src/images/版权icon.svg diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a1e973a..fe8a138 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -251,22 +251,20 @@ onMounted(() => {
-

AI棒榜

-
-
-
- -
- - - 共 {{ rankingData.length }} 条数据 -
-
@@ -624,58 +477,6 @@ onMounted(() => {
- - - @@ -807,29 +608,6 @@ export default { background: #c82333; } -/* 日期选择区域 */ -.date-section { - padding: 16px; - background: white; - border-bottom: 1px solid #e0e0e0; - display: flex; - align-items: center; - gap: 12px; - font-size: 14px; -} - -.date-input { - padding: 6px 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; -} - -.data-count { - color: #666; - margin-left: auto; -} - /* 管理内容区域 */ .admin-content { padding: 16px; @@ -1158,10 +936,6 @@ export default { padding: 16px 12px; } - .date-section { - padding: 12px; - } - .admin-content { padding: 12px; } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1bd8464..9e073b2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -370,9 +370,9 @@ onMounted(() => { 版权
-

剧场名:{{ item.series_author || '爱微剧场' }}

-

承制:{{ item.Manufacturing_Field || '妙想制片厂' }}

-

版权:{{ item.Copyright_field || '可梦' }}

+

剧场名:{{ item.series_author || '' }}

+

承制:{{ item.Manufacturing_Field || '' }}

+

版权:{{ item.Copyright_field || '' }}

From 13b05ae252a12aa05bd63c7000020426c04b19f9 Mon Sep 17 00:00:00 2001 From: qiaoyirui0819 <3160533978@qq.com> Date: Sat, 8 Nov 2025 16:29:33 +0800 Subject: [PATCH 13/21] =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Timer_worker.py | 17 +- .../handlers/Rankings/rank_data_scraper.py | 194 +++++++++--------- 2 files changed, 109 insertions(+), 102 deletions(-) diff --git a/backend/Timer_worker.py b/backend/Timer_worker.py index e514454..3fcebd3 100644 --- a/backend/Timer_worker.py +++ b/backend/Timer_worker.py @@ -249,9 +249,9 @@ class DouyinAutoScheduler: if not mix_id or mix_id == "" or mix_id.lower() == "null": continue - # 过滤掉播放量为0或无效的记录 + # 注意:播放量为0的数据也会被保留,可能是新发布的短剧 if play_vv <= 0: - continue + logging.warning(f"⚠️ 发现播放量为0的数据: mix_name={mix_name}, play_vv={play_vv},仍会保留") if mix_id not in unique_videos or play_vv > unique_videos[mix_id].get("play_vv", 0): unique_videos[mix_id] = video @@ -283,7 +283,7 @@ class DouyinAutoScheduler: }).sort("play_vv", -1)) # 按短剧ID去重,每个短剧只保留播放量最高的一条 - # 🚫 过滤掉空的或无效的mix_id和播放量为0的记录 + # 🚫 过滤掉空的或无效的mix_id unique_yesterday_videos = {} for video in yesterday_videos_raw: mix_id = video.get("mix_id", "").strip() @@ -294,9 +294,9 @@ class DouyinAutoScheduler: if not mix_id or mix_id == "" or mix_id.lower() == "null": continue - # 过滤掉播放量为0或无效的记录 + # 注意:播放量为0的数据也会被保留,可能是新发布的短剧 if play_vv <= 0: - continue + logging.warning(f"⚠️ 昨天数据中发现播放量为0: mix_name={mix_name}, play_vv={play_vv},仍会保留") if mix_id not in unique_yesterday_videos or play_vv > unique_yesterday_videos[mix_id].get("play_vv", 0): unique_yesterday_videos[mix_id] = video @@ -369,15 +369,14 @@ class DouyinAutoScheduler: current_play_vv = video.get("play_vv", 0) mix_name = video.get("mix_name", "").strip() - # 🚫 跳过无效数据:确保mix_name不为空且播放量大于0 - # 注意:这些数据应该已经在去重阶段被过滤掉了,这里是双重保险 + # 🚫 跳过无效数据:确保mix_name不为空 + # 注意:播放量为0的数据也会被保留,可能是新发布的短剧 if not mix_name or mix_name == "" or mix_name.lower() == "null": self.logger.warning(f"跳过空的mix_name记录,video_id: {video_id}") continue if current_play_vv <= 0: - self.logger.warning(f"跳过播放量无效的记录: mix_name={mix_name}, play_vv={current_play_vv}") - continue + self.logger.warning(f"⚠️ 榜单中发现播放量为0的记录: mix_name={mix_name}, play_vv={current_play_vv},仍会保留") # 计算排名变化(基于昨天的排名) rank_change = 0 diff --git a/backend/handlers/Rankings/rank_data_scraper.py b/backend/handlers/Rankings/rank_data_scraper.py index 4c24f95..a682bb5 100644 --- a/backend/handlers/Rankings/rank_data_scraper.py +++ b/backend/handlers/Rankings/rank_data_scraper.py @@ -809,91 +809,95 @@ class DouyinPlayVVScraper: if isinstance(play_vv, (int, str)) and str(play_vv).isdigit(): vv = int(play_vv) - # 数据验证:确保合集名称不为空 - if not mix_name or mix_name.strip() == "": - logging.warning(f"跳过缺少合集名称的数据: play_vv={vv}, mix_id={mix_id}") - return - - # 🔧 修复:不跳过播放量为0的数据,而是标记并保留 - # 这些数据可能是因为页面加载不完整,但合集本身是存在的 - # 警告信息移到去重检查之后,只有真正添加时才警告 - - # 构建合集链接 - video_url = f"https://www.douyin.com/collection/{mix_id}" if mix_id else "" - - # 提取合集封面图片URL - 直接存储完整的图片链接 - cover_image_url = "" - cover_image_backup_urls = [] # 备用链接列表 + # 数据验证:确保有mix_id(按短剧ID去重,所以必须有mix_id) + if not mix_id or mix_id.strip() == "": + logging.warning(f"跳过缺少mix_id的数据: play_vv={vv}, mix_name={mix_name}") + # 跳过当前项,但继续递归解析其他数据(不使用return) + else: + # 如果mix_name为空,使用mix_id作为名称 + if not mix_name or mix_name.strip() == "": + mix_name = f"短剧_{mix_id}" + logging.warning(f"⚠️ mix_name为空,使用mix_id作为名称: {mix_name}") + # 🔧 修复:不跳过播放量为0的数据,而是标记并保留 + # 这些数据可能是因为页面加载不完整,但合集本身是存在的 + # 警告信息移到去重检查之后,只有真正添加时才警告 + + # 构建合集链接 + video_url = f"https://www.douyin.com/collection/{mix_id}" if mix_id else "" + + # 提取合集封面图片URL - 直接存储完整的图片链接 + cover_image_url = "" + cover_image_backup_urls = [] # 备用链接列表 - # 查找封面图片字段,优先获取完整的URL链接 - if 'cover' in obj: - cover = obj['cover'] - if isinstance(cover, dict) and 'url_list' in cover and cover['url_list']: - # 主链接 - cover_image_url = cover['url_list'][0] - # 备用链接 - cover_image_backup_urls = cover['url_list'][1:] if len(cover['url_list']) > 1 else [] - elif isinstance(cover, str): - cover_image_url = cover - elif 'cover_url' in obj: - cover_url = obj['cover_url'] - if isinstance(cover_url, dict) and 'url_list' in cover_url and cover_url['url_list']: - cover_image_url = cover_url['url_list'][0] - cover_image_backup_urls = cover_url['url_list'][1:] if len(cover_url['url_list']) > 1 else [] - elif isinstance(cover_url, str): - cover_image_url = cover_url - elif 'image' in obj: - image = obj['image'] - if isinstance(image, dict) and 'url_list' in image and image['url_list']: - cover_image_url = image['url_list'][0] - cover_image_backup_urls = image['url_list'][1:] if len(image['url_list']) > 1 else [] - elif isinstance(image, str): - cover_image_url = image - elif 'pic' in obj: - pic = obj['pic'] - if isinstance(pic, dict) and 'url_list' in pic and pic['url_list']: - cover_image_url = pic['url_list'][0] - cover_image_backup_urls = pic['url_list'][1:] if len(pic['url_list']) > 1 else [] - elif isinstance(pic, str): - cover_image_url = pic + # 查找封面图片字段,优先获取完整的URL链接 + if 'cover' in obj: + cover = obj['cover'] + if isinstance(cover, dict) and 'url_list' in cover and cover['url_list']: + # 主链接 + cover_image_url = cover['url_list'][0] + # 备用链接 + cover_image_backup_urls = cover['url_list'][1:] if len(cover['url_list']) > 1 else [] + elif isinstance(cover, str): + cover_image_url = cover + elif 'cover_url' in obj: + cover_url = obj['cover_url'] + if isinstance(cover_url, dict) and 'url_list' in cover_url and cover_url['url_list']: + cover_image_url = cover_url['url_list'][0] + cover_image_backup_urls = cover_url['url_list'][1:] if len(cover_url['url_list']) > 1 else [] + elif isinstance(cover_url, str): + cover_image_url = cover_url + elif 'image' in obj: + image = obj['image'] + if isinstance(image, dict) and 'url_list' in image and image['url_list']: + cover_image_url = image['url_list'][0] + cover_image_backup_urls = image['url_list'][1:] if len(image['url_list']) > 1 else [] + elif isinstance(image, str): + cover_image_url = image + elif 'pic' in obj: + pic = obj['pic'] + if isinstance(pic, dict) and 'url_list' in pic and pic['url_list']: + cover_image_url = pic['url_list'][0] + cover_image_backup_urls = pic['url_list'][1:] if len(pic['url_list']) > 1 else [] + elif isinstance(pic, str): + cover_image_url = pic - # 提取新增的五个字段 - series_author = "" - desc = "" - updated_to_episode = 0 - manufacturing_field = "" # 承制信息 - copyright_field = "" # 版权信息 + # 提取新增的五个字段 + series_author = "" + desc = "" + updated_to_episode = 0 + manufacturing_field = "" # 承制信息 + copyright_field = "" # 版权信息 - # 提取合集作者/影视工作室 - if 'author' in obj: - author = obj['author'] - if isinstance(author, dict): - # 尝试多个可能的作者字段 - series_author = (author.get('nickname') or - author.get('unique_id') or - author.get('short_id') or - author.get('name') or '') - elif isinstance(author, str): - series_author = author - elif 'creator' in obj: - creator = obj['creator'] - if isinstance(creator, dict): - series_author = (creator.get('nickname') or - creator.get('unique_id') or - creator.get('name') or '') - elif isinstance(creator, str): - series_author = creator - elif 'user' in obj: - user = obj['user'] - if isinstance(user, dict): - series_author = (user.get('nickname') or - user.get('unique_id') or - user.get('name') or '') - elif isinstance(user, str): - series_author = user + # 提取合集作者/影视工作室 + if 'author' in obj: + author = obj['author'] + if isinstance(author, dict): + # 尝试多个可能的作者字段 + series_author = (author.get('nickname') or + author.get('unique_id') or + author.get('short_id') or + author.get('name') or '') + elif isinstance(author, str): + series_author = author + elif 'creator' in obj: + creator = obj['creator'] + if isinstance(creator, dict): + series_author = (creator.get('nickname') or + creator.get('unique_id') or + creator.get('name') or '') + elif isinstance(creator, str): + series_author = creator + elif 'user' in obj: + user = obj['user'] + if isinstance(user, dict): + series_author = (user.get('nickname') or + user.get('unique_id') or + user.get('name') or '') + elif isinstance(user, str): + series_author = user - # 提取合集描述 - 扩展更多可能的字段 - description_fields = ['desc', 'share_info'] # 保持字段列表 + # 提取合集描述 - 扩展更多可能的字段 + description_fields = ['desc', 'share_info'] # 保持字段列表 # 先检查desc字段 if 'desc' in obj and obj['desc']: @@ -999,9 +1003,9 @@ class DouyinPlayVVScraper: self.play_vv_items.remove(existing_item) self.play_vv_items.append(item_data) else: - # 已有数据更好,跳过 + # 已有数据更好,跳过当前数据但继续递归解析其他数据 logging.info(f'⏭️ 跳过重复短剧: {mix_name} (当前: {vv:,}, 已有: {existing_vv:,})') - return # 跳过当前数据 + # 注意:不使用return,避免中断递归解析 else: # 不存在,直接添加 self.play_vv_items.append(item_data) @@ -1049,14 +1053,20 @@ class DouyinPlayVVScraper: vv = int(match.group(3)) episodes = int(match.group(4)) - # 数据验证:确保播放量大于0且合集名称不为空 + # 数据验证:确保有mix_id(按短剧ID去重) + # 注意:播放量为0的数据也会被保存,可能是新发布的短剧 if vv <= 0: - logging.warning(f"正则提取跳过无效的播放量数据: mix_name={mix_name}, play_vv={vv}") + logging.warning(f"⚠️ 发现播放量为0的数据: mix_name={mix_name}, play_vv={vv},仍会保存") + + # 检查mix_id,如果没有则跳过 + if not mix_id or mix_id.strip() == "": + logging.warning(f"正则提取跳过缺少mix_id的数据: play_vv={vv}, mix_name={mix_name}") continue + # 如果mix_name为空,使用mix_id作为名称 if not mix_name or mix_name.strip() == "": - logging.warning(f"正则提取跳过缺少合集名称的数据: play_vv={vv}") - continue + mix_name = f"短剧_{mix_id}" + logging.warning(f"⚠️ mix_name为空,使用mix_id作为名称: {mix_name}") # 构建合集链接 video_url = f"https://www.douyin.com/collection/{mix_id}" if mix_id else "" @@ -1088,10 +1098,9 @@ class DouyinPlayVVScraper: for match in re.findall(r'"play_vv"\s*:\s*(\d+)', text): try: vv = int(match) - # 数据验证:跳过无效的播放量数据 + # 数据验证:播放量为0的数据也会被保存 if vv <= 0: - logging.warning(f"跳过无效的播放量数据: play_vv={vv}") - continue + logging.warning(f"⚠️ 发现播放量为0的数据: play_vv={vv},仍会保存") # 检查是否已经存在相同的play_vv if not any(item['play_vv'] == vv for item in self.play_vv_items): @@ -1208,10 +1217,9 @@ class DouyinPlayVVScraper: for m in re.findall(r'"statis"\s*:\s*\{[^}]*"play_vv"\s*:\s*(\d+)[^}]*\}', page_source): try: vv = int(m) - # 数据验证:跳过无效的播放量数据 + # 数据验证:播放量为0的数据也会被保存 if vv <= 0: - logging.warning(f"跳过无效的播放量数据: play_vv={vv}") - continue + logging.warning(f"⚠️ 发现播放量为0的数据: play_vv={vv},仍会保存") # 检查是否已经存在相同的play_vv if not any(item['play_vv'] == vv for item in self.play_vv_items): From 8f231a7c8efe8d5901b2e63d8bde1dfb43971373 Mon Sep 17 00:00:00 2001 From: qiaoyirui0819 <3160533978@qq.com> Date: Sun, 9 Nov 2025 19:03:04 +0800 Subject: [PATCH 14/21] =?UTF-8?q?=E6=A8=A1=E6=8B=9F=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=9C=A8=E6=94=B6=E8=97=8F=E5=90=88=E9=9B=86=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handlers/Rankings/rank_data_scraper.py | 1288 ++++++++++------- 1 file changed, 784 insertions(+), 504 deletions(-) diff --git a/backend/handlers/Rankings/rank_data_scraper.py b/backend/handlers/Rankings/rank_data_scraper.py index a682bb5..a71d3ef 100644 --- a/backend/handlers/Rankings/rank_data_scraper.py +++ b/backend/handlers/Rankings/rank_data_scraper.py @@ -28,9 +28,11 @@ import base64 import uuid import sys import psutil +from typing import Dict, List, Optional, Set import random import threading import argparse +from typing import Dict, List, Optional, Set from concurrent.futures import ThreadPoolExecutor # 使用线程池实现异步滑动和监控 from selenium import webdriver @@ -68,6 +70,633 @@ logging.basicConfig( ) +class UnifiedDataCollector: + """统一数据收集器 - 解决数据重复和抓取不全问题""" + + def __init__(self, driver, duration_s: int = 60): + self.driver = driver + self.duration_s = duration_s + + # 统一数据存储 - 按mix_id去重 + self.collected_items: Dict[str, dict] = {} + + # 数据源统计 + self.source_stats = { + 'network': 0, + 'ssr': 0, + 'page': 0, + 'filtered': 0 + } + + # 已知请求ID集合,用于去重 + self.known_request_ids: Set[str] = set() + + # 目标关键词(收藏/合集/视频) + self.url_keywords = ['aweme', 'mix', 'collection', 'favorite', 'note', 'api'] + + # 是否在网络收集过程中周期性触发滚动加载(默认关闭以避免浪费时间) + self.enable_network_scroll: bool = False + + logging.info('统一数据收集器初始化完成') + + def collect_all_data(self) -> List[dict]: + """统一的数据收集入口 - 整合所有数据源""" + logging.info('开始统一数据收集') + + # 重置统计 + self.source_stats = {'network': 0, 'ssr': 0, 'page': 0, 'filtered': 0} + + # 按优先级收集数据 + self._collect_from_network() + self._collect_from_ssr() + self._collect_from_page() + + # 输出统计信息 + self._log_collection_stats() + + return list(self.collected_items.values()) + + def _collect_from_network(self): + """从网络API监控收集数据""" + logging.info('开始网络API数据收集') + start_time = time.time() + last_scroll_time = start_time + + while time.time() - start_time < self.duration_s: + try: + logs = self.driver.get_log('performance') + except Exception as e: + logging.warning(f'获取性能日志失败: {e}') + time.sleep(1) + continue + + for entry in logs: + try: + message = json.loads(entry['message'])['message'] + method = message.get('method') + params = message.get('params', {}) + + # 响应到达,尝试获取响应体 + if method == 'Network.responseReceived': + req_id = params.get('requestId') + url = params.get('response', {}).get('url', '') + type_ = params.get('type') # XHR, Fetch, Document + + if req_id and req_id not in self.known_request_ids: + self.known_request_ids.add(req_id) + + # 仅处理XHR/Fetch + if type_ in ('XHR', 'Fetch') and any(k in url for k in self.url_keywords): + try: + body_obj = self.driver.execute_cdp_cmd('Network.getResponseBody', {'requestId': req_id}) + body_text = body_obj.get('body', '') + + # 可能是base64编码 + if body_obj.get('base64Encoded'): + try: + body_text = base64.b64decode(body_text).decode('utf-8', errors='ignore') + except Exception: + pass + + # 解析数据 + self._parse_and_add_item(body_text, url, req_id, 'network') + + except Exception: + # 某些响应不可获取或过大 + pass + except Exception: + continue + + # 在收集过程中定期触发数据加载(默认关闭) + if self.enable_network_scroll: + current_time = time.time() + if current_time - last_scroll_time > 15: # 降低频率:每15秒 + # 若检测到底部则不再滚动 + if not self._check_no_more_content(): + self._trigger_mini_scroll() + last_scroll_time = current_time + + time.sleep(0.8) + + logging.info(f'网络API数据收集完成,发现 {self.source_stats["network"]} 个有效项') + + def _trigger_mini_scroll(self): + """在数据收集过程中触发滚动加载数据 - 增强版滚动机制""" + try: + logging.info('开始触发滚动加载数据...') + + # 方式1:强力滚动策略 - 模拟真实用户行为 + try: + # 强力滚动:多次大幅度滚动确保触发懒加载 + for i in range(5): + # 计算滚动距离,递增以确保效果 + scroll_distance = 800 + (i * 300) + + # 执行强力滚动 + self.driver.execute_script(f""" + // 1. 强制滚动页面 + window.scrollBy(0, {scroll_distance}); + document.documentElement.scrollTop += {scroll_distance}; + document.body.scrollTop += {scroll_distance}; + + // 2. 滚动到页面底部(触发懒加载) + window.scrollTo(0, document.body.scrollHeight); + + // 3. 查找并滚动所有可能的容器 + const containers = document.querySelectorAll('[data-e2e="comment-list"], .comment-list, [class*="comment"], [class*="scroll"], [role="main"]'); + containers.forEach(container => {{ + if (container.scrollTop !== undefined) {{ + container.scrollTop = container.scrollHeight; + container.dispatchEvent(new Event('scroll', {{ bubbles: true }})); + }} + }}); + + // 4. 触发所有相关事件 + ['scroll', 'wheel', 'touchmove', 'resize'].forEach(eventType => {{ + window.dispatchEvent(new Event(eventType, {{ bubbles: true }})); + document.dispatchEvent(new Event(eventType, {{ bubbles: true }})); + }}); + + // 5. 模拟用户交互 + document.body.click(); + + console.log('执行强力滚动:', {scroll_distance}, 'px'); + """) + + logging.info(f'第{i+1}次强力滚动,距离: {scroll_distance}px') + time.sleep(2) # 等待数据加载 + + # 检查是否有新数据加载 + current_height = self.driver.execute_script("return document.body.scrollHeight;") + logging.info(f'当前页面高度: {current_height}px') + + # 检查是否到达底部 + if self._check_no_more_content(): + logging.info('检测到页面底部,停止滚动') + break + + return + except Exception as e: + logging.debug(f'强力滚动失败: {e}') + + # 方式2:尝试滚动到特定元素 + try: + # 查找可能的加载更多按钮或元素 + load_more_selectors = [ + "[data-e2e='load-more']", + "[class*='load-more']", + "[class*='loadmore']", + "[class*='more']", + "button", + "[role='button']" + ] + + for selector in load_more_selectors: + try: + elements = self.driver.find_elements(By.CSS_SELECTOR, selector) + for element in elements: + if element.is_displayed(): + # 滚动到元素 + self.driver.execute_script("arguments[0].scrollIntoView();", element) + logging.info(f'滚动到元素: {selector}') + time.sleep(2) + # 尝试点击 + try: + element.click() + logging.info(f'点击加载更多按钮: {selector}') + time.sleep(3) + except: + pass + return + except: + continue + except Exception as e: + logging.debug(f'滚动到元素失败: {e}') + + # 方式3:渐进式滚动 + try: + current_position = self.driver.execute_script("return window.pageYOffset;") + page_height = self.driver.execute_script("return document.body.scrollHeight;") + window_height = self.driver.execute_script("return window.innerHeight;") + + logging.info(f'当前位置: {current_position}px, 页面高度: {page_height}px, 窗口高度: {window_height}px') + + # 如果页面高度很小,说明没有数据,需要触发加载 + if page_height < 2000: + # 多次滚动触发数据加载 + for i in range(5): + self.driver.execute_script(f"window.scrollTo(0, {500 * (i+1)});") + logging.info(f'渐进滚动 {i+1}: {500 * (i+1)}px') + time.sleep(2) + else: + # 正常滚动 + scroll_distance = min(1000, page_height - current_position - window_height) + if scroll_distance > 100: + new_position = current_position + scroll_distance + self.driver.execute_script(f'window.scrollTo(0, {new_position});') + logging.info(f'滚动到位置: {new_position}px') + time.sleep(2) + + return + except Exception as e: + logging.debug(f'渐进式滚动失败: {e}') + + # 方式4:检查是否已显示"暂时没有更多了" + if self._check_no_more_content(): + logging.info('已到达页面底部:暂时没有更多了') + return + + logging.info('滚动完成,等待数据加载...') + + except Exception as e: + logging.error(f'滚动触发失败: {e}') + + def _check_no_more_content(self) -> bool: + """检查是否已到达页面底部,没有更多内容""" + try: + # 检查多种可能的底部标识文本 + bottom_indicators = [ + "暂时没有更多了", + "没有更多内容", + "已加载全部", + "加载完毕" + ] + + for indicator in bottom_indicators: + try: + result = self.driver.execute_script(f""" + var elements = document.querySelectorAll('*'); + for (var i = 0; i < elements.length; i++) {{ + var text = elements[i].textContent || elements[i].innerText; + if (text.includes('{indicator}')) {{ + return true; + }} + }} + return false; + """) + if result: + logging.debug(f'检测到页面底部标识: "{indicator}"') + return True + except Exception: + continue + + return False + except Exception as e: + logging.debug(f'检查页面底部失败: {e}') + return False + + def _trigger_scroll_during_collection(self): + """在数据收集过程中触发数据加载 - 简化版,仅使用滚动""" + logging.info('在数据收集过程中触发滚动加载') + + try: + # 获取初始数据量 + initial_count = len(self.collected_items) + logging.info(f'滚动前数据量: {initial_count} 个短剧') + + # 仅使用强力滚动策略,不进行不必要的刷新和按钮点击 + self._trigger_mini_scroll() + + # 检查是否有新数据加载 + final_count = len(self.collected_items) + total_new = final_count - initial_count + logging.info(f'滚动加载完成: 初始 {initial_count} → 最终 {final_count} 个短剧 (总共新增: {total_new} 个)') + + except Exception as e: + logging.warning(f'滚动加载过程中出错: {e}') + + + def _collect_from_ssr(self): + """从SSR数据收集数据""" + logging.info('开始SSR数据收集') + + # 尝试直接从window对象获取 + keys = ['_SSR_HYDRATED_DATA', 'RENDER_DATA'] + for key in keys: + try: + data = self.driver.execute_script(f'return window.{key}') + if data: + text = json.dumps(data, ensure_ascii=False) + self._parse_and_add_item(text, f'page_{key}', None, 'ssr') + logging.info(f'从 {key} 中解析完成') + except Exception: + continue + + logging.info(f'SSR数据收集完成,发现 {self.source_stats["ssr"]} 个有效项') + + def _collect_from_page(self): + """从页面解析收集数据(兜底方案)""" + logging.info('开始页面数据收集(兜底方案)') + + try: + page_source = self.driver.page_source + self._parse_and_add_item(page_source, 'page_source', None, 'page') + + # 同时尝试识别statis结构中的play_vv + for m in re.findall(r'"statis"\s*:\s*\{[^}]*"play_vv"\s*:\s*(\d+)[^}]*\}', page_source): + try: + vv = int(m) + # 从页面源码中无法获取完整的合集信息,跳过这些不完整的数据 + logging.debug(f'从页面源码statis中发现播放量: {vv},但缺少完整信息,跳过') + except Exception: + pass + + except Exception: + pass + + logging.info(f'页面数据收集完成,发现 {self.source_stats["page"]} 个有效项') + + def _parse_and_add_item(self, text: str, source_url: str, request_id: str, source_type: str): + """解析文本数据并添加到统一存储""" + try: + # 尝试解析JSON数据 + if text.strip().startswith('{') or text.strip().startswith('['): + try: + data = json.loads(text) + self._extract_from_json_data(data, source_url, request_id, source_type) + return + except json.JSONDecodeError: + pass + + # 如果不是JSON,使用正则表达式查找 + self._extract_from_text_regex(text, source_url, request_id, source_type) + + except Exception as e: + logging.debug(f'解析 {source_type} 数据时出错: {e}') + + def _extract_from_json_data(self, data, source_url: str, request_id: str, source_type: str): + """从JSON数据中递归提取合集信息""" + def extract_mix_info(obj, path=""): + if isinstance(obj, dict): + # 检查是否包含有效的合集信息 + if self._is_valid_collection_data(obj): + item_data = self._build_item_data(obj, source_url, request_id, source_type) + if item_data: + self._add_item_with_validation(item_data, source_type) + + # 递归搜索子对象 + for key, value in obj.items(): + if isinstance(value, (dict, list)): + extract_mix_info(value, f"{path}.{key}" if path else key) + + elif isinstance(obj, list): + for i, item in enumerate(obj): + if isinstance(item, (dict, list)): + extract_mix_info(item, f"{path}[{i}]" if path else f"[{i}]") + + extract_mix_info(data) + + def _extract_from_text_regex(self, text: str, source_url: str, request_id: str, source_type: str): + """使用正则表达式从文本中提取信息""" + # 查找包含完整合集信息的JSON片段 + mix_pattern = r'\{[^{}]*"mix_id"\s*:\s*"([^"]*)"[^{}]*"mix_name"\s*:\s*"([^"]*)"[^{}]*"statis"\s*:\s*\{[^{}]*"play_vv"\s*:\s*(\d+)[^{}]*\}[^{}]*\}' + + for match in re.finditer(mix_pattern, text): + try: + mix_id = match.group(1) + mix_name = match.group(2) + vv = int(match.group(3)) + + # 构建基础数据 + item_data = { + 'mix_id': mix_id, + 'mix_name': mix_name, + 'play_vv': vv, + 'url': source_url, + 'request_id': request_id, + 'source_type': source_type, + 'timestamp': datetime.now().isoformat() + } + + # 验证并添加 + if self._validate_item(item_data): + self._add_item_with_validation(item_data, source_type) + + except Exception: + continue + + def _is_valid_collection_data(self, obj: dict) -> bool: + """检查是否为有效的收藏合集数据""" + # 必须有mix_id和statis字段 + if 'mix_id' not in obj or 'statis' not in obj: + return False + + # statis必须是字典且包含play_vv + statis = obj.get('statis', {}) + if not isinstance(statis, dict) or 'play_vv' not in statis: + return False + + # play_vv必须是有效数字 + play_vv = statis.get('play_vv') + if not isinstance(play_vv, (int, str)): + return False + + try: + vv = int(play_vv) + # 收藏合集的短剧播放量不可能为0 + if vv <= 0: + return False + except (ValueError, TypeError): + return False + + return True + + def _build_item_data(self, obj: dict, source_url: str, request_id: str, source_type: str) -> Optional[dict]: + """构建标准化的数据项""" + try: + mix_id = obj.get('mix_id', '') + mix_name = obj.get('mix_name', '') + + # 获取播放量(与_is_valid_collection_data方法保持一致) + play_vv = 0 + + # 方式1:从statis字段获取 + if 'statis' in obj and isinstance(obj['statis'], dict): + statis = obj['statis'] + if 'play_vv' in statis: + play_vv = statis['play_vv'] + + # 方式2:直接从对象中获取play_vv + if play_vv == 0 and 'play_vv' in obj: + play_vv = obj['play_vv'] + + # 方式3:从其他可能的字段获取 + if play_vv == 0: + for field in ['play_count', 'view_count', 'vv']: + if field in obj: + play_vv = obj[field] + break + + # 转换为整数 + if isinstance(play_vv, str) and play_vv.isdigit(): + play_vv = int(play_vv) + + # 数据验证 + if not mix_id or play_vv <= 0: + return None + + # 如果mix_name为空,使用mix_id作为名称 + if not mix_name or mix_name.strip() == "": + mix_name = f"短剧_{mix_id}" + logging.warning(f"⚠️ mix_name为空,使用mix_id作为名称: {mix_name}") + + # 构建合集链接 + video_url = f"https://www.douyin.com/collection/{mix_id}" if mix_id else "" + + # 构建标准数据项 + item_data = { + 'mix_id': mix_id, + 'mix_name': mix_name, + 'play_vv': play_vv, + 'formatted': self._format_count(play_vv), + 'url': source_url, + 'request_id': request_id, + 'video_url': video_url, + 'source_type': source_type, + 'timestamp': datetime.now().isoformat() + } + + # 提取额外字段 + self._extract_additional_fields(obj, item_data) + + return item_data + + except Exception as e: + logging.debug(f'构建数据项失败: {e}') + return None + + def _extract_additional_fields(self, obj: dict, item_data: dict): + """提取额外的字段信息""" + # 提取合集封面图片URL + cover_image_url = "" + cover_image_backup_urls = [] + + # 查找封面图片字段 + for field in ['cover', 'cover_url', 'image', 'pic']: + if field in obj: + field_data = obj[field] + if isinstance(field_data, dict) and 'url_list' in field_data and field_data['url_list']: + cover_image_url = field_data['url_list'][0] + cover_image_backup_urls = field_data['url_list'][1:] if len(field_data['url_list']) > 1 else [] + break + elif isinstance(field_data, str): + cover_image_url = field_data + break + + item_data['cover_image_url'] = cover_image_url + item_data['cover_backup_urls'] = cover_image_backup_urls + + # 提取合集作者/影视工作室 + series_author = "" + for author_field in ['author', 'creator', 'user']: + if author_field in obj: + author_data = obj[author_field] + if isinstance(author_data, dict): + series_author = (author_data.get('nickname') or + author_data.get('unique_id') or + author_data.get('short_id') or + author_data.get('name') or '') + break + elif isinstance(author_data, str): + series_author = author_data + break + + item_data['series_author'] = series_author + + # 提取合集描述 + desc = "" + if 'desc' in obj and obj['desc']: + desc_value = str(obj['desc']).strip() + if desc_value: + desc = desc_value + + item_data['desc'] = desc + + # 提取合集总集数 + updated_to_episode = 0 + if 'statis' in obj and isinstance(obj['statis'], dict): + statis = obj['statis'] + if 'updated_to_episode' in statis: + try: + episodes = int(statis['updated_to_episode']) + if episodes > 0: + updated_to_episode = episodes + except ValueError: + pass + + item_data['updated_to_episode'] = updated_to_episode + + def _validate_item(self, item_data: dict) -> bool: + """验证数据项的有效性""" + # 基本字段验证 + mix_id = item_data.get('mix_id', '') + mix_name = item_data.get('mix_name', '') + play_vv = item_data.get('play_vv', 0) + + # 必须有mix_id和mix_name + if not mix_id or not mix_name: + return False + + # 播放量必须大于0(收藏合集的短剧不可能为0) + if play_vv <= 0: + return False + + # 排除占位名称 + if mix_name.startswith('短剧_') or '未知' in mix_name: + return False + + return True + + def _add_item_with_validation(self, item_data: dict, source_type: str): + """验证并添加数据项,包含实时去重""" + if not self._validate_item(item_data): + self.source_stats['filtered'] += 1 + return + + mix_id = item_data.get('mix_id') + + # 实时去重:保留播放量最大的版本 + if mix_id in self.collected_items: + existing = self.collected_items[mix_id] + current_play_vv = item_data.get('play_vv', 0) + existing_play_vv = existing.get('play_vv', 0) + + if current_play_vv > existing_play_vv: + # 当前数据更好,替换 + self.collected_items[mix_id] = item_data + logging.info(f'🔄 更新重复短剧: {item_data.get("mix_name")} (播放量: {existing_play_vv:,} → {current_play_vv:,})') + else: + # 已有数据更好,跳过 + logging.info(f'⏭️ 跳过重复短剧: {item_data.get("mix_name")} (当前: {current_play_vv:,}, 已有: {existing_play_vv:,})') + + # 记录去重统计 + logging.debug(f'去重统计: mix_id={mix_id}, 已有播放量={existing_play_vv:,}, 新播放量={current_play_vv:,}, 是否更新={current_play_vv > existing_play_vv}') + else: + # 新数据,直接添加 + self.collected_items[mix_id] = item_data + self.source_stats[source_type] += 1 + logging.info(f'✅ 添加新短剧: {item_data.get("mix_name")} - {item_data.get("play_vv", 0):,} 播放量') + + def _format_count(self, n: int) -> str: + """格式化数字显示""" + if n >= 100_000_000: + return f"{n/100_000_000:.1f}亿" + if n >= 10_000: + return f"{n/10_000:.1f}万" + return str(n) + + def _log_collection_stats(self): + """输出收集统计信息""" + logging.info('=' * 60) + logging.info('统一数据收集统计:') + logging.info(f' - 网络API: {self.source_stats["network"]} 个') + logging.info(f' - SSR数据: {self.source_stats["ssr"]} 个') + logging.info(f' - 页面解析: {self.source_stats["page"]} 个') + logging.info(f' - 过滤无效: {self.source_stats["filtered"]} 个') + logging.info(f' - 最终结果: {len(self.collected_items)} 个唯一短剧') + logging.info('=' * 60) + + class DouyinPlayVVScraper: def __init__(self, start_url: str = None, auto_continue: bool = False, duration_s: int = 60): self.start_url = start_url or "https://www.douyin.com/user/self?showTab=favorite_collection&showSubTab=compilation" @@ -681,37 +1310,127 @@ class DouyinPlayVVScraper: return True # 改为假设已登录,避免卡住 def trigger_loading(self): - logging.info('触发数据加载:滚动 + 刷新') + logging.info('触发数据加载:强力滚动直到"暂时没有更多了"') - # 在auto_continue模式下增加页面加载等待时间 - if self.auto_continue: - logging.info('自动继续模式:增加页面加载等待时间') - time.sleep(10) # 增加到10秒,确保页面完全加载 + # 等待页面完全加载 + logging.info('等待页面完全加载...') + time.sleep(10) + + # 强力滚动策略 - 模拟真实用户行为,直到看到"暂时没有更多了" + max_scroll_attempts = 50 # 最大滚动尝试次数 + scroll_count = 0 + no_more_content_found = False + + while scroll_count < max_scroll_attempts and not no_more_content_found: + try: + scroll_count += 1 + logging.info(f'第{scroll_count}次强力滚动...') + + # 强力滚动:多次大幅度滚动确保触发懒加载 + scroll_distance = 800 + (scroll_count * 200) + + # 执行强力滚动JavaScript + self.driver.execute_script(f""" + // 1. 强制滚动页面 + window.scrollBy(0, {scroll_distance}); + document.documentElement.scrollTop += {scroll_distance}; + document.body.scrollTop += {scroll_distance}; + + // 2. 滚动到页面底部(触发懒加载) + window.scrollTo(0, document.body.scrollHeight); + + // 3. 查找并滚动所有可能的容器 + const containers = document.querySelectorAll('[data-e2e="comment-list"], .comment-list, [class*="comment"], [class*="scroll"], [role="main"], [class*="collection"], [class*="favorite"]'); + containers.forEach(container => {{ + if (container.scrollTop !== undefined) {{ + container.scrollTop = container.scrollHeight; + container.dispatchEvent(new Event('scroll', {{ bubbles: true }})); + }} + }}); + + // 4. 触发所有相关事件 + ['scroll', 'wheel', 'touchmove', 'resize'].forEach(eventType => {{ + window.dispatchEvent(new Event(eventType, {{ bubbles: true }})); + document.dispatchEvent(new Event(eventType, {{ bubbles: true }})); + }}); + + // 5. 模拟用户交互 + document.body.click(); + + console.log('执行强力滚动:', {scroll_distance}, 'px'); + """) + + # 等待数据加载 + time.sleep(3) + + # 检查是否有新数据加载 + current_height = self.driver.execute_script("return document.body.scrollHeight;") + logging.info(f'当前页面高度: {current_height}px') + + # 检查是否到达底部 - 看到"暂时没有更多了" + no_more_content_found = self._check_no_more_content() + if no_more_content_found: + logging.info('✅ 检测到页面底部:"暂时没有更多了",停止滚动') + break + + # 检查页面高度是否不再增加(说明没有新内容加载) + if scroll_count > 5: + previous_height = current_height + time.sleep(2) + new_height = self.driver.execute_script("return document.body.scrollHeight;") + if new_height == previous_height: + logging.info('页面高度不再增加,可能已加载全部内容') + break + + except Exception as e: + logging.error(f'滚动过程中出错: {e}') + time.sleep(2) + + if no_more_content_found: + logging.info('🎉 成功滚动到页面底部,所有内容已加载完成') else: - # 普通模式也需要增加页面加载等待时间 - logging.info('普通模式:增加页面加载等待时间') - time.sleep(10) # 增加到10秒,确保页面完全加载 + logging.info(f'达到最大滚动次数 {max_scroll_attempts},停止滚动') - # 第一轮滚动:触发懒加载 - logging.info('第一轮滚动:触发懒加载') - for i in range(10): # 增加滚动次数 - self.driver.execute_script(f'window.scrollTo(0, {i * 900});') - time.sleep(1.5) # 增加等待时间 - - # 等待数据加载 - logging.info('等待数据加载...') - time.sleep(5) - - # 刷新触发新请求 - logging.info('刷新页面触发新请求') - self.driver.refresh() - time.sleep(6) # 增加刷新后的等待时间 - - # 第二轮滚动:确保所有数据加载 - logging.info('第二轮滚动:确保所有数据加载') - for i in range(8): - self.driver.execute_script(f'window.scrollTo(0, {i * 1200});') - time.sleep(1.5) + # 最终检查一次是否还有更多内容 + final_check = self._check_no_more_content() + if not final_check: + logging.info('⚠️ 最终检查:可能还有更多内容未加载') + + def _check_no_more_content(self) -> bool: + """检查是否已到达页面底部,没有更多内容""" + try: + # 检查多种可能的底部标识文本 + bottom_indicators = [ + "暂时没有更多了", + "没有更多内容", + "已加载全部", + "加载完毕", + "no more content", + "end of content" + ] + + for indicator in bottom_indicators: + try: + result = self.driver.execute_script(f""" + var elements = document.querySelectorAll('*'); + for (var i = 0; i < elements.length; i++) {{ + var text = elements[i].textContent || elements[i].innerText; + if (text.includes('{indicator}')) {{ + return true; + }} + }} + return false; + """) + if result: + logging.info(f'✅ 检测到页面底部标识: "{indicator}"') + return True + except Exception: + continue + + return False + except Exception as e: + logging.debug(f'检查页面底部失败: {e}') + return False def format_count(self, n: int) -> str: if n >= 100_000_000: @@ -774,463 +1493,9 @@ class DouyinPlayVVScraper: except Exception as e: logging.error(f'保存评论失败: {e}') - return None - - def parse_play_vv_from_text(self, text: str, source_url: str, request_id: str = None): - """解析文本中的play_vv、mix_name和watched_item信息""" - try: - # 尝试解析JSON数据 - if text.strip().startswith('{') or text.strip().startswith('['): - try: - data = json.loads(text) - self._extract_from_json_data(data, source_url, request_id) - return - except json.JSONDecodeError: - pass - - # 如果不是JSON,使用正则表达式查找 - self._extract_from_text_regex(text, source_url, request_id) - - except Exception as e: - logging.warning(f'解析文本数据时出错: {e}') + return None - def _extract_from_json_data(self, data, source_url: str, request_id: str = None): - """从JSON数据中递归提取合集信息""" - def extract_mix_info(obj, path=""): - if isinstance(obj, dict): - # 检查是否包含合集信息 - if 'mix_id' in obj and 'statis' in obj: - mix_id = obj.get('mix_id', '') - mix_name = obj.get('mix_name', '') - statis = obj.get('statis', {}) - - if isinstance(statis, dict) and 'play_vv' in statis: - play_vv = statis.get('play_vv') - if isinstance(play_vv, (int, str)) and str(play_vv).isdigit(): - vv = int(play_vv) - - # 数据验证:确保有mix_id(按短剧ID去重,所以必须有mix_id) - if not mix_id or mix_id.strip() == "": - logging.warning(f"跳过缺少mix_id的数据: play_vv={vv}, mix_name={mix_name}") - # 跳过当前项,但继续递归解析其他数据(不使用return) - else: - # 如果mix_name为空,使用mix_id作为名称 - if not mix_name or mix_name.strip() == "": - mix_name = f"短剧_{mix_id}" - logging.warning(f"⚠️ mix_name为空,使用mix_id作为名称: {mix_name}") - # 🔧 修复:不跳过播放量为0的数据,而是标记并保留 - # 这些数据可能是因为页面加载不完整,但合集本身是存在的 - # 警告信息移到去重检查之后,只有真正添加时才警告 - - # 构建合集链接 - video_url = f"https://www.douyin.com/collection/{mix_id}" if mix_id else "" - - # 提取合集封面图片URL - 直接存储完整的图片链接 - cover_image_url = "" - cover_image_backup_urls = [] # 备用链接列表 - # 查找封面图片字段,优先获取完整的URL链接 - if 'cover' in obj: - cover = obj['cover'] - if isinstance(cover, dict) and 'url_list' in cover and cover['url_list']: - # 主链接 - cover_image_url = cover['url_list'][0] - # 备用链接 - cover_image_backup_urls = cover['url_list'][1:] if len(cover['url_list']) > 1 else [] - elif isinstance(cover, str): - cover_image_url = cover - elif 'cover_url' in obj: - cover_url = obj['cover_url'] - if isinstance(cover_url, dict) and 'url_list' in cover_url and cover_url['url_list']: - cover_image_url = cover_url['url_list'][0] - cover_image_backup_urls = cover_url['url_list'][1:] if len(cover_url['url_list']) > 1 else [] - elif isinstance(cover_url, str): - cover_image_url = cover_url - elif 'image' in obj: - image = obj['image'] - if isinstance(image, dict) and 'url_list' in image and image['url_list']: - cover_image_url = image['url_list'][0] - cover_image_backup_urls = image['url_list'][1:] if len(image['url_list']) > 1 else [] - elif isinstance(image, str): - cover_image_url = image - elif 'pic' in obj: - pic = obj['pic'] - if isinstance(pic, dict) and 'url_list' in pic and pic['url_list']: - cover_image_url = pic['url_list'][0] - cover_image_backup_urls = pic['url_list'][1:] if len(pic['url_list']) > 1 else [] - elif isinstance(pic, str): - cover_image_url = pic - - # 提取新增的五个字段 - series_author = "" - desc = "" - updated_to_episode = 0 - manufacturing_field = "" # 承制信息 - copyright_field = "" # 版权信息 - - # 提取合集作者/影视工作室 - if 'author' in obj: - author = obj['author'] - if isinstance(author, dict): - # 尝试多个可能的作者字段 - series_author = (author.get('nickname') or - author.get('unique_id') or - author.get('short_id') or - author.get('name') or '') - elif isinstance(author, str): - series_author = author - elif 'creator' in obj: - creator = obj['creator'] - if isinstance(creator, dict): - series_author = (creator.get('nickname') or - creator.get('unique_id') or - creator.get('name') or '') - elif isinstance(creator, str): - series_author = creator - elif 'user' in obj: - user = obj['user'] - if isinstance(user, dict): - series_author = (user.get('nickname') or - user.get('unique_id') or - user.get('name') or '') - elif isinstance(user, str): - series_author = user - - # 提取合集描述 - 扩展更多可能的字段 - description_fields = ['desc', 'share_info'] # 保持字段列表 - - # 先检查desc字段 - if 'desc' in obj and obj['desc']: - desc_value = str(obj['desc']).strip() - if desc_value: - desc = desc_value - logging.info(f"从desc提取到描述") - - # 如果desc中没有找到有效描述,检查share_info - if not desc and 'share_info' in obj and isinstance(obj['share_info'], dict): - share_desc = obj['share_info'].get('share_desc', '').strip() - if share_desc: - desc = share_desc - logging.info(f"从share_info.share_desc提取到描述") - - # 如果share_info中没有找到有效描述,继续检查desc字段 - if not desc: - for field in description_fields: - if field in obj and obj[field]: - desc_value = str(obj[field]).strip() - if desc_value: - desc = desc_value - logging.info(f"从{field}提取到描述") - break - - # 如果还没有找到描述,尝试从嵌套对象中查找desc字段 - if not desc: - def search_nested_desc(data, depth=0): - if depth > 3: # 限制递归深度 - return None - - if isinstance(data, dict): - # 检查当前层级的desc字段 - if 'desc' in data and data['desc']: - desc_value = str(data['desc']).strip() - if 5 <= len(desc_value) <= 1000: - return desc_value - - # 递归检查嵌套对象 - for value in data.values(): - if isinstance(value, dict): - nested_result = search_nested_desc(value, depth + 1) - if nested_result: - return nested_result - return None - - desc = search_nested_desc(obj) - - - # 提取合集总集数 - 从statis字段中获取 - updated_to_episode = 0 # 初始化默认值 - if 'statis' in obj and isinstance(obj['statis'], dict): - statis = obj['statis'] - if 'updated_to_episode' in statis: - try: - episodes = int(statis['updated_to_episode']) - if episodes > 0: - updated_to_episode = episodes - logging.info(f"从statis.updated_to_episode提取到集数: {episodes}") - except ValueError: - logging.warning("updated_to_episode字段值无法转换为整数") - else: - logging.info("未找到statis字段或statis不是字典类型") - try: - episodes = int(obj['updated_to_episode']) - if episodes > 0: - updated_to_episode = episodes - logging.info(f"从updated_to_episode提取到集数: {episodes}") - except ValueError: - pass # 忽略无法转换为整数的情况 - - # 构建合集数据 - item_data = { - 'play_vv': vv, - 'formatted': self.format_count(vv), - 'url': source_url, - 'request_id': request_id, - 'mix_name': mix_name, - 'video_url': video_url, # 合集链接 - 'mix_id': mix_id, # 合集ID - 'cover_image_url': cover_image_url, # 合集封面图片主链接(完整URL) - 'cover_backup_urls': cover_image_backup_urls, # 封面图片备用链接列表 - 'series_author': series_author, # 合集作者/影视工作室 - 'desc': desc, # 合集描述 - 'updated_to_episode': updated_to_episode, # 合集总集数 - 'timestamp': datetime.now().isoformat() - } - - # 🔧 修复:添加前检查是否已存在(避免重复) - # 检查是否已经有相同mix_id的数据 - existing_item = None - for existing in self.play_vv_items: - if existing.get('mix_id') == mix_id: - existing_item = existing - break - - if existing_item: - # 如果已存在,比较播放量,保留更大的 - existing_vv = existing_item.get('play_vv', 0) - if vv > existing_vv: - # 当前数据更好,替换 - logging.info(f'🔄 更新重复短剧: {mix_name} (播放量: {existing_vv:,} → {vv:,})') - self.play_vv_items.remove(existing_item) - self.play_vv_items.append(item_data) - else: - # 已有数据更好,跳过当前数据但继续递归解析其他数据 - logging.info(f'⏭️ 跳过重复短剧: {mix_name} (当前: {vv:,}, 已有: {existing_vv:,})') - # 注意:不使用return,避免中断递归解析 - else: - # 不存在,直接添加 - self.play_vv_items.append(item_data) - - # 只有在真正添加时,才对播放量为0的数据发出警告 - if vv <= 0: - logging.warning(f"⚠️ 添加了播放量为0的数据: {mix_name} (ID: {mix_id})") - logging.warning(f" 这可能需要后续重新获取播放量") - - # 🔧 修复:不在数据收集阶段进行实时保存 - # 实时保存会触发获取详细内容,导致数据收集中断 - # 改为在数据收集完成后统一处理 - # if self.realtime_save_enabled: - # self.save_single_item_realtime(item_data) - - logging.info(f'提取到合集: {mix_name} (ID: {mix_id}) - {vv:,} 播放量') - if series_author: - logging.info(f' 作者: {series_author}') - if desc: - logging.info(f' 描述: {desc[:100]}{"..." if len(desc) > 100 else ""}') - if updated_to_episode > 0: - logging.info(f' 总集数: {updated_to_episode}') - - # 递归搜索子对象 - for key, value in obj.items(): - if isinstance(value, (dict, list)): - extract_mix_info(value, f"{path}.{key}" if path else key) - - elif isinstance(obj, list): - for i, item in enumerate(obj): - if isinstance(item, (dict, list)): - extract_mix_info(item, f"{path}[{i}]" if path else f"[{i}]") - - extract_mix_info(data) - - def _extract_from_text_regex(self, text: str, source_url: str, request_id: str = None): - """使用正则表达式从文本中提取信息""" - # 查找包含完整合集信息的JSON片段,包括statis中的updated_to_episode - mix_pattern = r'\{[^{}]*"mix_id"\s*:\s*"([^"]*)"[^{}]*"mix_name"\s*:\s*"([^"]*)"[^{}]*"statis"\s*:\s*\{[^{}]*"play_vv"\s*:\s*(\d+)[^{}]*"updated_to_episode"\s*:\s*(\d+)[^{}]*\}[^{}]*\}' - - for match in re.finditer(mix_pattern, text): - try: - mix_id = match.group(1) - mix_name = match.group(2) - vv = int(match.group(3)) - episodes = int(match.group(4)) - - # 数据验证:确保有mix_id(按短剧ID去重) - # 注意:播放量为0的数据也会被保存,可能是新发布的短剧 - if vv <= 0: - logging.warning(f"⚠️ 发现播放量为0的数据: mix_name={mix_name}, play_vv={vv},仍会保存") - - # 检查mix_id,如果没有则跳过 - if not mix_id or mix_id.strip() == "": - logging.warning(f"正则提取跳过缺少mix_id的数据: play_vv={vv}, mix_name={mix_name}") - continue - - # 如果mix_name为空,使用mix_id作为名称 - if not mix_name or mix_name.strip() == "": - mix_name = f"短剧_{mix_id}" - logging.warning(f"⚠️ mix_name为空,使用mix_id作为名称: {mix_name}") - - # 构建合集链接 - video_url = f"https://www.douyin.com/collection/{mix_id}" if mix_id else "" - - if episodes > 0: - logging.info(f"从statis.updated_to_episode提取到集数: {episodes}") - - # 构建合集数据 - item_data = { - 'play_vv': vv, - 'formatted': self.format_count(vv), - 'url': source_url, - 'request_id': request_id, - 'mix_name': mix_name, - 'video_url': video_url, # 合集链接 - 'mix_id': mix_id, # 合集ID - 'updated_to_episode': episodes if episodes > 0 else None, # 从statis.updated_to_episode提取的集数 - 'timestamp': datetime.now().isoformat() - } - - # 添加到列表(保持原有逻辑) - self.play_vv_items.append(item_data) - - logging.info(f'正则提取到合集: {mix_name} (ID: {mix_id}) - {vv:,} 播放量') - except Exception: - continue - - # 兜底:查找单独的play_vv值 - for match in re.findall(r'"play_vv"\s*:\s*(\d+)', text): - try: - vv = int(match) - # 数据验证:播放量为0的数据也会被保存 - if vv <= 0: - logging.warning(f"⚠️ 发现播放量为0的数据: play_vv={vv},仍会保存") - - # 检查是否已经存在相同的play_vv - if not any(item['play_vv'] == vv for item in self.play_vv_items): - # 由于无法获取完整的合集信息,跳过这些不完整的数据 - # 避免产生mix_name为空的无效记录 - logging.warning(f"跳过不完整的数据记录: play_vv={vv}, 缺少合集名称") - continue - except Exception: - continue - - def collect_network_bodies(self, duration_s: int = None): - if duration_s is None: - duration_s = self.duration_s - logging.info(f'开始收集网络响应体,持续 {duration_s}s') - start = time.time() - known_request_ids = set() - - # 目标关键词(收藏/合集/视频) - url_keywords = ['aweme', 'mix', 'collection', 'favorite', 'note', 'api'] - - last_progress = 0 - while time.time() - start < duration_s: - try: - logs = self.driver.get_log('performance') - except Exception as e: - import traceback - error_details = { - 'error_type': type(e).__name__, - 'error_message': str(e), - 'traceback': traceback.format_exc(), - 'context': '获取Chrome性能日志' - } - logging.warning(f'获取性能日志失败: {error_details["error_type"]} - {error_details["error_message"]}') - logging.warning(f'详细错误信息: {error_details["traceback"]}') - logging.warning(f'错误上下文: {error_details["context"]}') - time.sleep(1) - continue - - for entry in logs: - try: - message = json.loads(entry['message'])['message'] - except Exception: - continue - - method = message.get('method') - params = message.get('params', {}) - - # 记录请求URL - if method == 'Network.requestWillBeSent': - req_id = params.get('requestId') - url = params.get('request', {}).get('url', '') - if any(k in url for k in url_keywords): - self.captured_responses.append({'requestId': req_id, 'url': url, 'type': 'request'}) - - # 响应到达,尝试获取响应体 - if method == 'Network.responseReceived': - req_id = params.get('requestId') - url = params.get('response', {}).get('url', '') - type_ = params.get('type') # XHR, Fetch, Document - if req_id and req_id not in known_request_ids: - known_request_ids.add(req_id) - # 仅处理XHR/Fetch - if type_ in ('XHR', 'Fetch') and any(k in url for k in url_keywords): - try: - body_obj = self.driver.execute_cdp_cmd('Network.getResponseBody', {'requestId': req_id}) - body_text = body_obj.get('body', '') - # 可能是base64编码 - if body_obj.get('base64Encoded'): - try: - body_text = base64.b64decode(body_text).decode('utf-8', errors='ignore') - except Exception: - pass - - # 解析play_vv - self.parse_play_vv_from_text(body_text, url, req_id) - except Exception: - # 某些响应不可获取或过大 - pass - elapsed = int(time.time() - start) - if elapsed - last_progress >= 5: - last_progress = elapsed - logging.info(f'进度: {elapsed}/{duration_s}, 目标数量: {len(self.play_vv_items)}') - time.sleep(0.8) - - logging.info(f'网络收集完成,共发现 {len(self.play_vv_items)} 个目标') - logging.info(f'=' * 60) - logging.info(f'网络收集阶段统计:') - logging.info(f' - 总数量: {len(self.play_vv_items)} 个合集') - logging.info(f' - 播放量为0: {sum(1 for item in self.play_vv_items if item.get("play_vv", 0) == 0)} 个') - logging.info(f' - 播放量正常: {sum(1 for item in self.play_vv_items if item.get("play_vv", 0) > 0)} 个') - logging.info(f'=' * 60) - logging.info(f'开始解析SSR数据...') - - - def parse_ssr_data(self): - logging.info('尝试解析页面SSR数据') - # 尝试直接从window对象获取 - keys = ['_SSR_HYDRATED_DATA', 'RENDER_DATA'] - for key in keys: - try: - data = self.driver.execute_script(f'return window.{key}') - if data: - text = json.dumps(data, ensure_ascii=False) - self.parse_play_vv_from_text(text, f'page_{key}', None) - logging.info(f'从 {key} 中解析完成') - except Exception: - continue - - # 兜底:从page_source中正则查找 - try: - page_source = self.driver.page_source - self.parse_play_vv_from_text(page_source, 'page_source', None) - # 同时尝试识别statis结构中的play_vv - for m in re.findall(r'"statis"\s*:\s*\{[^}]*"play_vv"\s*:\s*(\d+)[^}]*\}', page_source): - try: - vv = int(m) - # 数据验证:播放量为0的数据也会被保存 - if vv <= 0: - logging.warning(f"⚠️ 发现播放量为0的数据: play_vv={vv},仍会保存") - - # 检查是否已经存在相同的play_vv - if not any(item['play_vv'] == vv for item in self.play_vv_items): - # 由于从statis中无法获取完整的合集信息,跳过这些不完整的数据 - # 避免产生mix_name为空的无效记录 - logging.warning(f"跳过不完整的数据记录: play_vv={vv}, 来源statis但缺少合集名称") - continue - except Exception: - pass - except Exception: - pass def dedupe(self): # 🔧 修复:按mix_id去重,保留播放量最大的那个 @@ -2291,18 +2556,16 @@ class DouyinPlayVVScraper: # 等待页面加载完成 try: - - WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "video")) ) except Exception as e: logging.warning(f'等待视频元素超时: {e}') - + # 获取网络请求日志 logs = self.driver.get_log('performance') video_info = {} - + for entry in logs: try: log = json.loads(entry['message'])['message'] @@ -2334,7 +2597,7 @@ class DouyinPlayVVScraper: break except Exception as e: logging.warning(f'解析日志条目时出错: {e}') - + return video_info def get_collection_videos(self, mix_id: str, mix_name: str = '', current_episode_count: int = 0) -> list: @@ -3950,34 +4213,51 @@ class DouyinPlayVVScraper: try: # 在开始抓取前清理旧数据(保留最近7天) self.cleanup_old_management_data(days_to_keep=7) - + self.setup_driver() self.navigate() self.ensure_login() self.trigger_loading() - + logging.info('=' * 60) - logging.info('开始数据收集阶段') + logging.info('开始统一数据收集') logging.info('=' * 60) - self.collect_network_bodies() - logging.info(f'✅ 网络数据收集完成:{len(self.play_vv_items)} 个合集') - - self.parse_ssr_data() - logging.info(f'✅ SSR数据解析完成:{len(self.play_vv_items)} 个合集') - + + # 使用统一数据收集器 + collector = UnifiedDataCollector(self.driver, self.duration_s) + collected_data = collector.collect_all_data() + + # 将收集到的数据转换为原有格式 + self.play_vv_items = [] + for item in collected_data: + self.play_vv_items.append({ + 'play_vv': item.get('play_vv', 0), + 'formatted': item.get('formatted', ''), + 'url': item.get('url', ''), + 'request_id': item.get('request_id', ''), + 'mix_name': item.get('mix_name', ''), + 'video_url': item.get('video_url', ''), + 'mix_id': item.get('mix_id', ''), + 'cover_image_url': item.get('cover_image_url', ''), + 'cover_backup_urls': item.get('cover_backup_urls', []), + 'series_author': item.get('series_author', ''), + 'desc': item.get('desc', ''), + 'updated_to_episode': item.get('updated_to_episode', 0), + 'timestamp': item.get('timestamp', '') + }) + + logging.info(f'✅ 统一数据收集完成:{len(self.play_vv_items)} 个合集') + + # 统一数据收集器已实时去重,无需额外去重步骤 logging.info('=' * 60) - logging.info('开始数据去重') + logging.info('数据去重已完成(统一收集器实时处理)') logging.info('=' * 60) - before_dedupe = len(self.play_vv_items) - self.dedupe() - after_dedupe = len(self.play_vv_items) - logging.info(f'✅ 去重完成:{before_dedupe} → {after_dedupe} (移除 {before_dedupe - after_dedupe} 个重复项)') - + logging.info('=' * 60) logging.info('开始保存数据') logging.info('=' * 60) self.save_results() - + logging.info('=' * 60) logging.info(f'✅ 全部完成!共处理 {len(self.play_vv_items)} 个合集') logging.info('=' * 60) From d3929f2e35b89632ab5a2d88bc5cc7a761e7e463 Mon Sep 17 00:00:00 2001 From: xbh <6726613@qq.com> Date: Sun, 9 Nov 2025 21:19:10 +0800 Subject: [PATCH 15/21] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=89=A7=E4=BF=A1=E6=81=AF=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/rank_api_routes.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/routers/rank_api_routes.py b/backend/routers/rank_api_routes.py index 18f3700..f6ddbfa 100644 --- a/backend/routers/rank_api_routes.py +++ b/backend/routers/rank_api_routes.py @@ -1322,11 +1322,7 @@ def update_content_classification(): end_of_day = datetime.combine(today, datetime.max.time()) mgmt_doc = rankings_management_collection.find_one({ - "mix_name": mix_name, - "$or": [ - {"created_at": {"$gte": start_of_day, "$lte": end_of_day}}, - {"last_updated": {"$gte": start_of_day, "$lte": end_of_day}} - ] + "mix_name": mix_name }) if not mgmt_doc: return jsonify({"success": False, "message": f"未找到短剧: {mix_name}"}) From 54fc7ed68a080a874740e0c33fe457e5561eafa0 Mon Sep 17 00:00:00 2001 From: xbh <6726613@qq.com> Date: Sun, 9 Nov 2025 23:41:19 +0800 Subject: [PATCH 16/21] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- frontend/src/App.vue | 119 ++++++++++++----------- frontend/src/images/mhru18yf-f5p3yze.svg | 6 ++ frontend/src/images/top_bg.png | Bin 0 -> 124174 bytes 4 files changed, 70 insertions(+), 60 deletions(-) create mode 100644 frontend/src/images/mhru18yf-f5p3yze.svg create mode 100644 frontend/src/images/top_bg.png diff --git a/.gitignore b/.gitignore index 4fce158..51d3498 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,7 @@ yarn-error.log* # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Figma 设计文件目录(无需纳入版本控制) +.figma/ \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c01ea79..521e2a8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -271,32 +271,16 @@ onMounted(() => {
- -
-
-

AI棒榜

+ +
+
- - - - - - - -
-

{{ formatDateTitle(selectedDate) }}

-
-
- -
+
+ +
+

{{ formatDateTitle(selectedDate) }}

+
+
+
@@ -447,26 +437,43 @@ onMounted(() => { position: relative; } -/* 顶部标题区域 */ -.header-section { - padding-top: 20px; - text-align: center; +/* 顶部横幅(按设计稿) */ +.top-banner { + position: relative; + height: 180px; + /* 背景图改为项目内资源路径 */ + background-image: url('./images/top_bg.png'); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + overflow: hidden; } -.title-wrapper { +.top-banner::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(to bottom, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.05) 60%, rgba(0,0,0,0.1) 100%); +} + +.banner-inner { + position: relative; + z-index: 1; display: flex; + flex-direction: column; align-items: center; justify-content: center; - gap: 10px; - position: relative; + height: 100%; } - -.main-title { - font-size: 24px; - font-weight: bold; - color: #333; - margin: 0; - font-family: Alatsi, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica, sans-serif; +.banner-main-title { + width: 136px; +} +.banner-subtitle { + margin-top: 12px; + letter-spacing: 1px; + color: #ffffff; + font-family: ABeeZee, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", SimHei, Arial, Helvetica, sans-serif; + font-size: 16px; } /* 横幅区域 */ @@ -484,20 +491,6 @@ onMounted(() => { z-index: 2; } -.banner-subtitle { - font-size: 14px; - color: rgba(255, 255, 255, 0.8); - margin: 0 0 8px 0; -} - -.banner-title { - font-size: 18px; - font-weight: bold; - color: white; - margin: 0; - line-height: 1.4; -} - /* 装饰分隔线 */ .divider-dots { display: flex; @@ -521,14 +514,13 @@ onMounted(() => { display: flex; align-items: center; justify-content: center; - margin: 20px 0; gap: 8px; } .date-title { - font-size: 16px; - color: #333; - margin: 0; + font-size: 14px; + color: #555; + margin: 8px 0; font-weight: 500; } @@ -570,7 +562,17 @@ onMounted(() => { /* 排行榜内容区域 */ .ranking-content { - padding: 0 16px; + /* 作为白色圆角卡片容器 */ + background: #ffffff; + /* 仅顶部圆角 */ + border-radius: 24px 24px 0 0; + /* 移除两侧内边距与外边距 */ + padding: 0; + margin: -24px 0 16px; + /* 提高层级,确保顶部圆角可见 */ + position: relative; + z-index: 2; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.06); } /* 加载状态 */ @@ -599,8 +601,6 @@ onMounted(() => { .ranking-list { display: flex; flex-direction: column; - background: white; - border-radius: 12px; padding: 0 16px; } @@ -888,7 +888,8 @@ onMounted(() => { } .ranking-content { - padding: 0 12px; + margin-top: -12px; + padding: 12px 0; } .ranking-item { diff --git a/frontend/src/images/mhru18yf-f5p3yze.svg b/frontend/src/images/mhru18yf-f5p3yze.svg new file mode 100644 index 0000000..29875c4 --- /dev/null +++ b/frontend/src/images/mhru18yf-f5p3yze.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/images/top_bg.png b/frontend/src/images/top_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..9b3c45ae7fcd352bf4fe036b5252bf1af6f03ccc GIT binary patch literal 124174 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ⅅV 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

G7vxN#4o|xwA;4&5p>QE{r~_wn@k0 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 literal 0 HcmV?d00001 From 868163d84ead0e5ef9e942c2e454679a63b19f78 Mon Sep 17 00:00:00 2001 From: qiaoyirui0819 <3160533978@qq.com> Date: Mon, 10 Nov 2025 21:34:27 +0800 Subject: [PATCH 17/21] =?UTF-8?q?=E5=8E=BB=E9=99=A4=E4=BA=86=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E7=9A=84=E6=97=A5=E6=9C=9F=E8=BF=87=E6=BB=A4=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=89=8D=E7=AB=AF=E5=87=86?= =?UTF-8?q?=E7=A1=AE=E4=BD=BF=E7=94=A8mix=5Fid=E6=9D=A5=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=89=BE=E7=9F=AD=E5=89=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Timer_worker.py | 22 +---- .../handlers/Rankings/rank_data_scraper.py | 22 ++--- backend/routers/rank_api_routes.py | 94 +++++-------------- frontend/src/AdminPanel.vue | 20 ++-- 4 files changed, 49 insertions(+), 109 deletions(-) diff --git a/backend/Timer_worker.py b/backend/Timer_worker.py index 3fcebd3..6e1fa88 100644 --- a/backend/Timer_worker.py +++ b/backend/Timer_worker.py @@ -136,25 +136,9 @@ class DouyinAutoScheduler: return len(killed_processes) > 0 except ImportError: - # 如果没有psutil,使用系统命令 - try: - import subprocess - import os - - script_dir = os.path.dirname(os.path.abspath(__file__)) - profile_dir = os.path.join(script_dir, 'config', 'chrome_profile_timer', 'douyin_persistent') - - # 使用taskkill命令终止Chrome进程 - result = subprocess.run(['taskkill', '/F', '/IM', 'chrome.exe'], capture_output=True, text=True) - if result.returncode == 0: - logging.info('使用系统命令终止Chrome进程') - return True - else: - logging.warning('无法终止Chrome进程') - return False - except Exception as e: - logging.warning(f'系统命令清理Chrome进程失败: {e}') - return False + # 如果没有psutil,跳过清理以避免影响其他脚本实例 + logging.warning('psutil 不可用,跳过进程清理(避免全局终止 Chrome)') + return False except Exception as e: logging.warning(f'清理Chrome进程时出错: {e}') return False diff --git a/backend/handlers/Rankings/rank_data_scraper.py b/backend/handlers/Rankings/rank_data_scraper.py index a71d3ef..6cfcde8 100644 --- a/backend/handlers/Rankings/rank_data_scraper.py +++ b/backend/handlers/Rankings/rank_data_scraper.py @@ -851,10 +851,13 @@ class DouyinPlayVVScraper: def _cleanup_chrome_processes(self): """清理可能占用配置文件的Chrome进程""" try: - - # 获取当前配置文件路径 + # 获取当前配置文件路径(按模式隔离) script_dir = os.path.dirname(os.path.abspath(__file__)) - profile_dir = os.path.join(script_dir, 'config', 'chrome_profile_scraper', 'douyin_persistent') + is_timer_mode = os.environ.get('TIMER_MODE') == '1' + if is_timer_mode: + profile_dir = os.path.join(script_dir, 'config', 'chrome_profile_timer', 'douyin_persistent') + else: + profile_dir = os.path.join(script_dir, 'config', 'chrome_profile_scraper', 'douyin_persistent') # 查找使用该配置文件的Chrome进程 killed_processes = [] @@ -874,18 +877,9 @@ class DouyinPlayVVScraper: time.sleep(2) return len(killed_processes) > 0 - except ImportError: - # 如果没有psutil,使用系统命令 - try: - result = subprocess.run(['taskkill', '/f', '/im', 'chrome.exe'], - capture_output=True, text=True, timeout=10) - if result.returncode == 0: - logging.info('使用taskkill清理Chrome进程') - time.sleep(2) - return True - except Exception as e: - logging.warning(f'清理Chrome进程失败: {e}') + # 如果没有psutil,跳过清理以避免影响其他脚本实例 + logging.warning('psutil 不可用,跳过进程清理(避免全局终止 Chrome)') return False except Exception as e: logging.warning(f'清理Chrome进程时出错: {e}') diff --git a/backend/routers/rank_api_routes.py b/backend/routers/rank_api_routes.py index 18f3700..b083615 100644 --- a/backend/routers/rank_api_routes.py +++ b/backend/routers/rank_api_routes.py @@ -68,7 +68,7 @@ def find_management_data(query, target_date=None): Args: query: 查询条件字典,可以包含mix_id, mix_name等字段 - target_date: 目标日期,用于日期过滤 + target_date: 目标日期(已不用于管理库过滤,保留参数兼容) Returns: 查询到的文档或None @@ -78,20 +78,6 @@ def find_management_data(query, target_date=None): if 'mix_id' in query and query['mix_id']: mix_id_query = {"mix_id": query['mix_id']} - # 添加日期过滤(如果提供了target_date) - if target_date: - if isinstance(target_date, str): - target_date = parse_date_string(target_date) - if target_date: - start_of_day = datetime.combine(target_date, datetime.min.time()) - end_of_day = datetime.combine(target_date, datetime.max.time()) - mix_id_query.update({ - "$or": [ - {"created_at": {"$gte": start_of_day, "$lte": end_of_day}}, - {"last_updated": {"$gte": start_of_day, "$lte": end_of_day}} - ] - }) - result = rankings_management_collection.find_one(mix_id_query) if result: logging.info(f"通过mix_id找到管理数据: {query['mix_id']}") @@ -100,20 +86,6 @@ def find_management_data(query, target_date=None): # 如果通过mix_id没找到,或者没有mix_id,尝试其他查询条件 fallback_query = {k: v for k, v in query.items() if k != 'mix_id'} - # 添加日期过滤(如果提供了target_date) - if target_date and fallback_query: - if isinstance(target_date, str): - target_date = parse_date_string(target_date) - if target_date: - start_of_day = datetime.combine(target_date, datetime.min.time()) - end_of_day = datetime.combine(target_date, datetime.max.time()) - fallback_query.update({ - "$or": [ - {"created_at": {"$gte": start_of_day, "$lte": end_of_day}}, - {"last_updated": {"$gte": start_of_day, "$lte": end_of_day}} - ] - }) - if fallback_query: result = rankings_management_collection.find_one(fallback_query) if result: @@ -1294,11 +1266,12 @@ def update_content_classification(): try: data = request.get_json() - # 验证必需参数 - if not data or 'mix_name' not in data or 'classification_type' not in data: - return jsonify({"success": False, "message": "缺少必需参数 mix_name 或 classification_type"}) + # 验证必需参数(支持 mix_id 或 mix_name 任一) + if not data or ('mix_id' not in data and 'mix_name' not in data) or 'classification_type' not in data: + return jsonify({"success": False, "message": "缺少必需参数:需要 mix_id 或 mix_name,以及 classification_type"}) - mix_name = data['mix_name'] + mix_id_param = data.get('mix_id') + mix_name = data.get('mix_name') classification_type = data['classification_type'] # 'novel', 'anime', 'drama' action = data.get('action', 'add') # 'add' 或 'remove' exclusive = data.get('exclusive', True) # 默认启用互斥模式,确保每个短剧只能属于一个分类 @@ -1316,24 +1289,14 @@ def update_content_classification(): } field_name = field_mapping[classification_type] - # 首先从Rankings_management获取短剧的mix_id,使用今天的日期 - today = datetime.now().date() - start_of_day = datetime.combine(today, datetime.min.time()) - end_of_day = datetime.combine(today, datetime.max.time()) - - mgmt_doc = rankings_management_collection.find_one({ - "mix_name": mix_name, - "$or": [ - {"created_at": {"$gte": start_of_day, "$lte": end_of_day}}, - {"last_updated": {"$gte": start_of_day, "$lte": end_of_day}} - ] - }) + # 优先使用 mix_id 获取管理库文档,不做日期过滤 + mgmt_doc = find_management_data({'mix_id': mix_id_param, 'mix_name': mix_name}) if not mgmt_doc: - return jsonify({"success": False, "message": f"未找到短剧: {mix_name}"}) + return jsonify({"success": False, "message": f"未找到短剧:{mix_name or mix_id_param}"}) mix_id = mgmt_doc.get('mix_id') if not mix_id: - return jsonify({"success": False, "message": f"短剧 {mix_name} 缺少 mix_id"}) + return jsonify({"success": False, "message": f"短剧 {mix_name or '[未知名称]'} 缺少 mix_id"}) updated_count = 0 @@ -1350,7 +1313,7 @@ def update_content_classification(): # 1. 从Rankings_management中移除其他分类 for other_field in other_fields: result = rankings_management_collection.update_many( - {"mix_name": mix_name, other_field: mix_id}, + {"mix_id": mix_id, other_field: mix_id}, {"$pull": {other_field: mix_id}} ) if result.modified_count > 0: @@ -1375,7 +1338,7 @@ def update_content_classification(): # 添加到分类字段(使用$addToSet避免重复) # 1. 更新Rankings_management数据库 result_mgmt = rankings_management_collection.update_many( - {"mix_name": mix_name}, + {"mix_id": mix_id}, {"$addToSet": {field_name: mix_id}} ) @@ -1394,7 +1357,7 @@ def update_content_classification(): # 从分类字段中移除 # 1. 更新Rankings_management数据库 result_mgmt = rankings_management_collection.update_many( - {"mix_name": mix_name}, + {"mix_id": mix_id}, {"$pull": {field_name: mix_id}} ) @@ -1412,14 +1375,8 @@ def update_content_classification(): logging.info(f"分类更新: {message}, Rankings_management({result_mgmt.modified_count}), Ranking_storage({result_storage.modified_count})") - # 获取更新后的分类状态,使用今天的日期 - updated_mgmt_doc = rankings_management_collection.find_one({ - "mix_name": mix_name, - "$or": [ - {"created_at": {"$gte": start_of_day, "$lte": end_of_day}}, - {"last_updated": {"$gte": start_of_day, "$lte": end_of_day}} - ] - }) + # 获取更新后的分类状态(按 mix_id 直接查询,不做日期过滤) + updated_mgmt_doc = rankings_management_collection.find_one({"mix_id": mix_id}) classification_status = { 'novel': mix_id in updated_mgmt_doc.get('Novel_IDs', []) if updated_mgmt_doc else False, 'anime': mix_id in updated_mgmt_doc.get('Anime_IDs', []) if updated_mgmt_doc else False, @@ -1449,19 +1406,20 @@ def update_content_classification(): def get_content_classification(): """获取短剧的分类状态""" try: + mix_id_param = request.args.get('mix_id') mix_name = request.args.get('mix_name') - - if not mix_name: - return jsonify({"success": False, "message": "缺少必需参数 mix_name"}) - - # 从Rankings_management获取短剧信息 - mgmt_doc = rankings_management_collection.find_one({"mix_name": mix_name}) + + if not mix_id_param and not mix_name: + return jsonify({"success": False, "message": "缺少必需参数:需要 mix_id 或 mix_name"}) + + # 优先使用 mix_id 获取管理库信息(不做日期过滤) + mgmt_doc = find_management_data({'mix_id': mix_id_param, 'mix_name': mix_name}) if not mgmt_doc: - return jsonify({"success": False, "message": f"未找到短剧: {mix_name}"}) + return jsonify({"success": False, "message": f"未找到短剧:{mix_name or mix_id_param}"}) mix_id = mgmt_doc.get('mix_id') if not mix_id: - return jsonify({"success": False, "message": f"短剧 {mix_name} 缺少 mix_id"}) + return jsonify({"success": False, "message": f"短剧 {mix_name or '[未知名称]'} 缺少 mix_id"}) # 检查短剧在各个分类中的状态 novel_ids = mgmt_doc.get('Novel_IDs', []) @@ -1476,9 +1434,9 @@ def get_content_classification(): return jsonify({ "success": True, - "message": f"获取短剧 {mix_name} 分类状态成功", + "message": f"获取短剧 {mgmt_doc.get('mix_name', mix_name)} 分类状态成功", "data": { - "mix_name": mix_name, + "mix_name": mgmt_doc.get('mix_name', mix_name), "mix_id": mix_id, "classification_status": classification_status, "classification_details": { diff --git a/frontend/src/AdminPanel.vue b/frontend/src/AdminPanel.vue index 2ab9b45..c311807 100644 --- a/frontend/src/AdminPanel.vue +++ b/frontend/src/AdminPanel.vue @@ -13,6 +13,7 @@ const showEditModal = ref(false) // 编辑表单数据 const editForm = reactive({ id: null, + mix_id: '', title: '', mix_name: '', series_author: '', @@ -106,6 +107,7 @@ const fetchRankingData = async () => { // 编辑项目 const editItem = async (item) => { editForm.id = item.id || item._id + editForm.mix_id = item.mix_id || '' editForm.title = item.title || '' editForm.mix_name = item.mix_name || '' editForm.series_author = item.series_author || '' @@ -120,17 +122,17 @@ const editItem = async (item) => { play_vv_change_rate: item.timeline_data?.play_vv_change_rate || 0 } - // 加载分类状态 - await loadClassificationStatus(item.mix_name) + // 加载分类状态(优先使用 mix_id,兼容 mix_name) + await loadClassificationStatus(item.mix_id, item.mix_name) showEditModal.value = true } // 加载分类状态 -const loadClassificationStatus = async (mixName) => { +const loadClassificationStatus = async (mixId, mixName) => { try { const response = await axios.get(`${API_BASE_URL}/rank/get_content_classification`, { - params: { mix_name: mixName } + params: { mix_id: mixId, mix_name: mixName } }) if (response.data.success) { @@ -150,8 +152,8 @@ const loadClassificationStatus = async (mixName) => { // 更新分类 const updateClassification = async (classificationType, isChecked) => { - if (!editForm.mix_name) { - alert('合集名不能为空') + if (!editForm.mix_id && !editForm.mix_name) { + alert('缺少短剧标识(mix_id 或 mix_name)') return } @@ -172,6 +174,7 @@ const updateClassification = async (classificationType, isChecked) => { try { const response = await axios.post(`${API_BASE_URL}/rank/update_content_classification`, { + mix_id: editForm.mix_id, mix_name: editForm.mix_name, classification_type: classificationType, action: isChecked ? 'add' : 'remove', @@ -190,13 +193,13 @@ const updateClassification = async (classificationType, isChecked) => { } else { alert(`分类更新失败: ${response.data.message}`) // 恢复checkbox状态 - await loadClassificationStatus(editForm.mix_name) + await loadClassificationStatus(editForm.mix_id, editForm.mix_name) } } catch (error) { console.error('分类更新失败:', error) alert('分类更新失败,请检查网络连接') // 恢复checkbox状态 - await loadClassificationStatus(editForm.mix_name) + await loadClassificationStatus(editForm.mix_id, editForm.mix_name) } } @@ -237,6 +240,7 @@ const deleteItem = async (item) => { const saveEdit = async () => { try { const updateData = { + mix_id: editForm.mix_id, title: editForm.title, mix_name: editForm.mix_name, series_author: editForm.series_author, From a35077363dd2574f0f6866423b6f76998e4aad78 Mon Sep 17 00:00:00 2001 From: Qyir <13521889462@163.com> Date: Mon, 10 Nov 2025 09:51:31 +0800 Subject: [PATCH 18/21] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4fce158..51d3498 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,7 @@ yarn-error.log* # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Figma 设计文件目录(无需纳入版本控制) +.figma/ \ No newline at end of file From 6dfcda492edcad79a13bcbc56bbd25dde65dffe3 Mon Sep 17 00:00:00 2001 From: Qyir <13521889462@163.com> Date: Wed, 12 Nov 2025 18:36:14 +0800 Subject: [PATCH 19/21] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Timer_worker.py | 10 +- backend/config.py | 12 + .../handlers/Rankings/rank_data_scraper.py | 403 +++++++++++- backend/routers/rank_api_routes.py | 308 +++++++++- frontend/src/AdminPanel.vue | 97 ++- frontend/src/App.vue | 91 ++- frontend/src/DramaDetail.vue | 581 ++++++++++++++++++ frontend/src/images/抖音icon.png | Bin 0 -> 8654 bytes frontend/src/router/index.js | 6 + 9 files changed, 1477 insertions(+), 31 deletions(-) create mode 100644 frontend/src/DramaDetail.vue create mode 100644 frontend/src/images/抖音icon.png diff --git a/backend/Timer_worker.py b/backend/Timer_worker.py index 6e1fa88..dd4235c 100644 --- a/backend/Timer_worker.py +++ b/backend/Timer_worker.py @@ -435,11 +435,17 @@ class DouyinAutoScheduler: "realtime_saved": management_data.get("realtime_saved", True) if management_data else True, "created_at": management_data.get("created_at") if management_data else None, "last_updated": management_data.get("last_updated") if management_data else None, + # 🎬 评论总结字段 + "comments_summary": management_data.get("comments_summary", "") if management_data else "", + # 🔑 分类字段:区分今天数据和历史数据 # - 如果今天有数据:从今天的数据获取所有字段 # - 如果今天没有数据:只从历史记录获取分类字段和锁定状态,其他字段为空 - "Manufacturing_Field": management_data.get("Manufacturing_Field", "") if management_data else "", - "Copyright_field": management_data.get("Copyright_field", "") if management_data else "", + # 注意:使用 .get() 的第二个参数确保即使字段不存在也会返回空字符串 + "Manufacturing_Field": (management_data.get("Manufacturing_Field", "") if management_data else "") or "", + "Copyright_field": (management_data.get("Copyright_field", "") if management_data else "") or "", + "classification_type": (management_data.get("classification_type", "") if management_data else "") or "", # 新增:类型/元素(确保字段存在) + "release_date": (management_data.get("release_date", "") if management_data else "") or "", # 新增:上线日期(确保字段存在) "Novel_IDs": ( management_data.get("Novel_IDs", []) if management_data else (classification_data.get("Novel_IDs", []) if classification_data else []) diff --git a/backend/config.py b/backend/config.py index 1ba92a7..9db8d58 100644 --- a/backend/config.py +++ b/backend/config.py @@ -52,6 +52,18 @@ API_CONFIG = { 'OSS_HOST': TOS_CONFIG['self_domain'] } +# DeepSeek API 配置(用于评论总结功能) +DEEPSEEK_CONFIG = { + 'api_key': 'sk-7b47e34bdcb549e6b00115a99b9b5c4c', # DeepSeek API密钥 + 'api_base': 'https://api.deepseek.com/v1', # API基础URL + 'model': 'deepseek-chat', # 使用的模型 + 'max_retries': 3, # 最大重试次数 + 'retry_delays': [2, 5, 10], # 重试延迟(秒) + 'batch_size': 800, # 每批评论数量 + 'max_tokens': 15000, # 每批最大token数 + 'summary_max_length': 200 # 最终总结最大字数 +} + def apply_timer_environment(): """应用定时器环境变量配置""" for key, value in TIMER_ENV_CONFIG.items(): diff --git a/backend/handlers/Rankings/rank_data_scraper.py b/backend/handlers/Rankings/rank_data_scraper.py index 6cfcde8..cda7dc7 100644 --- a/backend/handlers/Rankings/rank_data_scraper.py +++ b/backend/handlers/Rankings/rank_data_scraper.py @@ -54,6 +54,236 @@ from handlers.Rankings.tos_client import oss_client import config +# ==================== 评论总结器类 ==================== +class CommentsSummarizer: + """评论总结器 - 支持大量评论的分批处理和汇总""" + + def __init__(self): + self.api_key = config.DEEPSEEK_CONFIG['api_key'] + self.api_base = config.DEEPSEEK_CONFIG['api_base'] + self.model = config.DEEPSEEK_CONFIG['model'] + self.max_retries = config.DEEPSEEK_CONFIG['max_retries'] + self.retry_delays = config.DEEPSEEK_CONFIG['retry_delays'] + self.batch_size = config.DEEPSEEK_CONFIG['batch_size'] + self.max_tokens = config.DEEPSEEK_CONFIG['max_tokens'] + self.summary_max_length = config.DEEPSEEK_CONFIG['summary_max_length'] + self.logger = logging.getLogger(__name__) + + def _call_deepseek_api(self, messages: List[Dict], retry_count: int = 0) -> Optional[str]: + """调用 DeepSeek API""" + try: + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.api_key}' + } + + data = { + 'model': self.model, + 'messages': messages, + 'temperature': 0.7, + 'max_tokens': 2000 + } + + response = requests.post( + f'{self.api_base}/chat/completions', + headers=headers, + json=data, + timeout=60 + ) + + if response.status_code == 200: + result = response.json() + content = result['choices'][0]['message']['content'] + self.logger.info(f"✅ DeepSeek API 调用成功") + return content.strip() + else: + self.logger.error(f"❌ DeepSeek API 返回错误: {response.status_code} - {response.text}") + + if retry_count < self.max_retries: + delay = self.retry_delays[retry_count] + self.logger.info(f"⏳ {delay}秒后进行第 {retry_count + 1} 次重试...") + time.sleep(delay) + return self._call_deepseek_api(messages, retry_count + 1) + + return None + + except Exception as e: + self.logger.error(f"❌ DeepSeek API 调用异常: {e}") + + if retry_count < self.max_retries: + delay = self.retry_delays[retry_count] + self.logger.info(f"⏳ {delay}秒后进行第 {retry_count + 1} 次重试...") + time.sleep(delay) + return self._call_deepseek_api(messages, retry_count + 1) + + return None + + def _estimate_comment_length(self, comment: str) -> int: + """估算评论的字符长度""" + return len(comment) + + def _split_comments_into_batches(self, comments: List[str]) -> List[List[str]]: + """将评论智能分批,根据评论长度动态调整每批数量""" + if not comments: + return [] + + batches = [] + current_batch = [] + current_length = 0 + + avg_length = sum(self._estimate_comment_length(c) for c in comments[:100]) / min(100, len(comments)) + + if avg_length < 50: + batch_size = 1000 + elif avg_length < 200: + batch_size = 600 + else: + batch_size = 400 + + self.logger.info(f"📊 评论平均长度: {avg_length:.0f} 字,批次大小: {batch_size}") + + for comment in comments: + comment_length = self._estimate_comment_length(comment) + + if len(current_batch) >= batch_size or (current_length + comment_length > self.max_tokens * 3): + if current_batch: + batches.append(current_batch) + current_batch = [] + current_length = 0 + + current_batch.append(comment) + current_length += comment_length + + if current_batch: + batches.append(current_batch) + + return batches + + def _generate_analysis_prompt(self, content: str, max_length: int = 200) -> str: + """生成通用的分析提示词""" + return f"""你是一位资深的用户反馈分析师,擅长从海量评论中提炼真实观点,用客观自然的语言准确传达用户的声音和整体评价趋势。 + +请基于以下内容,写一份真实客观的观众反馈分析: + +{content} + +分析要求: +1. 识别高频话题和关键观点(如剧情、演技、制作、节奏等维度) +2. 准确判断整体情感倾向,如实反映好评或差评的比例和强度 +3. 用自然的语言描述观众的真实感受,避免模板化和官方措辞 +4. 明确指出观众最在意的亮点和槽点 +5. 负面评价要委婉表达,使用"有待提升"、"存在改进空间"、"部分观众认为"等温和措辞 +6. 字数控制在{max_length}字以内,语言简洁有力 + +输出格式要求(严格遵守): +必须使用【】符号标注每个部分,格式示例: + +【核心观点】用户普遍识别出AI制作属性,对技术应用表示惊叹,同时对作品质量提出了一些看法 + +【用户关注焦点】 + 优点:AI人物颜值高、特效精美、制作成本低 + 待提升:部分观众认为角色表情和动作的自然度有待改进,剧情逻辑存在优化空间 + +【情感分布】观众意见较为分散,约65%的观众提出了改进建议 + +【核心看法】技术创新获得认可,制作细节方面仍有提升空间 + +格式规则: +- 使用【】符号标注每个分析维度的标题(标题可以自由命名,不限于示例) +- 每个【】标题后直接跟内容,不要换行 +- 每个部分结束后换行,再开始下一个【】部分 +- 可以根据实际评论内容灵活组织分析维度 +- 不要添加其他前缀或后缀 +- 严格按照【标题】内容的格式输出""" + + def _summarize_batch(self, comments: List[str], batch_num: int, total_batches: int) -> Optional[str]: + """总结一批评论""" + self.logger.info(f"📝 正在总结第 {batch_num}/{total_batches} 批评论(共 {len(comments)} 条)...") + + comments_text = "\n".join([f"{i+1}. {comment}" for i, comment in enumerate(comments)]) + content = f"用户评论:\n{comments_text}" + + prompt = self._generate_analysis_prompt(content, max_length=200) + messages = [{"role": "user", "content": prompt}] + + return self._call_deepseek_api(messages) + + def _merge_summaries(self, batch_summaries: List[str]) -> Optional[str]: + """合并所有批次总结为最终总结""" + self.logger.info(f"🔄 正在合并 {len(batch_summaries)} 个批次总结...") + + if len(batch_summaries) == 1: + return batch_summaries[0] + + summaries_text = "\n\n".join([f"批次{i+1}总结:\n{summary}" for i, summary in enumerate(batch_summaries)]) + content = f"多个批次的评论总结:\n\n{summaries_text}" + + prompt = self._generate_analysis_prompt(content, max_length=self.summary_max_length) + messages = [{"role": "user", "content": prompt}] + + return self._call_deepseek_api(messages) + + def summarize_comments(self, comments: List[str], drama_name: str = "") -> Optional[str]: + """总结评论(主入口)""" + if not comments: + self.logger.warning("⚠️ 评论列表为空,无法总结") + return None + + self.logger.info(f"🚀 开始总结评论:{drama_name}(共 {len(comments)} 条评论)") + + # 过滤空评论,处理字符串和字典两种格式 + valid_comments = [] + for c in comments: + if isinstance(c, dict): + text = c.get('text', '').strip() + if text: + valid_comments.append(text) + elif isinstance(c, str): + text = c.strip() + if text: + valid_comments.append(text) + + if not valid_comments: + self.logger.warning("⚠️ 没有有效评论,无法总结") + return None + + self.logger.info(f"📊 有效评论数量: {len(valid_comments)}") + + # 分批处理 + batches = self._split_comments_into_batches(valid_comments) + self.logger.info(f"📦 评论已分为 {len(batches)} 批") + + # 逐批总结 + batch_summaries = [] + failed_batches = [] + + for i, batch in enumerate(batches, 1): + summary = self._summarize_batch(batch, i, len(batches)) + if summary: + batch_summaries.append(summary) + else: + self.logger.error(f"❌ 第 {i} 批总结失败") + failed_batches.append(i) + + if not batch_summaries: + self.logger.error(f"❌ 所有批次总结都失败了") + return None + + if failed_batches: + self.logger.warning(f"⚠️ 以下批次总结失败: {failed_batches}") + + # 合并批次总结 + final_summary = self._merge_summaries(batch_summaries) + + if final_summary: + self.logger.info(f"✅ 评论总结完成:{drama_name}") + self.logger.info(f"📝 总结长度: {len(final_summary)} 字") + return final_summary + else: + self.logger.error(f"❌ 最终总结合并失败:{drama_name}") + return None + + # 配置日志 # 确保logs目录存在 script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -729,6 +959,22 @@ class DouyinPlayVVScraper: self._cleanup_chrome_cache_smart() self._setup_mongodb() self._load_image_cache() + + # 初始化评论总结器 + try: + # 检查配置是否存在 + if not hasattr(config, 'DEEPSEEK_CONFIG'): + logging.warning('⚠️ config.py 中未找到 DEEPSEEK_CONFIG 配置,将跳过评论总结功能') + self.comments_summarizer = None + else: + self.comments_summarizer = CommentsSummarizer() + logging.info('✅ 评论总结器初始化成功') + logging.info(f'📝 DeepSeek API 配置: model={config.DEEPSEEK_CONFIG.get("model")}, base={config.DEEPSEEK_CONFIG.get("api_base")}') + except Exception as e: + logging.warning(f'⚠️ 评论总结器初始化失败: {e},将跳过评论总结功能') + import traceback + logging.warning(f'详细错误: {traceback.format_exc()}') + self.comments_summarizer = None def _setup_mongodb(self): """设置MongoDB连接""" @@ -1580,10 +1826,38 @@ class DouyinPlayVVScraper: self.update_video_details_incrementally( document_id, episode_video_ids, mix_name, mix_id ) + + # 🎬 生成评论总结(在所有数据收集完成后) + self.generate_comments_summary(document_id, mix_name) except Exception as e: logging.error(f'[实时保存] 获取详细内容失败: {item_data.get("mix_name", "未知")} - {e}') logging.info(f'[实时保存] 所有数据处理完成,共 {len(self.saved_items)} 个合集') + + # 🔄 同步字段到 Ranking_storage(包括评论总结) + try: + logging.info('[字段同步] 🔄 开始同步字段到 Ranking_storage') + + # 导入同步函数 + import sys + import os + sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'routers')) + from rank_api_routes import sync_ranking_storage_fields + + # 获取今天的日期 + today_str = datetime.now().strftime('%Y-%m-%d') + + # 执行同步(强制更新以确保评论总结被同步) + sync_result = sync_ranking_storage_fields(target_date=today_str, force_update=True) + + if sync_result.get("success", False): + logging.info(f'[字段同步] ✅ 同步成功: {sync_result.get("message", "")}') + else: + logging.info(f'[字段同步] ⚠️ 同步完成: {sync_result.get("message", "")}') + + except Exception as e: + logging.error(f'[字段同步] ❌ 同步失败: {e}') + # 同步失败不影响主流程 else: # 传统批量保存模式 self.save_to_mongodb() @@ -1918,6 +2192,30 @@ class DouyinPlayVVScraper: } for i in range(item.get('updated_to_episode', 0)) ] + # 生成评论总结 + comments_summary = '' + if self.comments_summarizer and episode_details: + try: + # 收集所有集的评论 + all_comments = [] + for episode in episode_details: + comments = episode.get('comments', []) + if comments: + all_comments.extend(comments) + + if all_comments: + logging.info(f'🎬 开始为短剧 {mix_name} 生成评论总结(共 {len(all_comments)} 条评论)') + comments_summary = self.comments_summarizer.summarize_comments(all_comments, mix_name) + if comments_summary: + logging.info(f'✅ 短剧 {mix_name} 评论总结生成成功') + else: + logging.warning(f'⚠️ 短剧 {mix_name} 评论总结生成失败') + else: + logging.info(f'ℹ️ 短剧 {mix_name} 没有评论,跳过总结') + except Exception as e: + logging.error(f'❌ 生成评论总结时出错: {e}') + comments_summary = '' + # 保留用户要求的7个字段 + cover_image_url作为合集封面图片完整链接 + 新增字段 doc = { 'batch_time': batch_time, @@ -1940,6 +2238,7 @@ class DouyinPlayVVScraper: 'episode_details': episode_details, # 每集的详细信息 'Manufacturing_Field': item.get('Manufacturing_Field', ''), # 承制信息 'Copyright_field': item.get('Copyright_field', ''), # 版权信息 + 'comments_summary': comments_summary, # AI生成的评论总结 } documents.append(doc) @@ -2048,6 +2347,8 @@ class DouyinPlayVVScraper: 'series_author': item_data.get('series_author', ''), 'Manufacturing_Field': item_data.get('Manufacturing_Field', ''), 'Copyright_field': item_data.get('Copyright_field', ''), + 'classification_type': '', # 新增:类型/元素(锁定字段,初始为空) + 'release_date': '', # 新增:上线日期(锁定字段,初始为空) 'desc': item_data.get('desc', ''), 'updated_to_episode': current_episode_count, 'episode_video_ids': [], # 稍后更新 @@ -2113,6 +2414,8 @@ class DouyinPlayVVScraper: existing_field_lock_status = existing_doc.get('field_lock_status', {}) existing_manufacturing = existing_doc.get('Manufacturing_Field', '') existing_copyright = existing_doc.get('Copyright_field', '') + existing_classification = existing_doc.get('classification_type', '') # 新增 + existing_release_date = existing_doc.get('release_date', '') # 新增 existing_novel_ids = existing_doc.get('Novel_IDs', []) existing_anime_ids = existing_doc.get('Anime_IDs', []) existing_drama_ids = existing_doc.get('Drama_IDs', []) @@ -2151,6 +2454,34 @@ class DouyinPlayVVScraper: logging.info(f'[锁定字段] 更新Copyright_field: {mix_name} -> "{new_copyright}"') # 如果现有为空且新数据也为空,则不设置该字段(保持为空) + # classification_type 保护逻辑(新增) + existing_classification = existing_doc.get('classification_type') + new_classification = target_doc.get('classification_type', '') + if existing_field_lock_status.get('classification_type_locked', False): + logging.info(f'[锁定字段] 跳过classification_type更新: {mix_name} -> 字段已被用户锁定') + elif existing_classification: + logging.info(f'[锁定字段] 跳过classification_type更新: {mix_name} -> 保持现有值 "{existing_classification}"') + else: + set_fields['classification_type'] = new_classification or '' + if new_classification: + logging.info(f'[锁定字段] 更新classification_type: {mix_name} -> "{new_classification}"') + else: + logging.info(f'[锁定字段] 初始化classification_type: {mix_name} -> 空值') + + # release_date 保护逻辑(新增) + existing_release_date = existing_doc.get('release_date') + new_release_date = target_doc.get('release_date', '') + if existing_field_lock_status.get('release_date_locked', False): + logging.info(f'[锁定字段] 跳过release_date更新: {mix_name} -> 字段已被用户锁定') + elif existing_release_date: + logging.info(f'[锁定字段] 跳过release_date更新: {mix_name} -> 保持现有值 "{existing_release_date}"') + else: + set_fields['release_date'] = new_release_date or '' + if new_release_date: + logging.info(f'[锁定字段] 更新release_date: {mix_name} -> "{new_release_date}"') + else: + logging.info(f'[锁定字段] 初始化release_date: {mix_name} -> 空值') + # Novel_IDs 保护逻辑 if existing_field_lock_status.get('Novel_IDs_locked', False): # 字段被用户锁定,跳过更新 @@ -2194,10 +2525,12 @@ class DouyinPlayVVScraper: # 新记录,只设置非分类字段 set_fields['Manufacturing_Field'] = target_doc.get('Manufacturing_Field', '') set_fields['Copyright_field'] = target_doc.get('Copyright_field', '') + set_fields['classification_type'] = target_doc.get('classification_type', '') # 新增 + set_fields['release_date'] = target_doc.get('release_date', '') # 新增 # 注意:不设置分类字段 Novel_IDs, Anime_IDs, Drama_IDs # 因为爬虫数据不包含这些用户手动设置的分类信息 # 新记录的分类字段将保持为空,等待用户手动设置 - logging.info(f'[锁定字段] 新记录,设置初始非分类字段: {mix_name}') + logging.info(f'[锁定字段] 新记录,设置初始非分类字段(包含新增的2个锁定字段): {mix_name}') # 使用upsert操作:如果存在则更新,不存在则插入 upsert_result = target_collection.update_one( @@ -2442,6 +2775,68 @@ class DouyinPlayVVScraper: logging.error(f'错误上下文: {error_details["context"]}') return False + def generate_comments_summary(self, document_id, mix_name: str): + """生成评论总结并保存到数据库""" + logging.info(f'[评论总结] 🔍 检查评论总结条件: comments_summarizer={self.comments_summarizer is not None}, document_id={document_id}') + + if not self.comments_summarizer or not document_id: + if not self.comments_summarizer: + logging.warning(f'[评论总结] ⚠️ 评论总结器未初始化,跳过: {mix_name}') + if not document_id: + logging.warning(f'[评论总结] ⚠️ document_id 为空,跳过: {mix_name}') + return + + try: + # 从数据库获取最新的 episode_details + target_collection = self.collection + doc = target_collection.find_one({'_id': document_id}) + logging.info(f'[评论总结] 从数据库查询文档: 找到={doc is not None}') + + if not doc or not doc.get('episode_details'): + logging.warning(f'[评论总结] 未找到文档或episode_details为空: {mix_name}') + return + + # 🔍 检查是否已有评论总结 + existing_summary = doc.get('comments_summary', '') + if existing_summary: + logging.info(f'[评论总结] ⏭️ 短剧 {mix_name} 已有评论总结,跳过生成') + return + + logging.info(f'[评论总结] 🎬 开始为短剧 {mix_name} 生成评论总结') + + # 收集所有集的评论 + all_comments = [] + for episode in doc['episode_details']: + comments = episode.get('comments', []) + if comments: + all_comments.extend(comments) + + if not all_comments: + logging.info(f'[评论总结] ℹ️ 短剧 {mix_name} 没有评论,跳过总结') + return + + logging.info(f'[评论总结] 共收集到 {len(all_comments)} 条评论') + comments_summary = self.comments_summarizer.summarize_comments(all_comments, mix_name) + + if comments_summary: + # 更新评论总结到数据库 + target_collection.update_one( + {'_id': document_id}, + {'$set': { + 'comments_summary': comments_summary, + 'last_updated': datetime.now() + }} + ) + logging.info(f'[评论总结] ✅ 短剧 {mix_name} 评论总结生成并保存成功') + logging.info(f'[评论总结] 📝 总结内容(前100字): {comments_summary[:100]}...') + else: + logging.warning(f'[评论总结] ⚠️ 短剧 {mix_name} 评论总结生成失败') + + except Exception as e: + logging.error(f'[评论总结] ❌ 生成评论总结时出错: {mix_name} - {e}') + import traceback + logging.error(f'详细错误: {traceback.format_exc()}') + def save_single_item_realtime(self, item_data: dict): """分阶段实时保存合集数据(新版本)""" logging.info(f'[分阶段保存] 开始处理合集: {item_data.get("mix_name", "未知")}') @@ -2500,6 +2895,12 @@ class DouyinPlayVVScraper: logging.warning(f'[字段同步] 同步失败,但不影响数据保存: {mix_name} - {sync_error}') # 同步失败不影响数据保存的成功状态 + logging.info(f'[分阶段保存] ✅ 前四阶段完成,准备生成评论总结: {mix_name}') + + # 🎬 第五阶段:生成评论总结(在所有数据收集完成后) + self.generate_comments_summary(document_id, mix_name) + + logging.info(f'[分阶段保存] ✅ 所有阶段完成: {mix_name}') return True def update_video_details_incrementally(self, document_id, episode_video_ids: list, mix_name: str, mix_id: str = ''): diff --git a/backend/routers/rank_api_routes.py b/backend/routers/rank_api_routes.py index ccb4041..f83e5aa 100644 --- a/backend/routers/rank_api_routes.py +++ b/backend/routers/rank_api_routes.py @@ -196,10 +196,12 @@ def format_mix_item(doc, target_date=None): "request_id": doc.get("request_id", ""), "rank": doc.get("rank", 0), "cover_image_url": doc.get("cover_image_url", ""), - # 新增字段 + # 基础字段 "series_author": doc.get("series_author", ""), "Manufacturing_Field": doc.get("Manufacturing_Field", ""), "Copyright_field": doc.get("Copyright_field", ""), + "classification_type": doc.get("classification_type", ""), # 新增:类型/元素 + "release_date": doc.get("release_date", ""), # 新增:上线日期 "desc": doc.get("desc", ""), "updated_to_episode": doc.get("updated_to_episode", 0), "cover_backup_urls": doc.get("cover_backup_urls", []), @@ -213,6 +215,8 @@ def format_mix_item(doc, target_date=None): "total_comments_formatted": total_comments_formatted, # 播放量变化数据 "timeline_data": doc.get("timeline_data", []), + # 评论总结 + "comments_summary": doc.get("comments_summary", ""), } @@ -1156,6 +1160,14 @@ def update_drama_info(): update_fields['Copyright_field'] = data['Copyright_field'] # 标记版权方字段已被用户锁定 field_lock_updates['field_lock_status.Copyright_field_locked'] = True + if 'classification_type' in data: + update_fields['classification_type'] = data['classification_type'] + # 标记类型/元素字段已被用户锁定 + field_lock_updates['field_lock_status.classification_type_locked'] = True + if 'release_date' in data: + update_fields['release_date'] = data['release_date'] + # 标记上线日期字段已被用户锁定 + field_lock_updates['field_lock_status.release_date_locked'] = True if 'desc' in data: update_fields['desc'] = data['desc'] if 'play_vv' in data: @@ -1167,6 +1179,8 @@ def update_drama_info(): update_fields['cover_backup_urls'] = data['cover_backup_urls'] if 'timeline_data' in data: update_fields['timeline_data'] = data['timeline_data'] + if 'comments_summary' in data: + update_fields['comments_summary'] = data['comments_summary'] # 检查分类字段的锁定状态 if 'Novel_IDs' in data: @@ -1683,6 +1697,8 @@ def sync_ranking_storage_fields(target_date=None, force_update=False, max_retrie has_locked_fields = any([ field_lock_status.get('Manufacturing_Field_locked', False), field_lock_status.get('Copyright_field_locked', False), + field_lock_status.get('classification_type_locked', False), # 新增 + field_lock_status.get('release_date_locked', False), # 新增 field_lock_status.get('Novel_IDs_locked', False), field_lock_status.get('Anime_IDs_locked', False), field_lock_status.get('Drama_IDs_locked', False) @@ -1692,6 +1708,8 @@ def sync_ranking_storage_fields(target_date=None, force_update=False, max_retrie has_user_data = has_locked_fields or any([ data_item.get('Manufacturing_Field'), data_item.get('Copyright_field'), + data_item.get('classification_type'), # 新增 + data_item.get('release_date'), # 新增 data_item.get('Novel_IDs'), data_item.get('Anime_IDs'), data_item.get('Drama_IDs') @@ -1735,10 +1753,14 @@ def sync_ranking_storage_fields(target_date=None, force_update=False, max_retrie 'last_updated': data_item.get('last_updated'), 'Manufacturing_Field': data_item.get('Manufacturing_Field'), 'Copyright_field': data_item.get('Copyright_field'), + 'classification_type': data_item.get('classification_type', ''), # 新增:类型/元素 + 'release_date': data_item.get('release_date', ''), # 新增:上线日期 # 新增:内容分类字段 'Novel_IDs': data_item.get('Novel_IDs', []), 'Anime_IDs': data_item.get('Anime_IDs', []), 'Drama_IDs': data_item.get('Drama_IDs', []), + # 评论总结字段 + 'comments_summary': data_item.get('comments_summary', ''), # 计算字段 } @@ -1750,33 +1772,31 @@ def sync_ranking_storage_fields(target_date=None, force_update=False, max_retrie anime_ids_locked = field_lock_status.get('Anime_IDs_locked', False) drama_ids_locked = field_lock_status.get('Drama_IDs_locked', False) - # 检查哪些字段需要更新 + # 检查哪些字段需要更新(检查目标数据是否缺少字段) needs_update = False - for field_name, field_value in fields_to_check.items(): + for field_name, source_field_value in fields_to_check.items(): # 🔒 字段锁定保护:如果字段已锁定,跳过更新 if field_name == 'Manufacturing_Field' and manufacturing_locked: - logging.info(f"[字段锁定] 跳过Manufacturing_Field更新: {mix_name} (已锁定)") continue elif field_name == 'Copyright_field' and copyright_locked: - logging.info(f"[字段锁定] 跳过Copyright_field更新: {mix_name} (已锁定)") continue elif field_name == 'Novel_IDs' and novel_ids_locked: - logging.info(f"[字段锁定] 跳过Novel_IDs更新: {mix_name} (已锁定)") continue elif field_name == 'Anime_IDs' and anime_ids_locked: - logging.info(f"[字段锁定] 跳过Anime_IDs更新: {mix_name} (已锁定)") continue elif field_name == 'Drama_IDs' and drama_ids_locked: - logging.info(f"[字段锁定] 跳过Drama_IDs更新: {mix_name} (已锁定)") continue + # 🔑 关键修复:检查目标数据(data_item)中的字段值,而不是源数据 + current_value = data_item.get(field_name) + # 对于数组字段,检查是否为空数组 if field_name in ['cover_backup_urls', 'episode_video_ids', 'episode_details', 'Novel_IDs', 'Anime_IDs', 'Drama_IDs']: - if force_update or field_value is None or (isinstance(field_value, list) and len(field_value) == 0): + if force_update or current_value is None or (isinstance(current_value, list) and len(current_value) == 0): needs_update = True break - # 对于其他字段,使用原来的条件 - elif force_update or field_value is None or field_value == '' or field_value == 0: + # 对于其他字段,检查目标数据是否缺少或为空 + elif force_update or current_value is None or current_value == '': needs_update = True break @@ -1786,7 +1806,7 @@ def sync_ranking_storage_fields(target_date=None, force_update=False, max_retrie # 从源数据获取字段值并更新data_item item_updated = False - for field_name, current_value in fields_to_check.items(): + for field_name, source_field_value in fields_to_check.items(): # 🔒 字段锁定保护:如果字段已锁定,跳过更新 if field_name == 'Manufacturing_Field' and manufacturing_locked: logging.info(f"[字段锁定] 保护Manufacturing_Field不被覆盖: {mix_name}") @@ -1804,12 +1824,15 @@ def sync_ranking_storage_fields(target_date=None, force_update=False, max_retrie logging.info(f"[字段锁定] 保护Drama_IDs不被覆盖: {mix_name}") continue + # 🔑 关键修复:检查目标数据(data_item)中的字段值 + current_value = data_item.get(field_name) + # 对于数组字段,检查是否为空数组 should_update = False if field_name in ['cover_backup_urls', 'episode_video_ids', 'episode_details', 'Novel_IDs', 'Anime_IDs', 'Drama_IDs']: should_update = force_update or current_value is None or (isinstance(current_value, list) and len(current_value) == 0) else: - should_update = force_update or current_value is None or current_value == '' or current_value == 0 + should_update = force_update or current_value is None or current_value == '' if should_update: if field_name == 'episode_details': @@ -1869,6 +1892,16 @@ def sync_ranking_storage_fields(target_date=None, force_update=False, max_retrie # 当前也没有值,设置为空数组 data_item[field_name] = [] item_updated = True + elif field_name == 'comments_summary': + # 🎬 特殊处理评论总结字段:只有源数据有值时才更新,保护已有的总结 + source_value = source_data.get(field_name, '') + if source_value: # 只有当源数据有评论总结时才更新 + data_item[field_name] = source_value + item_updated = True + logging.info(f"[评论总结] 更新评论总结: {mix_name}") + else: + # 源数据没有总结,保留当前值(不覆盖) + logging.debug(f"[评论总结] 保留现有评论总结: {mix_name}") else: # 对于其他字段,直接从源数据获取 source_value = source_data.get(field_name, '') @@ -1978,4 +2011,251 @@ def validate_classification_exclusivity_api(): return jsonify({ "success": False, "message": f"验证分类互斥性失败: {str(e)}" - }), 500 \ No newline at end of file + }), 500 + + +@rank_bp.route('/get_comments_summary', methods=['GET']) +def get_comments_summary(): + """获取短剧的评论总结(优先使用 mix_id)""" + try: + mix_id = request.args.get('mix_id') + mix_name = request.args.get('mix_name') + date_str = request.args.get('date') + + if not mix_id and not mix_name: + return jsonify({"success": False, "message": "缺少必需参数 mix_id 或 mix_name"}) + + if not date_str: + from datetime import date + date_str = date.today().strftime('%Y-%m-%d') + + # 从 Ranking_storage 获取榜单数据 + ranking_doc = collection.find_one({ + "date": date_str, + "type": "comprehensive" + }, sort=[("created_at", -1)]) + + if not ranking_doc: + return jsonify({ + "success": False, + "message": f"未找到 {date_str} 的榜单数据" + }) + + # 在 data 数组中查找短剧(优先使用 mix_id) + data_items = ranking_doc.get("data", []) + drama_item = None + + for item in data_items: + # 优先使用 mix_id 匹配 + if mix_id and item.get("mix_id") == mix_id: + drama_item = item + break + # 备用:使用 mix_name 匹配 + elif mix_name and item.get("mix_name") == mix_name: + drama_item = item + # 继续查找,看是否有 mix_id 匹配的 + + if not drama_item: + return jsonify({ + "success": False, + "message": f"未找到短剧: {mix_name or mix_id}" + }) + + comments_summary = drama_item.get("comments_summary", "") + + if not comments_summary: + return jsonify({ + "success": False, + "message": "该短剧暂无评论总结" + }) + + return jsonify({ + "success": True, + "data": { + "mix_id": drama_item.get("mix_id"), + "mix_name": drama_item.get("mix_name"), + "date": date_str, + "comments_summary": comments_summary + } + }) + + except Exception as e: + logging.error(f"获取评论总结失败: {e}") + return jsonify({ + "success": False, + "message": f"获取评论总结失败: {str(e)}" + }), 500 + + +@rank_bp.route('/clear_comments_summary', methods=['POST']) +def clear_comments_summary(): + """清空短剧的评论总结(优先使用 mix_id)""" + try: + data = request.get_json() + mix_id = data.get('mix_id') + mix_name = data.get('mix_name') + date_str = data.get('date') + + if not mix_id and not mix_name: + return jsonify({"success": False, "message": "缺少必需参数 mix_id 或 mix_name"}) + + if not date_str: + from datetime import date + date_str = date.today().strftime('%Y-%m-%d') + + # 从 Ranking_storage 获取榜单数据 + ranking_doc = collection.find_one({ + "date": date_str, + "type": "comprehensive" + }, sort=[("created_at", -1)]) + + if not ranking_doc: + return jsonify({ + "success": False, + "message": f"未找到 {date_str} 的榜单数据" + }) + + # 在 data 数组中查找短剧并获取 mix_id + data_items = ranking_doc.get("data", []) + target_mix_id = None + target_mix_name = None + + for item in data_items: + if mix_id and item.get("mix_id") == mix_id: + target_mix_id = item.get("mix_id") + target_mix_name = item.get("mix_name") + break + elif mix_name and item.get("mix_name") == mix_name: + target_mix_id = item.get("mix_id") + target_mix_name = item.get("mix_name") + + if not target_mix_id and not target_mix_name: + return jsonify({ + "success": False, + "message": f"未找到短剧: {mix_name or mix_id}" + }) + + # 清空评论总结字段(优先使用 mix_id) + if target_mix_id: + result = collection.update_many( + { + "date": date_str, + "type": "comprehensive", + "data.mix_id": target_mix_id + }, + { + "$set": { + "data.$[elem].comments_summary": "" + } + }, + array_filters=[{"elem.mix_id": target_mix_id}] + ) + else: + # 备用:使用 mix_name + result = collection.update_many( + { + "date": date_str, + "type": "comprehensive", + "data.mix_name": target_mix_name + }, + { + "$set": { + "data.$[elem].comments_summary": "" + } + }, + array_filters=[{"elem.mix_name": target_mix_name}] + ) + + # 同时清空 Rankings_management 中的评论总结 + management_result = None + if target_mix_id: + management_result = rankings_management_collection.update_one( + {"mix_id": target_mix_id}, + {"$set": {"comments_summary": ""}} + ) + elif target_mix_name: + management_result = rankings_management_collection.update_one( + {"mix_name": target_mix_name}, + {"$set": {"comments_summary": ""}} + ) + + if result.modified_count > 0 or (management_result and management_result.modified_count > 0): + return jsonify({ + "success": True, + "message": f"已清空短剧 {target_mix_name} 的评论总结(Ranking_storage: {result.modified_count}, Rankings_management: {management_result.modified_count if management_result else 0})", + "modified_count": result.modified_count + }) + else: + return jsonify({ + "success": False, + "message": "未找到需要清空的评论总结" + }) + + except Exception as e: + logging.error(f"清空评论总结失败: {e}") + return jsonify({ + "success": False, + "message": f"清空评论总结失败: {str(e)}" + }), 500 + + +@rank_bp.route('/drama/') +def get_drama_detail_by_id(drama_id): + """ + 根据短剧ID获取详细信息(用于详情页) + 支持通过 mix_id 或 _id 查询 + """ + try: + # 获取日期参数(可选) + date_str = request.args.get('date') + if not date_str: + date_str = datetime.now().date().strftime("%Y-%m-%d") + + # 首先尝试从 Ranking_storage 中查找 + ranking_doc = collection.find_one({ + "date": date_str, + "type": "comprehensive" + }, sort=[("calculation_sequence", -1)]) + + drama_data = None + + if ranking_doc and "data" in ranking_doc: + # 在 data 数组中查找匹配的短剧 + for item in ranking_doc.get("data", []): + if item.get("mix_id") == drama_id or str(item.get("_id")) == drama_id: + drama_data = item + break + + # 如果在 Ranking_storage 中没找到,尝试从 Rankings_management 查找 + if not drama_data: + from bson import ObjectId + try: + mgmt_doc = rankings_management_collection.find_one({"mix_id": drama_id}) + if not mgmt_doc: + mgmt_doc = rankings_management_collection.find_one({"_id": ObjectId(drama_id)}) + if mgmt_doc: + drama_data = mgmt_doc + except: + pass + + if not drama_data: + return jsonify({ + "success": False, + "message": f"未找到短剧: {drama_id}" + }) + + # 格式化数据(format_mix_item已经包含了所有新字段) + formatted_data = format_mix_item(drama_data, date_str) + + return jsonify({ + "success": True, + "data": formatted_data, + "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + + except Exception as e: + logging.error(f"获取短剧详情失败: {e}") + return jsonify({ + "success": False, + "message": f"获取短剧详情失败: {str(e)}" + }) \ No newline at end of file diff --git a/frontend/src/AdminPanel.vue b/frontend/src/AdminPanel.vue index bcc2cc8..e553e52 100644 --- a/frontend/src/AdminPanel.vue +++ b/frontend/src/AdminPanel.vue @@ -19,6 +19,8 @@ const editForm = reactive({ series_author: '', Manufacturing_Field: '', Copyright_field: '', + classification_type: '', // 新增:女频/玄等 + release_date: '', // 新增:上线日期 play_vv: 0, total_likes_formatted: '', cover_image_url: '', @@ -30,11 +32,14 @@ const editForm = reactive({ // 分类字段 isNovel: false, isAnime: false, - isDrama: false + isDrama: false, + // 评论总结字段 + comments_summary: '' }) // API基础URL -const API_BASE_URL = 'http://159.75.150.210:8443/api' +// const API_BASE_URL = 'http://159.75.150.210:8443/api' // 远程服务器 +const API_BASE_URL = 'http://localhost:8443/api' // 本地服务器 // 格式化播放量 const formatPlayCount = (count) => { @@ -113,6 +118,8 @@ const editItem = async (item) => { editForm.series_author = item.series_author || '' editForm.Manufacturing_Field = item.Manufacturing_Field || '' editForm.Copyright_field = item.Copyright_field || '' + editForm.classification_type = item.classification_type || '' // 新增 + editForm.release_date = item.release_date || '' // 新增 editForm.play_vv = item.play_vv || 0 editForm.total_likes_formatted = item.total_likes_formatted || '' editForm.cover_image_url = item.cover_image_url || '' @@ -121,6 +128,7 @@ const editItem = async (item) => { play_vv_change: item.timeline_data?.play_vv_change || 0, play_vv_change_rate: item.timeline_data?.play_vv_change_rate || 0 } + editForm.comments_summary = item.comments_summary || '' // 加载分类状态(优先使用 mix_id,兼容 mix_name) await loadClassificationStatus(item.mix_id, item.mix_name) @@ -203,6 +211,39 @@ const updateClassification = async (classificationType, isChecked) => { } } +// 清空评论总结(优先使用 mix_id) +const clearCommentsSummary = async () => { + if (!confirm('确定要清空评论总结吗?清空后下次定时任务会重新生成。')) { + return + } + + try { + const today = new Date().toISOString().split('T')[0] + const requestData = { + date: today + } + + // 优先使用 mix_id,备用 mix_name + if (editForm.mix_id) { + requestData.mix_id = editForm.mix_id + } else { + requestData.mix_name = editForm.mix_name + } + + const response = await axios.post(`${API_BASE_URL}/rank/clear_comments_summary`, requestData) + + if (response.data.success) { + editForm.comments_summary = '' + alert('评论总结已清空') + } else { + alert(`清空失败: ${response.data.message}`) + } + } catch (error) { + console.error('清空评论总结失败:', error) + alert('清空评论总结失败,请检查网络连接') + } +} + // 删除项目 const deleteItem = async (item) => { if (!confirm(`确定要删除 "${item.title || item.mix_name}" 吗?`)) { @@ -246,11 +287,14 @@ const saveEdit = async () => { series_author: editForm.series_author, Manufacturing_Field: editForm.Manufacturing_Field, Copyright_field: editForm.Copyright_field, + classification_type: editForm.classification_type, + release_date: editForm.release_date, play_vv: editForm.play_vv, total_likes_formatted: editForm.total_likes_formatted, cover_image_url: editForm.cover_image_url, cover_backup_urls: editForm.cover_backup_urls, - timeline_data: editForm.timeline_data + timeline_data: editForm.timeline_data, + comments_summary: editForm.comments_summary } // 调用后端API更新数据 @@ -416,14 +460,26 @@ onMounted(() => {

-

制作信息

-
- - -
+

制作信息(锁定字段)

- + +
+
+ + +
+
+ +
+

短剧详细信息(锁定字段)

+
+ + +
+
+ +
@@ -467,6 +523,29 @@ onMounted(() => {
+ +
+

评论总结

+
+ + + +
+
+

其他信息

diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 521e2a8..046ccf9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -17,6 +17,10 @@ const updateTime = ref('') // 添加更新时间字段 const showDatePicker = ref(false) // 控制日期选择器显示 const dateOptions = ref([]) // 日期选项列表 const selectedCategory = ref('all') // 当前选中的分类 +const showCommentsSummary = ref(false) // 控制评论总结弹窗显示 +const currentCommentsSummary = ref('') // 当前显示的评论总结 +const currentDramaName = ref('') // 当前短剧名称 +const currentDramaMixId = ref('') // 当前短剧ID // 初始化日期为今天 const initDate = () => { @@ -77,7 +81,8 @@ const fetchRankingData = async () => { params.classification_type = selectedCategory.value } - const response = await axios.get('http://159.75.150.210:8443/api/rank/videos', { + // const response = await axios.get('http://159.75.150.210:8443/api/rank/videos', { // 远程服务器 + const response = await axios.get('http://localhost:8443/api/rank/videos', { // 本地服务器 params: params }) @@ -257,6 +262,33 @@ const getRankBadgeClass = (rank) => { // router.push('/admin') // } +// 获取评论总结(优先使用 mix_id)- 改为直接跳转到详情页 +const fetchCommentsSummary = async (item, event) => { + // 阻止事件冒泡,避免触发卡片点击 + if (event) { + event.stopPropagation() + } + + // 直接跳转到详情页,并定位到评论区域 + const dramaId = item.mix_id || item._id + router.push(`/drama/${dramaId}#comments`) +} + +// 关闭评论总结弹窗 +const closeCommentsSummary = () => { + showCommentsSummary.value = false + currentCommentsSummary.value = '' + currentDramaName.value = '' + currentDramaMixId.value = '' +} + +// 跳转到短剧详情页 +const goToDramaDetail = (item) => { + // 使用 mix_id 作为路由参数 + const dramaId = item.mix_id || item._id + router.push(`/drama/${dramaId}`) +} + // 页面加载时初始化 onMounted(() => { initDate() @@ -331,6 +363,7 @@ onMounted(() => { v-for="(item, index) in rankingData" :key="item._id || index" class="ranking-item" + @click="goToDramaDetail(item)" >
@@ -381,8 +414,20 @@ onMounted(() => {
- 热度 - {{ formatGrowth(item) || '300W' }} +
+ 热度 + {{ formatGrowth(item) || '300W' }} +
+ + +
@@ -414,6 +459,7 @@ onMounted(() => {
+
@@ -612,6 +658,12 @@ onMounted(() => { gap: 12px; position: relative; border-bottom: 1px solid #E1E3E5; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.ranking-item:hover { + background-color: #f8f9fa; } /* 排名徽章 */ @@ -742,10 +794,18 @@ onMounted(() => { /* 增长数据 */ .growth-section { display: flex; - align-items: center; - gap: 4px; + flex-direction: column; + align-items: flex-start; + gap: 2px; flex-shrink: 0; min-width: 60px; + justify-content: flex-end; +} + +.growth-section > div:first-child { + display: flex; + align-items: center; + gap: 4px; } .growth-icon { @@ -876,6 +936,27 @@ onMounted(() => { color: rgba(255, 255, 255, 0.8); } +/* 评论总结按钮样式 */ +.comments-summary-btn { + background: none; + border: none; + color: #333; + font-size: 10px; + cursor: pointer; + padding: 0; + margin-top: 78px; + display: block; + text-align: left; + transition: color 0.2s ease; + white-space: nowrap; +} + +.comments-summary-btn:hover { + color: #666; + text-decoration: underline; +} + + /* 响应式设计 */ @media (max-width: 480px) { .main-container { diff --git a/frontend/src/DramaDetail.vue b/frontend/src/DramaDetail.vue new file mode 100644 index 0000000..7fffb14 --- /dev/null +++ b/frontend/src/DramaDetail.vue @@ -0,0 +1,581 @@ + + + + + diff --git a/frontend/src/images/抖音icon.png b/frontend/src/images/抖音icon.png new file mode 100644 index 0000000000000000000000000000000000000000..59606033912f0e217f7bfa8d148d5e1c955d8873 GIT binary patch literal 8654 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�juXN zMhc>}8RzcfbGY=zn9si6`|;-qW=pJ+tSv_Y&pPZkx;uwtOrpz1OlJma&?LV^gS_*k z)-9|5jZ&G`*S4(i1b)9#f`;VDKjUo74SqE7SMzVONO9cikw6s|-S1TLzVbzs<+N^=4wRCDmHy|s(t zkoJl$3Vxdd%(b*~nmo-hRr&n)C@6R^#rte>0E9DBr*Nx!?q-(GJTo7i>E+Gp%`6Y>uxfVBI26pUO8+ zB;S8ck4uOSVwQfezn}A;>AMQ}Q7i$KJAiwuC*J{IqCDsH0NdgnGsZ_RvAXKJAdXft zz?KDCW;V1Qr%VNV9dYS(YU)%Y3nzeBv8PS(OWf@E%fWZD5bCf>;sG-wWV`v3o&NN&Vl!j_BbVNg#v)EjKadC<)tun&w3+;UbK&RJEle(N2)~ zy?y4;^Fv2kWrir2qf` literal 0 HcmV?d00001 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ace39c5..dc2e2eb 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,11 +1,17 @@ import { createRouter, createWebHistory } from 'vue-router' import AdminPanel from '../AdminPanel.vue' +import DramaDetail from '../DramaDetail.vue' const routes = [ { path: '/admin', name: 'Admin', component: AdminPanel + }, + { + path: '/drama/:id', + name: 'DramaDetail', + component: DramaDetail } ] From 5a1c14a080a3baeae514f3e878d3d5ea0af0490a Mon Sep 17 00:00:00 2001 From: Qyir <13521889462@163.com> Date: Thu, 13 Nov 2025 10:00:39 +0800 Subject: [PATCH 20/21] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Timer_worker.py | 84 ++++++++++-------------------------- frontend/src/DramaDetail.vue | 4 +- 2 files changed, 25 insertions(+), 63 deletions(-) diff --git a/backend/Timer_worker.py b/backend/Timer_worker.py index dd4235c..f2c947c 100644 --- a/backend/Timer_worker.py +++ b/backend/Timer_worker.py @@ -368,42 +368,19 @@ class DouyinAutoScheduler: yesterday_rank = item["yesterday_data"].get("rank", 0) rank_change = yesterday_rank - rank # 使用当前排名计数器 - # 🔍 从Rankings_management获取详细信息(按日期和mix_name查询) - today_str = datetime.now().strftime('%Y-%m-%d') - management_data = rankings_management_collection.find_one({ - "mix_name": mix_name, - "$or": [ - {"created_at": {"$gte": datetime.strptime(today_str, '%Y-%m-%d'), - "$lt": datetime.strptime(today_str, '%Y-%m-%d') + timedelta(days=1)}}, - {"last_updated": {"$gte": datetime.strptime(today_str, '%Y-%m-%d'), - "$lt": datetime.strptime(today_str, '%Y-%m-%d') + timedelta(days=1)}} - ] - }) - - # 🔑 如果今天没有数据,查询昨天的 Rankings_management(仅用于获取分类字段和锁定状态) - classification_data = None - if not management_data: - # 查询昨天的 Rankings_management - yesterday_start = datetime.strptime(yesterday_str, '%Y-%m-%d') - yesterday_end = yesterday_start + timedelta(days=1) - classification_data = rankings_management_collection.find_one({ - "mix_name": mix_name, - "$or": [ - {"created_at": {"$gte": yesterday_start, "$lt": yesterday_end}}, - {"last_updated": {"$gte": yesterday_start, "$lt": yesterday_end}} - ] - }) - if classification_data: - novel_ids = classification_data.get('Novel_IDs', []) - anime_ids = classification_data.get('Anime_IDs', []) - drama_ids = classification_data.get('Drama_IDs', []) - logging.info(f"📋 今天没有数据,从昨天的 Rankings_management 获取分类: {mix_name}") - logging.info(f" - Novel_IDs: {novel_ids}") - logging.info(f" - Anime_IDs: {anime_ids}") - logging.info(f" - Drama_IDs: {drama_ids}") - logging.info(f" - last_updated: {classification_data.get('last_updated')}") + # 🔍 从Rankings_management获取详细信息(按mix_id查询,因为管理数据库每个短剧只有一条记录) + mix_id = video.get("mix_id", "").strip() + management_data = None + + if mix_id: + # 直接按mix_id查询,不需要按日期查询 + management_data = rankings_management_collection.find_one({"mix_id": mix_id}) + if management_data: + logging.info(f"📋 从 Rankings_management 获取数据: {mix_name} (mix_id: {mix_id})") else: - logging.warning(f"⚠️ 今天和昨天都没有数据: {mix_name}") + logging.warning(f"⚠️ 未找到管理数据: {mix_name} (mix_id: {mix_id})") + else: + logging.warning(f"⚠️ mix_id 为空: {mix_name}") ranking_item = { # 🎯 核心榜单字段 @@ -435,35 +412,20 @@ class DouyinAutoScheduler: "realtime_saved": management_data.get("realtime_saved", True) if management_data else True, "created_at": management_data.get("created_at") if management_data else None, "last_updated": management_data.get("last_updated") if management_data else None, - # 🎬 评论总结字段 + # 🎬 评论总结字段:直接从管理数据库获取(按mix_id查询) "comments_summary": management_data.get("comments_summary", "") if management_data else "", - # 🔑 分类字段:区分今天数据和历史数据 - # - 如果今天有数据:从今天的数据获取所有字段 - # - 如果今天没有数据:只从历史记录获取分类字段和锁定状态,其他字段为空 - # 注意:使用 .get() 的第二个参数确保即使字段不存在也会返回空字符串 - "Manufacturing_Field": (management_data.get("Manufacturing_Field", "") if management_data else "") or "", - "Copyright_field": (management_data.get("Copyright_field", "") if management_data else "") or "", - "classification_type": (management_data.get("classification_type", "") if management_data else "") or "", # 新增:类型/元素(确保字段存在) - "release_date": (management_data.get("release_date", "") if management_data else "") or "", # 新增:上线日期(确保字段存在) - "Novel_IDs": ( - management_data.get("Novel_IDs", []) if management_data - else (classification_data.get("Novel_IDs", []) if classification_data else []) - ), - "Anime_IDs": ( - management_data.get("Anime_IDs", []) if management_data - else (classification_data.get("Anime_IDs", []) if classification_data else []) - ), - "Drama_IDs": ( - management_data.get("Drama_IDs", []) if management_data - else (classification_data.get("Drama_IDs", []) if classification_data else []) - ), + # 🔑 分类字段:直接从管理数据库获取(按mix_id查询,每个短剧只有一条记录) + "Manufacturing_Field": management_data.get("Manufacturing_Field", "") if management_data else "", + "Copyright_field": management_data.get("Copyright_field", "") if management_data else "", + "classification_type": management_data.get("classification_type", "") if management_data else "", + "release_date": management_data.get("release_date", "") if management_data else "", + "Novel_IDs": management_data.get("Novel_IDs", []) if management_data else [], + "Anime_IDs": management_data.get("Anime_IDs", []) if management_data else [], + "Drama_IDs": management_data.get("Drama_IDs", []) if management_data else [], - # 🔒 锁定状态:同样区分今天数据和历史数据 - "field_lock_status": ( - management_data.get("field_lock_status", {}) if management_data - else (classification_data.get("field_lock_status", {}) if classification_data else {}) - ), + # 🔒 锁定状态:直接从管理数据库获取 + "field_lock_status": management_data.get("field_lock_status", {}) if management_data else {}, # 📊 时间轴对比数据(重要:包含播放量差值) "timeline_data": { diff --git a/frontend/src/DramaDetail.vue b/frontend/src/DramaDetail.vue index 7fffb14..1fc0a68 100644 --- a/frontend/src/DramaDetail.vue +++ b/frontend/src/DramaDetail.vue @@ -253,12 +253,12 @@ onMounted(() => { .card { display: flex; flex-direction: column; - border-radius: 16px; + border-radius: 0; overflow: hidden; background: #f3f4f6; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); width: 100%; - max-width: 448px; + max-width: 428px; } .header { From 91761b6754b2ca55b5f1a5317c1fc603a76794aa Mon Sep 17 00:00:00 2001 From: Qyir <13521889462@163.com> Date: Thu, 13 Nov 2025 17:49:48 +0800 Subject: [PATCH 21/21] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=9F=AD=E5=89=A7?= =?UTF-8?q?=E7=89=88=E6=9D=83=E6=96=B9=E8=AE=A4=E8=AF=81=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=20=E6=B7=BB=E5=8A=A0=E7=94=B3=E8=AF=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B8=8A=E4=BC=A0=E8=87=B3TOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.py | 2 - backend/routers/article_routes.py | 268 ---------- backend/routers/rank_api_routes.py | 680 +++++++++++++++++++++++++- frontend/src/AdminPanel.vue | 10 +- frontend/src/ClaimApplications.vue | 682 ++++++++++++++++++++++++++ frontend/src/ClaimPage.vue | 759 +++++++++++++++++++++++++++++ frontend/src/DramaDetail.vue | 19 +- frontend/src/router/index.js | 12 + 8 files changed, 2156 insertions(+), 276 deletions(-) delete mode 100644 backend/routers/article_routes.py create mode 100644 frontend/src/ClaimApplications.vue create mode 100644 frontend/src/ClaimPage.vue diff --git a/backend/app.py b/backend/app.py index 4eea4dd..99e868f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -42,9 +42,7 @@ logging.basicConfig( # 导入并注册蓝图 from routers.rank_api_routes import rank_bp -from routers.article_routes import article_bp app.register_blueprint(rank_bp) -app.register_blueprint(article_bp) if __name__ == '__main__': diff --git a/backend/routers/article_routes.py b/backend/routers/article_routes.py deleted file mode 100644 index 9d99260..0000000 --- a/backend/routers/article_routes.py +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -文章API服务器 -提供文章列表获取和文章详情获取的接口 -""" - -from flask import Blueprint, request, jsonify -from datetime import datetime, timedelta -import logging -from database import db -from bson import ObjectId - -# 创建蓝图 -article_bp = Blueprint('article', __name__, url_prefix='/api/article') - -# 获取数据库集合 -articles_collection = db['articles'] - -def format_time(time_obj): - """格式化时间""" - if not time_obj: - return "" - - if isinstance(time_obj, datetime): - return time_obj.strftime("%Y-%m-%d %H:%M:%S") - else: - return str(time_obj) - -def format_article_item(doc): - """格式化文章数据项""" - return { - "_id": str(doc.get("_id", "")), - "title": doc.get("title", ""), - "author_id": doc.get("author_id", ""), - "cover_image": doc.get("cover_image", ""), - "status": doc.get("status", ""), - "summary": doc.get("summary", ""), - "created_at": format_time(doc.get("created_at")), - "likes": doc.get("likes", []), - "likes_count": len(doc.get("likes", [])) - } - -def get_article_list(page=1, limit=20, sort_by="created_at", status=None): - """获取文章列表(分页)""" - try: - # 计算跳过的数量 - skip = (page - 1) * limit - - # 构建查询条件 - query_condition = {} - if status: - query_condition["status"] = status - - # 设置排序字段 - sort_field = sort_by if sort_by in ["created_at", "title"] else "created_at" - sort_order = -1 # 降序 - - # 查询数据 - cursor = articles_collection.find(query_condition).sort(sort_field, sort_order).skip(skip).limit(limit) - docs = list(cursor) - - # 获取总数 - total = articles_collection.count_documents(query_condition) - - # 格式化数据 - article_list = [] - for doc in docs: - item = format_article_item(doc) - article_list.append(item) - - return { - "success": True, - "data": article_list, - "pagination": { - "page": page, - "limit": limit, - "total": total, - "pages": (total + limit - 1) // limit, - "has_next": page * limit < total, - "has_prev": page > 1 - }, - "sort_by": sort_by, - "status_filter": status, - "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - - except Exception as e: - logging.error(f"获取文章列表失败: {e}") - return {"success": False, "message": f"获取数据失败: {str(e)}"} - -def search_articles(keyword, page=1, limit=10): - """搜索文章""" - try: - if not keyword: - return {"success": False, "message": "请提供搜索关键词"} - - # 计算跳过的数量 - skip = (page - 1) * limit - - # 构建搜索条件(模糊匹配标题和内容) - search_condition = { - "$or": [ - {"title": {"$regex": keyword, "$options": "i"}}, - {"content": {"$regex": keyword, "$options": "i"}}, - {"summary": {"$regex": keyword, "$options": "i"}} - ] - } - - # 查询数据 - cursor = articles_collection.find(search_condition).sort("created_at", -1).skip(skip).limit(limit) - docs = list(cursor) - - # 获取搜索结果总数 - total = articles_collection.count_documents(search_condition) - - # 格式化数据 - search_results = [] - for doc in docs: - item = format_article_item(doc) - search_results.append(item) - - return { - "success": True, - "data": search_results, - "keyword": keyword, - "pagination": { - "page": page, - "limit": limit, - "total": total, - "pages": (total + limit - 1) // limit, - "has_next": page * limit < total, - "has_prev": page > 1 - }, - "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - - except Exception as e: - logging.error(f"搜索文章失败: {e}") - return {"success": False, "message": f"搜索失败: {str(e)}"} - -def get_article_detail(article_id): - """获取文章详情""" - try: - # 尝试通过ObjectId查找 - try: - doc = articles_collection.find_one({"_id": ObjectId(article_id)}) - except: - # 如果ObjectId无效,尝试其他字段 - doc = articles_collection.find_one({ - "$or": [ - {"title": article_id}, - {"author_id": article_id} - ] - }) - - if not doc: - return {"success": False, "message": "未找到文章信息"} - - # 格式化详细信息 - detail = format_article_item(doc) - - return { - "success": True, - "data": detail, - "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - - except Exception as e: - logging.error(f"获取文章详情失败: {e}") - return {"success": False, "message": f"获取详情失败: {str(e)}"} - -def get_statistics(): - """获取统计信息""" - try: - # 基本统计 - total_articles = articles_collection.count_documents({}) - - if total_articles == 0: - return {"success": False, "message": "暂无数据"} - - # 按状态统计 - status_stats = [] - for status in ["draft", "published", "archived"]: - count = articles_collection.count_documents({"status": status}) - status_stats.append({"status": status, "count": count}) - - # 获取最新更新时间 - latest_doc = articles_collection.find().sort("created_at", -1).limit(1) - latest_time = "" - if latest_doc: - latest_list = list(latest_doc) - if latest_list: - latest_time = format_time(latest_list[0].get("created_at")) - - return { - "success": True, - "data": { - "total_articles": total_articles, - "status_stats": status_stats, - "latest_update": latest_time - }, - "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - - except Exception as e: - logging.error(f"获取统计信息失败: {e}") - return {"success": False, "message": f"获取统计失败: {str(e)}"} - -# 路由定义 -@article_bp.route('/list') -def get_articles(): - """获取文章列表""" - page = int(request.args.get('page', 1)) - limit = int(request.args.get('limit', 20)) - sort_by = request.args.get('sort', 'created_at') - status = request.args.get('status') - - result = get_article_list(page, limit, sort_by, status) - return jsonify(result) - -@article_bp.route('/search') -def search(): - """搜索文章""" - keyword = request.args.get('q', '') - page = int(request.args.get('page', 1)) - limit = int(request.args.get('limit', 10)) - result = search_articles(keyword, page, limit) - return jsonify(result) - -@article_bp.route('/detail') -def get_detail(): - """获取文章详情""" - article_id = request.args.get('id', '') - result = get_article_detail(article_id) - return jsonify(result) - -@article_bp.route('/stats') -def get_stats(): - """获取统计信息""" - result = get_statistics() - return jsonify(result) - -@article_bp.route('/health') -def health_check(): - """健康检查""" - try: - # 检查数据库连接 - total_records = articles_collection.count_documents({}) - - return jsonify({ - "success": True, - "message": "服务正常", - "data": { - "database": "连接正常", - "total_records": total_records, - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - }) - except Exception as e: - return jsonify({ - "success": False, - "message": f"服务异常: {str(e)}", - "data": { - "database": "连接失败", - "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - } - }) \ No newline at end of file diff --git a/backend/routers/rank_api_routes.py b/backend/routers/rank_api_routes.py index f83e5aa..8038f0e 100644 --- a/backend/routers/rank_api_routes.py +++ b/backend/routers/rank_api_routes.py @@ -9,7 +9,10 @@ from flask import Blueprint, request, jsonify from datetime import datetime, timedelta import logging import re +import uuid +from werkzeug.utils import secure_filename from database import db +from handlers.Rankings.tos_client import oss_client # 创建蓝图 rank_bp = Blueprint('rank', __name__, url_prefix='/api/rank') @@ -17,6 +20,7 @@ rank_bp = Blueprint('rank', __name__, url_prefix='/api/rank') # 获取数据库集合 collection = db['Ranking_storage'] # 主要数据源:榜单存储表(包含data数组) rankings_management_collection = db['Rankings_management'] # 管理数据库(字段同步源) +claim_applications_collection = db['Claim_Applications'] # 认领申请集合 def format_playcount(playcount_str): """格式化播放量字符串为数字""" @@ -2258,4 +2262,678 @@ def get_drama_detail_by_id(drama_id): return jsonify({ "success": False, "message": f"获取短剧详情失败: {str(e)}" - }) \ No newline at end of file + }) + +def upload_certification_file(file): + """ + 上传认领证明文件到TOS + + Args: + file: 上传的文件对象 + + Returns: + str: TOS永久链接URL + """ + try: + # 获取文件扩展名 + filename = secure_filename(file.filename) + file_extension = '' + if '.' in filename: + file_extension = '.' + filename.rsplit('.', 1)[1].lower() + + # 验证文件类型 + allowed_image_extensions = ['.jpg', '.jpeg', '.png', '.gif'] + allowed_doc_extensions = ['.pdf', '.doc', '.docx'] + + if file_extension not in allowed_image_extensions + allowed_doc_extensions: + raise ValueError(f"不支持的文件类型: {file_extension}") + + # 验证文件大小 + file.seek(0, 2) # 移动到文件末尾 + file_size = file.tell() # 获取文件大小 + file.seek(0) # 重置文件指针 + + max_size = 10 * 1024 * 1024 # 10MB for images + if file_extension in allowed_doc_extensions: + max_size = 20 * 1024 * 1024 # 20MB for documents + + if file_size > max_size: + raise ValueError(f"文件大小超过限制: {file_size / 1024 / 1024:.2f}MB") + + # 生成唯一文件名 + random_filename = f"{uuid.uuid4().hex}{file_extension}" + object_key = f"media/rank/Certification/{random_filename}" + + # 上传到TOS + tos_url = oss_client.upload_bytes( + data=file.read(), + object_key=object_key, + content_type=file.content_type or 'application/octet-stream', + return_url=True + ) + + logging.info(f"文件上传成功: {filename} -> {tos_url}") + return tos_url + + except Exception as e: + logging.error(f"文件上传失败: {str(e)}") + raise + + +@rank_bp.route('/claim', methods=['POST']) +def submit_claim(): + """ + 提交认领申请(新版本:上传文件到TOS并创建待审核申请) + """ + try: + # 获取表单数据 + drama_id = request.form.get('drama_id') + field_type = request.form.get('field_type') # 'copyright' 或 'manufacturing' + company_name = request.form.get('company_name') + description = request.form.get('description', '') + + # 验证必填字段 + if not all([drama_id, field_type, company_name]): + return jsonify({ + "success": False, + "message": "缺少必填字段" + }), 400 + + # 验证字段类型 + if field_type not in ['copyright', 'manufacturing']: + return jsonify({ + "success": False, + "message": "无效的字段类型" + }), 400 + + # 获取短剧信息 + drama_info = rankings_management_collection.find_one({"mix_id": drama_id}) + if not drama_info: + return jsonify({ + "success": False, + "message": "未找到对应的短剧" + }), 404 + + drama_name = drama_info.get('mix_name', '未知短剧') + + # 处理上传的文件并上传到TOS + uploaded_files = request.files.getlist('files') + tos_file_urls = [] + + if uploaded_files: + for file in uploaded_files: + if file and file.filename: + try: + tos_url = upload_certification_file(file) + tos_file_urls.append(tos_url) + except ValueError as ve: + return jsonify({ + "success": False, + "message": str(ve) + }), 400 + except Exception as e: + return jsonify({ + "success": False, + "message": f"文件上传失败: {str(e)}" + }), 500 + + if not tos_file_urls: + return jsonify({ + "success": False, + "message": "请至少上传一个证明文件" + }), 400 + + # 检查是否存在该短剧+该字段类型的待审核申请 + existing_application = claim_applications_collection.find_one({ + "drama_id": drama_id, + "field_type": field_type, + "status": "pending" + }) + + # 如果存在待审核申请,删除旧的(但保留TOS文件) + if existing_application: + claim_applications_collection.delete_one({"_id": existing_application["_id"]}) + logging.info(f"删除旧的待审核申请: {existing_application.get('application_id')}") + + # 创建新的申请记录 + application_id = str(uuid.uuid4()) + application_data = { + "application_id": application_id, + "drama_id": drama_id, + "drama_name": drama_name, + "field_type": field_type, + "company_name": company_name, + "description": description, + "tos_file_urls": tos_file_urls, + "status": "pending", + "submit_time": datetime.now(), + "review_time": None, + "reviewer": None, + "reject_reason": None + } + + claim_applications_collection.insert_one(application_data) + + logging.info(f"认领申请创建成功: application_id={application_id}, drama_id={drama_id}, field_type={field_type}") + + return jsonify({ + "success": True, + "message": "认领申请提交成功,等待管理员审核", + "data": { + "application_id": application_id, + "drama_id": drama_id, + "field_type": field_type, + "company_name": company_name, + "file_count": len(tos_file_urls) + } + }) + + except Exception as e: + logging.error(f"提交认领申请失败: {e}") + return jsonify({ + "success": False, + "message": f"提交认领申请失败: {str(e)}" + }), 500 + + +# 获取申请列表 +@rank_bp.route('/claim/applications', methods=['GET']) +def get_claim_applications(): + """ + 获取认领申请列表 + 支持筛选和分页 + """ + try: + # 获取查询参数 + status = request.args.get('status', 'all') # all/pending/approved/rejected + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 20)) + + # 构建查询条件 + query = {} + if status != 'all': + query['status'] = status + + # 查询总数 + total = claim_applications_collection.count_documents(query) + + # 查询数据(按提交时间倒序) + applications = list(claim_applications_collection.find(query) + .sort('submit_time', -1) + .skip((page - 1) * limit) + .limit(limit)) + + # 格式化数据 + formatted_applications = [] + for app in applications: + formatted_applications.append({ + "application_id": app.get('application_id'), + "drama_id": app.get('drama_id'), + "drama_name": app.get('drama_name'), + "field_type": app.get('field_type'), + "field_type_label": "版权方" if app.get('field_type') == 'copyright' else "承制方", + "company_name": app.get('company_name'), + "status": app.get('status'), + "status_label": { + "pending": "待审核", + "approved": "已通过", + "rejected": "已拒绝" + }.get(app.get('status'), "未知"), + "submit_time": app.get('submit_time').strftime("%Y-%m-%d %H:%M:%S") if app.get('submit_time') else "", + "file_count": len(app.get('tos_file_urls', [])) + }) + + return jsonify({ + "success": True, + "data": formatted_applications, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) // limit + } + }) + + except Exception as e: + logging.error(f"获取申请列表失败: {e}") + return jsonify({ + "success": False, + "message": f"获取申请列表失败: {str(e)}" + }), 500 + + +# 获取申请详情 +@rank_bp.route('/claim/application/', methods=['GET']) +def get_claim_application_detail(application_id): + """ + 获取认领申请详情 + """ + try: + application = claim_applications_collection.find_one({"application_id": application_id}) + + if not application: + return jsonify({ + "success": False, + "message": "申请不存在" + }), 404 + + # 格式化数据 + formatted_data = { + "application_id": application.get('application_id'), + "drama_id": application.get('drama_id'), + "drama_name": application.get('drama_name'), + "field_type": application.get('field_type'), + "field_type_label": "版权方" if application.get('field_type') == 'copyright' else "承制方", + "company_name": application.get('company_name'), + "description": application.get('description', ''), + "tos_file_urls": application.get('tos_file_urls', []), + "status": application.get('status'), + "status_label": { + "pending": "待审核", + "approved": "已通过", + "rejected": "已拒绝" + }.get(application.get('status'), "未知"), + "submit_time": application.get('submit_time').strftime("%Y-%m-%d %H:%M:%S") if application.get('submit_time') else "", + "review_time": application.get('review_time').strftime("%Y-%m-%d %H:%M:%S") if application.get('review_time') else None, + "reviewer": application.get('reviewer'), + "reject_reason": application.get('reject_reason') + } + + return jsonify({ + "success": True, + "data": formatted_data + }) + + except Exception as e: + logging.error(f"获取申请详情失败: {e}") + return jsonify({ + "success": False, + "message": f"获取申请详情失败: {str(e)}" + }), 500 + + +# 审核申请 +@rank_bp.route('/claim/review', methods=['POST']) +def review_claim_application(): + """ + 审核认领申请 + """ + try: + data = request.get_json() + application_id = data.get('application_id') + action = data.get('action') # 'approve' 或 'reject' + reject_reason = data.get('reject_reason', '') + reviewer = data.get('reviewer', 'admin') # 审核人 + + # 验证参数 + if not application_id or not action: + return jsonify({ + "success": False, + "message": "缺少必填参数" + }), 400 + + if action not in ['approve', 'reject']: + return jsonify({ + "success": False, + "message": "无效的操作类型" + }), 400 + + if action == 'reject' and not reject_reason: + return jsonify({ + "success": False, + "message": "拒绝时必须填写理由" + }), 400 + + # 查找申请 + application = claim_applications_collection.find_one({"application_id": application_id}) + if not application: + return jsonify({ + "success": False, + "message": "申请不存在" + }), 404 + + if application.get('status') != 'pending': + return jsonify({ + "success": False, + "message": "该申请已经被审核过了" + }), 400 + + # 执行审核操作 + if action == 'approve': + # 通过:更新短剧字段并锁定 + drama_id = application.get('drama_id') + field_type = application.get('field_type') + company_name = application.get('company_name') + description = application.get('description', '') + tos_file_urls = application.get('tos_file_urls', []) + + field_name = 'Copyright_field' if field_type == 'copyright' else 'Manufacturing_field' + + # 更新 Rankings_management 数据库 + update_data = { + field_name: company_name, + f"{field_name}_claim_description": description, + f"{field_name}_claim_images": tos_file_urls, + f"{field_name}_claim_time": datetime.now(), + "last_updated": datetime.now() + } + + # 设置锁定状态 + lock_status_update = { + f"field_lock_status.{field_name}": True, + f"field_lock_status.{field_name}_claim_description": True, + f"field_lock_status.{field_name}_claim_images": True, + f"field_lock_status.{field_name}_claim_time": True + } + update_data.update(lock_status_update) + + rankings_management_collection.update_one( + {"mix_id": drama_id}, + {"$set": update_data} + ) + + # 同步更新 Ranking_storage 数据库 + ranking_storage_update = { + f"data.$[elem].{field_name}": company_name, + f"data.$[elem].{field_name}_claim_description": description, + f"data.$[elem].{field_name}_claim_images": tos_file_urls, + f"data.$[elem].{field_name}_claim_time": datetime.now(), + f"data.$[elem].field_lock_status.{field_name}": True, + f"data.$[elem].field_lock_status.{field_name}_claim_description": True, + f"data.$[elem].field_lock_status.{field_name}_claim_images": True, + f"data.$[elem].field_lock_status.{field_name}_claim_time": True + } + + collection.update_many( + {"data.mix_id": drama_id}, + {"$set": ranking_storage_update}, + array_filters=[{"elem.mix_id": drama_id}] + ) + + # 更新申请状态 + claim_applications_collection.update_one( + {"application_id": application_id}, + {"$set": { + "status": "approved", + "review_time": datetime.now(), + "reviewer": reviewer + }} + ) + + logging.info(f"认领申请审核通过: application_id={application_id}, drama_id={drama_id}") + + return jsonify({ + "success": True, + "message": "申请已通过,短剧信息已更新" + }) + + else: # reject + # 拒绝:只更新申请状态 + claim_applications_collection.update_one( + {"application_id": application_id}, + {"$set": { + "status": "rejected", + "review_time": datetime.now(), + "reviewer": reviewer, + "reject_reason": reject_reason + }} + ) + + logging.info(f"认领申请已拒绝: application_id={application_id}, reason={reject_reason}") + + return jsonify({ + "success": True, + "message": "申请已拒绝" + }) + + except Exception as e: + logging.error(f"审核申请失败: {e}") + return jsonify({ + "success": False, + "message": f"审核申请失败: {str(e)}" + }), 500 + + +# 获取待审核数量 +@rank_bp.route('/claim/pending-count', methods=['GET']) +def get_pending_claim_count(): + """ + 获取待审核的认领申请数量 + """ + try: + count = claim_applications_collection.count_documents({"status": "pending"}) + + return jsonify({ + "success": True, + "count": count + }) + + except Exception as e: + logging.error(f"获取待审核数量失败: {e}") + return jsonify({ + "success": False, + "message": f"获取待审核数量失败: {str(e)}" + }), 500 + + +# ==================== 文章相关API ==================== +# 获取数据库集合 +articles_collection = db['articles'] + +def format_article_item(doc): + """格式化文章数据项""" + return { + "_id": str(doc.get("_id", "")), + "title": doc.get("title", ""), + "author_id": doc.get("author_id", ""), + "cover_image": doc.get("cover_image", ""), + "status": doc.get("status", ""), + "summary": doc.get("summary", ""), + "created_at": format_time(doc.get("created_at")), + "likes": doc.get("likes", []), + "likes_count": len(doc.get("likes", [])) + } + +def get_article_list_data(page=1, limit=20, sort_by="created_at", status=None): + """获取文章列表(分页)""" + try: + skip = (page - 1) * limit + query_condition = {} + if status: + query_condition["status"] = status + + sort_field = sort_by if sort_by in ["created_at", "title"] else "created_at" + sort_order = -1 + + cursor = articles_collection.find(query_condition).sort(sort_field, sort_order).skip(skip).limit(limit) + docs = list(cursor) + total = articles_collection.count_documents(query_condition) + + article_list = [] + for doc in docs: + item = format_article_item(doc) + article_list.append(item) + + return { + "success": True, + "data": article_list, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) // limit, + "has_next": page * limit < total, + "has_prev": page > 1 + }, + "sort_by": sort_by, + "status_filter": status, + "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + except Exception as e: + logging.error(f"获取文章列表失败: {e}") + return {"success": False, "message": f"获取数据失败: {str(e)}"} + +def search_articles_data(keyword, page=1, limit=10): + """搜索文章""" + try: + if not keyword: + return {"success": False, "message": "请提供搜索关键词"} + + skip = (page - 1) * limit + search_condition = { + "$or": [ + {"title": {"$regex": keyword, "$options": "i"}}, + {"content": {"$regex": keyword, "$options": "i"}}, + {"summary": {"$regex": keyword, "$options": "i"}} + ] + } + + cursor = articles_collection.find(search_condition).sort("created_at", -1).skip(skip).limit(limit) + docs = list(cursor) + total = articles_collection.count_documents(search_condition) + + search_results = [] + for doc in docs: + item = format_article_item(doc) + search_results.append(item) + + return { + "success": True, + "data": search_results, + "keyword": keyword, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) // limit, + "has_next": page * limit < total, + "has_prev": page > 1 + }, + "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + except Exception as e: + logging.error(f"搜索文章失败: {e}") + return {"success": False, "message": f"搜索失败: {str(e)}"} + +def get_article_detail_data(article_id): + """获取文章详情""" + try: + from bson import ObjectId + try: + doc = articles_collection.find_one({"_id": ObjectId(article_id)}) + except: + doc = articles_collection.find_one({ + "$or": [ + {"title": article_id}, + {"author_id": article_id} + ] + }) + + if not doc: + return {"success": False, "message": "未找到文章信息"} + + detail = format_article_item(doc) + + return { + "success": True, + "data": detail, + "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + except Exception as e: + logging.error(f"获取文章详情失败: {e}") + return {"success": False, "message": f"获取详情失败: {str(e)}"} + +def get_article_statistics(): + """获取统计信息""" + try: + total_articles = articles_collection.count_documents({}) + + if total_articles == 0: + return {"success": False, "message": "暂无数据"} + + status_stats = [] + for status in ["draft", "published", "archived"]: + count = articles_collection.count_documents({"status": status}) + status_stats.append({"status": status, "count": count}) + + latest_doc = articles_collection.find().sort("created_at", -1).limit(1) + latest_time = "" + if latest_doc: + latest_list = list(latest_doc) + if latest_list: + latest_time = format_time(latest_list[0].get("created_at")) + + return { + "success": True, + "data": { + "total_articles": total_articles, + "status_stats": status_stats, + "latest_update": latest_time + }, + "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + except Exception as e: + logging.error(f"获取统计信息失败: {e}") + return {"success": False, "message": f"获取统计失败: {str(e)}"} + +# 文章路由定义 +@rank_bp.route('/article/list') +def get_articles_route(): + """获取文章列表""" + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 20)) + sort_by = request.args.get('sort', 'created_at') + status = request.args.get('status') + result = get_article_list_data(page, limit, sort_by, status) + return jsonify(result) + +@rank_bp.route('/article/search') +def search_articles_route(): + """搜索文章""" + keyword = request.args.get('q', '') + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 10)) + result = search_articles_data(keyword, page, limit) + return jsonify(result) + +@rank_bp.route('/article/detail') +def get_article_detail_route(): + """获取文章详情""" + article_id = request.args.get('id', '') + result = get_article_detail_data(article_id) + return jsonify(result) + +@rank_bp.route('/article/stats') +def get_article_stats_route(): + """获取统计信息""" + result = get_article_statistics() + return jsonify(result) + +@rank_bp.route('/article/health') +def article_health_check(): + """健康检查""" + try: + total_records = articles_collection.count_documents({}) + + return jsonify({ + "success": True, + "message": "服务正常", + "data": { + "database": "连接正常", + "total_records": total_records, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + }) + except Exception as e: + return jsonify({ + "success": False, + "message": f"服务异常: {str(e)}", + "data": { + "database": "连接失败", + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + }) diff --git a/frontend/src/AdminPanel.vue b/frontend/src/AdminPanel.vue index e553e52..c746abc 100644 --- a/frontend/src/AdminPanel.vue +++ b/frontend/src/AdminPanel.vue @@ -342,6 +342,11 @@ const goBack = () => { router.push('/') } +// 跳转到认领申请管理页面 +const goToClaimApplications = () => { + router.push('/admin/claim-applications') +} + // 页面加载时初始化 onMounted(() => { fetchRankingData() @@ -359,6 +364,9 @@ onMounted(() => {

AI棒榜 - 后台管理

+ @@ -586,7 +594,7 @@ export default { /* 主容器 */ .main-container { - max-width: 375px; + max-width: 428px; margin: 0 auto; background: #f5f5f5; min-height: 100vh; diff --git a/frontend/src/ClaimApplications.vue b/frontend/src/ClaimApplications.vue new file mode 100644 index 0000000..0db6e54 --- /dev/null +++ b/frontend/src/ClaimApplications.vue @@ -0,0 +1,682 @@ + + + + + diff --git a/frontend/src/ClaimPage.vue b/frontend/src/ClaimPage.vue new file mode 100644 index 0000000..25114b5 --- /dev/null +++ b/frontend/src/ClaimPage.vue @@ -0,0 +1,759 @@ + + +