bep40 commited on
Commit
dd41153
·
verified ·
1 Parent(s): 2d4dd0f

Upload yt_scraper.py

Browse files
Files changed (1) hide show
  1. yt_scraper.py +199 -106
yt_scraper.py CHANGED
@@ -1,141 +1,234 @@
1
  """
2
- YouTube Shorts Scraper using requests HTML parsing (no yt-dlp dependency)
3
- Optimized for fast load with cache + instant fallback to prevent homepage blocking
4
  """
5
- import requests
6
  import json
7
  import time
8
  import threading
9
- import html as html_lib
 
10
 
11
  _cache = {}
12
  _lock = threading.Lock()
13
- CACHE_TTL = 1800 # 30 min cache - longer to reduce timeout issues
14
-
15
- HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi,en;q=0.8"}
16
-
17
- # Static fallback - always available instantly
18
- _SHORTS_FALLBACK = {
19
- 'baodantri7941': [
20
- {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội"},
21
- {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước"},
22
- {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt"},
23
- {"id":"b1Nxzv9ixlU","title":"Y án 3 năm tù với nữ tài xế uống 8 lon bia lái xe tông chủ tịch xã tử vong"},
24
- {"id":"Xp5eTwAZAis","title":"Người đánh hàng xóm tại chung cư ở Hà Nội bị tuyên hơn 4 tháng tù"},
25
- ],
26
- 'baosuckhoedoisongboyte': [
27
- {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong ở sông Lô"},
28
- {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam"},
29
- {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?"},
30
- {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý"},
31
- ]
32
- }
33
 
34
  def _cached(key):
35
- """Get cached data if still valid"""
36
  with _lock:
37
- if key in _cache:
38
- entry = _cache[key]
39
- if time.time() - entry['t'] < CACHE_TTL:
40
- return entry['d']
41
  return None
42
 
43
  def _set_cache(key, data):
44
- """Set cache with timestamp"""
45
  with _lock:
46
  _cache[key] = {'t': time.time(), 'd': data}
47
 
48
- def _get_channel_shorts_requests(channel, count=15):
49
- """Fast requests-based scraping with timeout protection"""
50
  try:
51
- url=f"https://www.youtube.com/@{channel}/shorts"
52
- r=requests.get(url, headers=HEADERS, timeout=6) # Fast 6s timeout
53
- if r.status_code!=200:
54
- return None
55
- html=r.text; ids=[]; items=[]
56
- import re
57
- for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
58
- vid=m.group(1)
59
- if vid in ids: continue
60
- ids.append(vid)
61
- snip=html[max(0,m.start()-900):m.start()+1600]
62
- title=""
63
- mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
64
- if not mt: mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
65
- if mt: title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
66
- if not title: title="YouTube Short"
67
- items.append({"id":vid,"title":title,"channel":channel})
68
- if len(items)>=count: break
69
  return items
70
- except Exception:
71
- return None
 
 
 
 
 
 
 
72
 
73
- def get_channel_shorts_fast(channel_username, max_count=25):
74
- """Get shorts fast - requests with fallback"""
75
- import re
76
-
77
- # Try fast requests scraping
78
- result = _get_channel_shorts_requests(channel_username, max_count)
79
 
80
- # If success, add image URL
81
- if result:
82
- for item in result:
83
- item['img'] = f"https://i.ytimg.com/vi/{item['id']}/hqdefault.jpg"
84
- return result
 
 
 
 
 
 
85
 
86
- # Return static fallback
87
- fb = _SHORTS_FALLBACK.get(channel_username, [])
88
- return [{"id":v["id"],"title":v["title"],"channel":channel_username,"img":f"https://i.ytimg.com/vi/{v['id']}/hqdefault.jpg"} for v in fb]
89
-
90
- def get_dantri_shorts(max_count=25):
91
- """Get Dantri shorts - separate cache, fast fallback"""
92
- cached = _cached('dantri_shorts_yt')
93
- if cached is not None:
94
- return cached
 
 
 
 
 
 
 
 
 
 
95
 
96
- shorts = get_channel_shorts_fast('baodantri7941', max_count)
97
- _set_cache('dantri_shorts_yt', shorts)
98
  return shorts
99
 
100
- def get_skds_shorts(max_count=25):
101
- """Get SKĐS shorts - separate cache, fast fallback"""
102
- cached = _cached('skds_shorts_yt')
103
- if cached is not None:
104
- return cached
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- shorts = get_channel_shorts_fast('baosuckhoedoisongboyte', max_count)
107
- _set_cache('skds_shorts_yt', shorts)
108
  return shorts
109
 
110
- def get_vtvnambo_shorts(max_count=50):
111
- """Get interleaved Dantri + SKĐS shorts - optimized with separate caching"""
112
- dantri = get_dantri_shorts(max_count // 2 + 10)
113
- skds = get_skds_shorts(max_count // 2 + 10)
114
-
115
- # Interleave them
116
- result = []
117
- seen = set()
118
- i, j = 0, 0
119
 
120
- while (i < len(dantri) or j < len(skds)) and len(result) < max_count:
121
- if i < len(dantri):
122
- item = dantri[i]
123
- if item.get('id') not in seen:
124
- seen.add(item.get('id'))
125
- result.append(item)
126
- i += 1
127
- if j < len(skds):
128
- item = skds[j]
129
- if item.get('id') not in seen:
130
- seen.add(item.get('id'))
131
- result.append(item)
132
- j += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  return result
135
 
136
  def get_wc_related_shorts(max_count=30):
137
- """Get World Cup / football related shorts - now uses same cache as main shorts"""
138
- return get_vtvnambo_shorts(max_count)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- # Alias for backward compatibility
141
  get_vtvnamo_shorts = get_vtvnambo_shorts
 
1
  """
2
+ YouTube Shorts Scraper using yt-dlp (already installed on Space)
3
+ Runs yt-dlp as subprocess to extract video info and direct URLs
4
  """
5
+ import subprocess
6
  import json
7
  import time
8
  import threading
9
+ import os
10
+ import re as re_mod
11
 
12
  _cache = {}
13
  _lock = threading.Lock()
14
+ CACHE_TTL = 600 # 10 min cache
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  def _cached(key):
 
17
  with _lock:
18
+ if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
19
+ return _cache[key]['d']
 
 
20
  return None
21
 
22
  def _set_cache(key, data):
 
23
  with _lock:
24
  _cache[key] = {'t': time.time(), 'd': data}
25
 
26
+ def run_yt_dlp(args, timeout=120):
27
+ """Run yt-dlp and return parsed JSON lines"""
28
  try:
29
+ result = subprocess.run(
30
+ ["yt-dlp"] + args,
31
+ capture_output=True, text=True, timeout=timeout
32
+ )
33
+ if result.returncode != 0 and not result.stdout.strip():
34
+ print(f"yt-dlp error: {result.stderr[:200]}")
35
+ return []
36
+ lines = result.stdout.strip().split('\n')
37
+ items = []
38
+ for line in lines:
39
+ line = line.strip()
40
+ if not line:
41
+ continue
42
+ try:
43
+ items.append(json.loads(line))
44
+ except json.JSONDecodeError:
45
+ continue
 
46
  return items
47
+ except subprocess.TimeoutExpired:
48
+ print("yt-dlp timeout")
49
+ return []
50
+ except FileNotFoundError:
51
+ print("yt-dlp not found!")
52
+ return []
53
+ except Exception as e:
54
+ print(f"yt-dlp exception: {e}")
55
+ return []
56
 
57
+ def get_channel_shorts_via_playlist(channel_username, max_count=100):
58
+ """Get shorts from channel's shorts page using yt-dlp"""
59
+ shorts = []
 
 
 
60
 
61
+ # Method 1: Fetch from /shorts page
62
+ url = f"https://www.youtube.com/@{channel_username}/shorts"
63
+ items = run_yt_dlp([
64
+ "--dump-json",
65
+ "--flat-playlist",
66
+ "--no-download",
67
+ "--playlist-end", str(max_count),
68
+ "--no-check-certificates",
69
+ "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
70
+ url
71
+ ], timeout=90)
72
 
73
+ seen_ids = set()
74
+ for item in items:
75
+ vid = item.get('id', '')
76
+ if not vid or vid in seen_ids:
77
+ continue
78
+ seen_ids.add(vid)
79
+
80
+ title = item.get('title', 'VTV Nam Bộ Short')
81
+ duration = item.get('duration', 0) or 0
82
+
83
+ # Only include actual shorts (<= 120s to be safe)
84
+ if duration <= 120 or '#shorts' in title.lower() or '#short' in title.lower():
85
+ shorts.append({
86
+ 'id': vid,
87
+ 'title': title,
88
+ 'duration': duration,
89
+ 'channel': channel_username,
90
+ 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
91
+ })
92
 
 
 
93
  return shorts
94
 
95
+ def get_channel_videos_filter_shorts(channel_username, max_count=200):
96
+ """Get all videos from /videos page and filter for shorts by duration"""
97
+ shorts = []
98
+
99
+ url = f"https://www.youtube.com/@{channel_username}/videos"
100
+ items = run_yt_dlp([
101
+ "--dump-json",
102
+ "--flat-playlist",
103
+ "--no-download",
104
+ "--playlist-end", str(max_count),
105
+ "--match-filter", "duration > 0 and duration <= 120",
106
+ "--no-check-certificates",
107
+ "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
108
+ url
109
+ ], timeout=120)
110
+
111
+ seen_ids = set()
112
+ for item in items:
113
+ vid = item.get('id', '')
114
+ if not vid or vid in seen_ids:
115
+ continue
116
+ seen_ids.add(vid)
117
+
118
+ title = item.get('title', 'VTV Nam Bộ Short')
119
+ duration = item.get('duration', 0) or 0
120
+
121
+ shorts.append({
122
+ 'id': vid,
123
+ 'title': title,
124
+ 'duration': duration,
125
+ 'channel': channel_username,
126
+ 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
127
+ })
128
 
 
 
129
  return shorts
130
 
131
+ def get_shorts_with_direct_url(video_ids):
132
+ """Get direct video download URLs for given video IDs"""
133
+ results = []
 
 
 
 
 
 
134
 
135
+ for vid in video_ids[:20]: # Limit to avoid timeout
136
+ try:
137
+ url = f"https://www.youtube.com/shorts/{vid}"
138
+ items = run_yt_dlp([
139
+ "--dump-json",
140
+ "-- "--no-check-certificates",
141
+ "--format", "best[filesize<10M]/best",
142
+ url
143
+ ], timeout=30)
144
+
145
+ if items:
146
+ info = items[0]
147
+ direct_url = info.get('url', '')
148
+ if not direct_url:
149
+ # Try to get from formats
150
+ formats = info.get('formats', [])
151
+ for f in formats:
152
+ if f.get('vcodec') != 'none' and f.get('acodec') != 'none':
153
+ direct_url = f.get('url', '')
154
+ break
155
+
156
+ if direct_url:
157
+ results.append({
158
+ 'id': vid,
159
+ 'title': info.get('title', ''),
160
+ 'direct_url': direct_url,
161
+ 'thumbnail': info.get('thumbnail', f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"),
162
+ 'duration': info.get('duration', 0),
163
+ })
164
+ except Exception as e:
165
+ print(f"Error getting URL for {vid}: {e}")
166
 
167
+ return results
168
+
169
+ def get_vtvnambo_shorts(max_count=50):
170
+ """Get all shorts from VTV Nam Bộ using yt-dlp"""
171
+ cached = _cached('vtvnambo_shorts_yt')
172
+ if cached is not None:
173
+ return cached
174
+
175
+ all_shorts = []
176
+ seen_ids = set()
177
+
178
+ # Method 1: /shorts page
179
+ print(f"[yt-dlp] Fetching /shorts page...")
180
+ shorts_page = get_channel_shorts_via_playlist('vtvnambo', max_count)
181
+ for s in shorts_page:
182
+ if s['id'] not in seen_ids:
183
+ seen_ids.add(s['id'])
184
+ all_shorts.append(s)
185
+ print(f"[yt-dlp] /shorts page: {len(shorts_page)} shorts")
186
+
187
+ # Method 2: /videos page with duration filter
188
+ if len(all_shorts) < 5:
189
+ print(f"[yt-dlp] Fetching /videos page with filter...")
190
+ videos_filtered = get_channel_videos_filter_shorts('vtvnambo', max_count * 2)
191
+ for s in videos_filtered:
192
+ if s['id'] not in seen_ids:
193
+ seen_ids.add(s['id'])
194
+ all_shorts.append(s)
195
+ print(f"[yt-dlp] /videos filter: {len(videos_filtered)} shorts")
196
+
197
+ result = all_shorts[:max_count]
198
+ _set_cache('vtvnambo_shorts_yt', result)
199
+ print(f"[yt-dlp] Total: {len(result)} shorts from VTV Nam Bộ")
200
  return result
201
 
202
  def get_wc_related_shorts(max_count=30):
203
+ """Get World Cup / football related shorts"""
204
+ all_shorts = get_vtvnambo_shorts(max_count * 3)
205
+
206
+ wc_kws = [
207
+ 'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
208
+ 'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
209
+ 'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
210
+ 'kết quả', 'tỉ số', 'việt nam', 'vn',
211
+ 'ngoại hạng', 'premier league', 'champions league',
212
+ 'laliga', 'serie a', 'bundesliga', 'ligue 1',
213
+ 'copa', 'europa', 'c1', 'c2',
214
+ 'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
215
+ 'v-league', 'vleague', 'bóng đá việt',
216
+ 'đội bóng', 'hlv', 'huấn luyện viên',
217
+ 'chuyển nhượng', 'transfer',
218
+ 'asian cup', 'aff cup', 'sea games',
219
+ 'olympic', 'u23', 'u20', 'u17',
220
+ ]
221
+
222
+ wc_shorts = []
223
+ for s in all_shorts:
224
+ tl = s.get('title', '').lower()
225
+ if any(k in tl for k in wc_kws):
226
+ wc_shorts.append(s)
227
+
228
+ if not wc_shorts:
229
+ wc_shorts = all_shorts
230
+
231
+ return wc_shorts[:max_count]
232
 
233
+ # Aliases
234
  get_vtvnamo_shorts = get_vtvnambo_shorts