chanfasf commited on
Commit
5960aee
·
1 Parent(s): e04fdc1

Feature: add cleanup invalid videos functionality

Browse files
backend/app/api/videos.py CHANGED
@@ -1,10 +1,14 @@
1
  from typing import Optional
 
2
  from fastapi import APIRouter, Depends, Query, HTTPException
3
  from sqlalchemy.orm import Session
4
 
5
  from app.database import get_db
6
- from app.models.video import Platform
7
  from app.analyzers.ranking import RankingAnalyzer
 
 
 
8
 
9
  router = APIRouter(prefix="/videos", tags=["videos"])
10
 
@@ -58,3 +62,61 @@ def get_trending(
58
  def get_platform_stats(db: Session = Depends(get_db)):
59
  analyzer = RankingAnalyzer(db)
60
  return analyzer.get_platform_stats()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from typing import Optional
2
+ import logging
3
  from fastapi import APIRouter, Depends, Query, HTTPException
4
  from sqlalchemy.orm import Session
5
 
6
  from app.database import get_db
7
+ from app.models.video import Platform, Video, VideoSnapshot, ViralRecord, RevenueEstimate
8
  from app.analyzers.ranking import RankingAnalyzer
9
+ from app.crawlers.youtube import YouTubeCrawler
10
+
11
+ logger = logging.getLogger(__name__)
12
 
13
  router = APIRouter(prefix="/videos", tags=["videos"])
14
 
 
62
  def get_platform_stats(db: Session = Depends(get_db)):
63
  analyzer = RankingAnalyzer(db)
64
  return analyzer.get_platform_stats()
65
+
66
+
67
+ @router.post("/cleanup-invalid")
68
+ def cleanup_invalid_videos(
69
+ platform: Optional[str] = Query(None, description="平台筛选: youtube 或 tiktok"),
70
+ dry_run: bool = Query(False, description="仅检测不删除"),
71
+ db: Session = Depends(get_db),
72
+ ):
73
+ p = _parse_platform(platform)
74
+
75
+ query = db.query(Video)
76
+ if p:
77
+ query = query.filter(Video.platform == p)
78
+
79
+ videos = query.all()
80
+ crawler = YouTubeCrawler()
81
+
82
+ valid_videos = []
83
+ invalid_videos = []
84
+
85
+ for video in videos:
86
+ is_valid = False
87
+ try:
88
+ if video.platform == Platform.YOUTUBE:
89
+ details = crawler.get_video_details(video.video_id)
90
+ if details:
91
+ is_valid = True
92
+ else:
93
+ is_valid = True
94
+ except Exception as e:
95
+ logger.error(f"检测视频 {video.video_id} 失败: {e}")
96
+
97
+ if is_valid:
98
+ valid_videos.append(video.id)
99
+ else:
100
+ invalid_videos.append({
101
+ "id": video.id,
102
+ "video_id": video.video_id,
103
+ "platform": video.platform.value,
104
+ "title": video.title,
105
+ })
106
+
107
+ if not dry_run and invalid_videos:
108
+ for inv in invalid_videos:
109
+ db.query(VideoSnapshot).filter(VideoSnapshot.video_id == inv["id"]).delete()
110
+ db.query(ViralRecord).filter(ViralRecord.video_id == inv["id"]).delete()
111
+ db.query(RevenueEstimate).filter(RevenueEstimate.video_id == inv["id"]).delete()
112
+ db.query(Video).filter(Video.id == inv["id"]).delete()
113
+ db.commit()
114
+
115
+ return {
116
+ "total_checked": len(videos),
117
+ "valid_count": len(valid_videos),
118
+ "invalid_count": len(invalid_videos),
119
+ "invalid_videos": invalid_videos[:20],
120
+ "dry_run": dry_run,
121
+ "deleted": not dry_run and len(invalid_videos) > 0,
122
+ }
frontend/src/api/index.js CHANGED
@@ -21,6 +21,7 @@ export const videosApi = {
21
  getTopLiked: (params) => api.get('/videos/top-liked', { params }),
22
  getTrending: (params) => api.get('/videos/trending', { params }),
23
  getStats: () => api.get('/videos/stats'),
 
24
  }
25
 
26
  export const analyticsApi = {
 
21
  getTopLiked: (params) => api.get('/videos/top-liked', { params }),
22
  getTrending: (params) => api.get('/videos/trending', { params }),
23
  getStats: () => api.get('/videos/stats'),
24
+ cleanupInvalid: (params) => api.post('/videos/cleanup-invalid', null, { params }),
25
  }
26
 
27
  export const analyticsApi = {
frontend/src/views/Crawl.vue CHANGED
@@ -45,6 +45,45 @@
45
  <span class="demo-hint">为所有视频计算收入估算数据</span>
46
  </div>
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  <div class="task-grid">
49
  <div class="card task-card">
50
  <div class="task-header">
@@ -121,7 +160,7 @@
121
  </template>
122
 
123
  <script>
124
- import { tasksApi } from '../api'
125
 
126
  export default {
127
  name: 'Crawl',
@@ -150,6 +189,8 @@ export default {
150
  seeding: false,
151
  resetting: false,
152
  calculating: false,
 
 
153
  logs: [],
154
  }
155
  },
@@ -273,6 +314,28 @@ export default {
273
  this.resetting = false
274
  }
275
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  },
277
  }
278
  </script>
@@ -475,6 +538,114 @@ export default {
475
  color: #475569;
476
  }
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  @media (max-width: 768px) {
479
  .task-grid {
480
  grid-template-columns: 1fr;
 
45
  <span class="demo-hint">为所有视频计算收入估算数据</span>
46
  </div>
47
 
48
+ <div class="demo-section">
49
+ <button class="btn btn-danger" @click="cleanupInvalid" :disabled="cleaning">
50
+ {{ cleaning ? '⏳ 检测中...' : '🗑️ 清理失效视频' }}
51
+ </button>
52
+ <span class="demo-hint">检测并删除链接失效的视频(仅YouTube)</span>
53
+ </div>
54
+
55
+ <div v-if="cleanupResult" class="card cleanup-result">
56
+ <h3 class="section-title">📊 清理结果</h3>
57
+ <div class="result-grid">
58
+ <div class="result-item">
59
+ <div class="result-label">检测总数</div>
60
+ <div class="result-value">{{ cleanupResult.total_checked }}</div>
61
+ </div>
62
+ <div class="result-item">
63
+ <div class="result-label">有效视频</div>
64
+ <div class="result-value success">{{ cleanupResult.valid_count }}</div>
65
+ </div>
66
+ <div class="result-item">
67
+ <div class="result-label">失效视频</div>
68
+ <div class="result-value error">{{ cleanupResult.invalid_count }}</div>
69
+ </div>
70
+ <div class="result-item">
71
+ <div class="result-label">状态</div>
72
+ <div class="result-value" :class="cleanupResult.deleted ? 'success' : 'info'">
73
+ {{ cleanupResult.deleted ? '已删除' : '仅检测' }}
74
+ </div>
75
+ </div>
76
+ </div>
77
+ <div v-if="cleanupResult.invalid_videos && cleanupResult.invalid_videos.length > 0" class="invalid-list">
78
+ <h4>失效视频列表:</h4>
79
+ <div v-for="v in cleanupResult.invalid_videos" :key="v.id" class="invalid-item">
80
+ <span class="invalid-platform">{{ v.platform === 'youtube' ? 'YT' : 'TT' }}</span>
81
+ <span class="invalid-title">{{ v.title }}</span>
82
+ <span class="invalid-id">ID: {{ v.video_id }}</span>
83
+ </div>
84
+ </div>
85
+ </div>
86
+
87
  <div class="task-grid">
88
  <div class="card task-card">
89
  <div class="task-header">
 
160
  </template>
161
 
162
  <script>
163
+ import { tasksApi, videosApi } from '../api'
164
 
165
  export default {
166
  name: 'Crawl',
 
189
  seeding: false,
190
  resetting: false,
191
  calculating: false,
192
+ cleaning: false,
193
+ cleanupResult: null,
194
  logs: [],
195
  }
196
  },
 
314
  this.resetting = false
315
  }
316
  },
317
+ async cleanupInvalid() {
318
+ this.cleaning = true
319
+ this.cleanupResult = null
320
+ const startTime = new Date().toLocaleTimeString()
321
+ try {
322
+ const res = await videosApi.cleanupInvalid({ dry_run: false })
323
+ this.cleanupResult = res.data
324
+ this.logs.unshift({
325
+ time: startTime,
326
+ status: res.data.invalid_count > 0 ? 'warning' : 'success',
327
+ message: `清理完成: 检测${res.data.total_checked}个视频,发现${res.data.invalid_count}个失效`,
328
+ })
329
+ } catch (e) {
330
+ this.logs.unshift({
331
+ time: startTime,
332
+ status: 'error',
333
+ message: `清理失败: ${e.response?.data?.detail || e.message}`,
334
+ })
335
+ } finally {
336
+ this.cleaning = false
337
+ }
338
+ },
339
  },
340
  }
341
  </script>
 
538
  color: #475569;
539
  }
540
 
541
+ .cleanup-result {
542
+ margin-bottom: 20px;
543
+ }
544
+
545
+ .cleanup-result .result-grid {
546
+ display: grid;
547
+ grid-template-columns: repeat(4, 1fr);
548
+ gap: 16px;
549
+ margin-bottom: 20px;
550
+ }
551
+
552
+ .cleanup-result .result-item {
553
+ text-align: center;
554
+ padding: 16px;
555
+ background: #f8fafc;
556
+ border-radius: 10px;
557
+ }
558
+
559
+ .cleanup-result .result-label {
560
+ font-size: 11px;
561
+ font-weight: 700;
562
+ color: #64748b;
563
+ margin-bottom: 8px;
564
+ }
565
+
566
+ .cleanup-result .result-value {
567
+ font-size: 24px;
568
+ font-weight: 800;
569
+ color: #1e293b;
570
+ }
571
+
572
+ .cleanup-result .result-value.success {
573
+ color: #059669;
574
+ }
575
+
576
+ .cleanup-result .result-value.error {
577
+ color: #dc2626;
578
+ }
579
+
580
+ .cleanup-result .result-value.info {
581
+ color: #6366f1;
582
+ }
583
+
584
+ .invalid-list {
585
+ margin-top: 16px;
586
+ }
587
+
588
+ .invalid-list h4 {
589
+ font-size: 13px;
590
+ color: #64748b;
591
+ margin-bottom: 12px;
592
+ }
593
+
594
+ .invalid-item {
595
+ display: flex;
596
+ align-items: center;
597
+ gap: 10px;
598
+ padding: 8px 12px;
599
+ background: #fef2f2;
600
+ border-radius: 8px;
601
+ margin-bottom: 6px;
602
+ }
603
+
604
+ .invalid-platform {
605
+ display: inline-block;
606
+ padding: 2px 8px;
607
+ border-radius: 4px;
608
+ font-size: 10px;
609
+ font-weight: 700;
610
+ background: #dc2626;
611
+ color: white;
612
+ }
613
+
614
+ .invalid-title {
615
+ flex: 1;
616
+ font-size: 13px;
617
+ color: #1e293b;
618
+ overflow: hidden;
619
+ text-overflow: ellipsis;
620
+ white-space: nowrap;
621
+ }
622
+
623
+ .invalid-id {
624
+ font-size: 11px;
625
+ color: #94a3b8;
626
+ }
627
+
628
+ .btn-danger {
629
+ background: linear-gradient(135deg, #dc2626, #ef4444);
630
+ color: white;
631
+ border: none;
632
+ padding: 12px 24px;
633
+ border-radius: 10px;
634
+ font-weight: 600;
635
+ cursor: pointer;
636
+ transition: all 0.2s;
637
+ }
638
+
639
+ .btn-danger:hover:not(:disabled) {
640
+ transform: translateY(-1px);
641
+ box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3);
642
+ }
643
+
644
+ .btn-danger:disabled {
645
+ opacity: 0.5;
646
+ cursor: not-allowed;
647
+ }
648
+
649
  @media (max-width: 768px) {
650
  .task-grid {
651
  grid-template-columns: 1fr;