bep40 commited on
Commit
a9de00b
·
verified ·
1 Parent(s): a894cc7

Add auto_scheduler.py - tự động đăng 3 bài rewrite AI + short từ 3 chủ đề HOT lúc 7:00, 13:00, 19:00

Browse files
Files changed (1) hide show
  1. auto_scheduler.py +361 -0
auto_scheduler.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS Auto Scheduler - tự động đăng 3 bài rewrite AI + shorts từ 3 chủ đề HOT
2
+ Vào các khung giờ: 7:00, 13:00, 19:00 (giờ Việt Nam)
3
+ Mỗi bài: Rewrite AI từ nguồn báo + short video tự động
4
+ """
5
+ import os, re, json, time, threading, asyncio, logging, random, hashlib
6
+ from datetime import datetime, timezone, timedelta
7
+ from urllib.parse import quote
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+
11
+ VN_TZ = timezone(timedelta(hours=7))
12
+ LOG = logging.getLogger("auto_scheduler")
13
+ LOG.setLevel(logging.INFO)
14
+ if not LOG.handlers:
15
+ ch = logging.StreamHandler()
16
+ ch.setFormatter(logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s'))
17
+ LOG.addHandler(ch)
18
+
19
+ SCHEDULE_TIMES = [(7, 0), (13, 0), (19, 0)]
20
+
21
+ # ===== Hot topics from VN RSS (same logic as app_v2_entry._get_hot_topics) =====
22
+ _STOP = set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
23
+
24
+ def _clean(s):
25
+ return re.sub(r"\s+", " ", str(s or "")).strip()
26
+
27
+ def _get_hot_topics():
28
+ freq = {}; display = {}
29
+ feeds = [
30
+ 'https://vnexpress.net/rss/tin-moi-nhat.rss',
31
+ 'https://dantri.com.vn/rss/home.rss',
32
+ 'https://vietnamnet.vn/rss/tin-moi-nhat.rss',
33
+ 'https://thanhnien.vn/rss/home.rss',
34
+ 'https://tuoitre.vn/rss/tin-moi-nhat.rss',
35
+ 'https://genk.vn/rss',
36
+ 'https://vnexpress.net/rss/the-thao.rss',
37
+ 'https://thethaovanhoa.vn/rss/tin-nong.rss',
38
+ 'https://vnexpress.net/rss/kinh-doanh.rss',
39
+ 'https://dantri.com.vn/rss/the-gioi.rss',
40
+ ]
41
+ for feed_url in feeds:
42
+ try:
43
+ r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6)
44
+ r.encoding = 'utf-8'
45
+ soup = BeautifulSoup(r.text, 'xml')
46
+ for item in soup.find_all('item')[:12]:
47
+ title = _clean(item.find('title').get_text() if item.find('title') else '')
48
+ if not title: continue
49
+ title = re.sub(r'\s*[-|].*$', '', title)
50
+ words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', title) if len(w) > 2 and w.lower() not in _STOP]
51
+ if len(words) < 2: continue
52
+ for n in (3, 4, 2):
53
+ for i in range(max(0, len(words) - n + 1)):
54
+ phrase = ' '.join(words[i:i + n])
55
+ if 8 <= len(phrase) <= 45:
56
+ key = phrase.lower()
57
+ freq[key] = freq.get(key, 0) + 1
58
+ display[key] = phrase
59
+ except Exception:
60
+ continue
61
+ ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
62
+ topics = []; seen = set()
63
+ for key, count in ranked:
64
+ kw = display[key]
65
+ is_dup = any(len(set(e.split()) & set(key.split())) / max(len(set(e.split())), len(set(key.split())), 1) > 0.6 for e in seen)
66
+ if is_dup: continue
67
+ seen.add(key)
68
+ topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': count})
69
+ if len(topics) >= 20: break
70
+ for kw in ['World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu', 'Công nghệ AI', 'Giá vàng', 'Thời tiết']:
71
+ if len(topics) >= 24: break
72
+ if not any(kw.lower() in s for s in seen):
73
+ topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0})
74
+ return topics[:24]
75
+
76
+
77
+ # ===== Import AI functions lazily to avoid circular imports =====
78
+ _ai_ext = None
79
+ _ai_patch = None
80
+
81
+ def _get_ai_ext():
82
+ global _ai_ext
83
+ if _ai_ext is None:
84
+ import ai_ext as m
85
+ _ai_ext = m
86
+ return _ai_ext
87
+
88
+ def _get_ai_patch():
89
+ global _ai_patch
90
+ if _ai_patch is None:
91
+ import ai_patch as m
92
+ _ai_patch = m
93
+ return _ai_patch
94
+
95
+
96
+ async def _create_ai_post(topic):
97
+ """Create one AI rewrite post for a hot topic. Returns list of posts."""
98
+ ai_ext = _get_ai_ext()
99
+ ai_patch = _get_ai_patch()
100
+
101
+ articles = ai_patch._topic_source_articles(topic, limit=4)
102
+ if not articles:
103
+ LOG.warning(f"No articles found for topic: {topic}")
104
+ return []
105
+
106
+ posts = []
107
+ wall = ai_ext._load_ai_wall()
108
+ if not isinstance(wall, list):
109
+ wall = []
110
+
111
+ for art in articles:
112
+ try:
113
+ prompt = ai_patch._make_summary_prompt(
114
+ art.get('title', topic),
115
+ art.get('raw', ''),
116
+ art.get('via', '')
117
+ )
118
+ text = await ai_ext.qwen_generate(prompt, image_url=art.get('image'), max_tokens=1500)
119
+ text = ai_patch._postprocess_ai_text(text, max_units=20)
120
+ src = [art.get('source', {'title': art.get('title', topic), 'url': art.get('url', ''), 'via': art.get('via', '')})]
121
+ if 'Nguồn tham khảo:' not in (text or ''):
122
+ text = (text or '') + "\n\n" + ai_patch._source_line(src)
123
+ img = art.get('image') or ai_ext.pollination_image_url(art['title'])
124
+ post = ai_ext.make_post(art['title'], text, img, art.get('url', ''), 'auto_scheduled', sources=src)
125
+
126
+ # Try to generate slides from article content
127
+ try:
128
+ page_data = ai_patch._scrape_article_images(art.get('url', ''))
129
+ if page_data and page_data.get('paragraphs'):
130
+ key_points = ai_patch._extract_key_points_for_slides(page_data['paragraphs'], max_points=8)
131
+ if key_points:
132
+ relevant_imgs = page_data.get('images', [])
133
+ if not relevant_imgs and page_data.get('og_img'):
134
+ relevant_imgs = [page_data['og_img']]
135
+ slides = []
136
+ for i, point in enumerate(key_points):
137
+ img_s = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
138
+ slides.append({'text': point, 'image': img_s, 'index': i + 1})
139
+ post['slides'] = slides
140
+ except Exception:
141
+ pass
142
+
143
+ posts.append(post)
144
+ except Exception as e:
145
+ LOG.error(f"Error creating post for article '{art.get('title', '')}': {e}")
146
+
147
+ # Save posts to wall
148
+ wall = posts + wall
149
+ ai_ext._save_ai_wall(wall)
150
+
151
+ # Try to generate short video for each post
152
+ for post in posts:
153
+ try:
154
+ _try_generate_short(post)
155
+ except Exception as e:
156
+ LOG.warning(f"Short generation skipped for '{post.get('title', '')[:40]}': {e}")
157
+
158
+ return posts
159
+
160
+
161
+ def _try_generate_short(post):
162
+ """Try to generate a short video for a post. Uses gTTS + ffmpeg."""
163
+ post_id = post.get('id', '')
164
+ if not post_id:
165
+ return
166
+
167
+ try:
168
+ ai_ext = _get_ai_ext()
169
+ ai_patch = _get_ai_patch()
170
+ if ai_ext.gTTS is None:
171
+ LOG.warning("gTTS not available, skipping short generation")
172
+ return
173
+
174
+ segments = ai_patch._summary_segments_from_post(post, max_segments=15)
175
+ if not segments:
176
+ return
177
+
178
+ seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
179
+ suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
180
+ out_mp4 = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix) + '.mp4')
181
+
182
+ if os.path.exists(out_mp4):
183
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
184
+ wall = ai_ext._load_ai_wall()
185
+ for i, p in enumerate(wall):
186
+ if p.get('id') == post_id:
187
+ wall[i] = post
188
+ break
189
+ ai_ext._save_ai_wall(wall)
190
+ return
191
+
192
+ # Generate short in background using threading
193
+ threading.Thread(
194
+ target=_generate_short_worker,
195
+ args=(post, segments, post_id, suffix, out_mp4),
196
+ daemon=True
197
+ ).start()
198
+
199
+ except Exception as e:
200
+ LOG.warning(f"Short generation init failed: {e}")
201
+
202
+
203
+ def _generate_short_worker(post, segments, post_id, suffix, out_mp4):
204
+ """Worker thread to generate short video."""
205
+ import subprocess
206
+ try:
207
+ ai_ext = _get_ai_ext()
208
+ ai_patch = _get_ai_patch()
209
+ work = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix))
210
+ os.makedirs(work, exist_ok=True)
211
+
212
+ img = os.path.join(work, 'image.jpg')
213
+ ai_ext._download_image(post.get('img'), post.get('title', 'AI news'), img)
214
+
215
+ part_files = []
216
+ for idx, seg in enumerate(segments[:10]):
217
+ frame = os.path.join(work, f'frame_{idx:02d}.jpg')
218
+ aud = os.path.join(work, f'voice_{idx:02d}.mp3')
219
+ aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
220
+ part = os.path.join(work, f'part_{idx:02d}.mp4')
221
+ try:
222
+ ai_patch._make_scene_frame(post, seg, idx, min(len(segments), 10), img, frame, emotion='neutral')
223
+ except Exception:
224
+ if not os.path.exists(img):
225
+ continue
226
+ from PIL import Image
227
+ bg = Image.new('RGB', (1080, 1920), (14, 14, 14))
228
+ bg.save(frame, quality=85)
229
+ tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()
230
+ try:
231
+ ai_ext.gTTS(tts_text, lang='vi', slow=False).save(aud)
232
+ except Exception:
233
+ try:
234
+ ai_ext.gTTS(tts_text, lang='vi', tld='com.vn', slow=False).save(aud)
235
+ except Exception:
236
+ continue
237
+ subprocess.run(['ffmpeg', '-y', '-i', aud, '-filter:a', 'atempo=1.0', '-vn', aud_fast],
238
+ check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
239
+ dur = 12.0
240
+ try:
241
+ pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
242
+ '-of', 'default=noprint_wrappers=1:no_key=1', aud_fast],
243
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
244
+ dur = max(8.0, float((pr.stdout or b'').decode().strip() or 12.0)) + 0.5
245
+ except Exception:
246
+ pass
247
+ subprocess.run(['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame,
248
+ '-i', aud_fast, '-shortest', '-c:v', 'libx264', '-tune', 'stillimage',
249
+ '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', part],
250
+ check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
251
+ part_files.append(part)
252
+
253
+ if part_files:
254
+ concat = os.path.join(work, 'concat.txt')
255
+ with open(concat, 'w', encoding='utf-8') as f:
256
+ for p in part_files:
257
+ f.write("file '" + p.replace("'", "'\\''") + "'\n")
258
+ subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4],
259
+ check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
260
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
261
+ post['short_voice'] = 'nu'
262
+ post['short_emotion'] = 'neutral'
263
+ post['short_speed'] = 1.0
264
+ post['short_segments'] = segments
265
+ post['short_subtitles'] = False
266
+ wall = ai_ext._load_ai_wall()
267
+ for i, p in enumerate(wall):
268
+ if p.get('id') == post_id:
269
+ wall[i] = post
270
+ break
271
+ ai_ext._save_ai_wall(wall)
272
+ LOG.info(f"Short video generated for post: {post_id}")
273
+ except Exception as e:
274
+ LOG.warning(f"Short video generation failed for {post_id}: {e}")
275
+
276
+
277
+ def _run_scheduled_posting():
278
+ """Run the scheduled posting task - creates 3 AI rewrite posts from 3 hot topics."""
279
+ LOG.info("=" * 50)
280
+ LOG.info("Auto scheduler triggered at %s", datetime.now(VN_TZ).strftime('%H:%M %d/%m/%Y'))
281
+ LOG.info("=" * 50)
282
+
283
+ try:
284
+ hot_topics = _get_hot_topics()
285
+ if not hot_topics:
286
+ LOG.warning("No hot topics found")
287
+ return
288
+
289
+ selected = []
290
+ seen_labels = set()
291
+ for t in hot_topics:
292
+ label = t.get('label', '')
293
+ if label and label not in seen_labels:
294
+ seen_labels.add(label)
295
+ selected.append(t['topic'])
296
+ if len(selected) >= 3:
297
+ break
298
+
299
+ if len(selected) < 3:
300
+ selected = ['Thời sự Việt Nam', 'Kinh tế Việt Nam', 'Thể thao']
301
+
302
+ LOG.info(f"Selected {len(selected)} hot topics: {selected}")
303
+
304
+ async def _do_all():
305
+ all_results = []
306
+ for topic in selected:
307
+ try:
308
+ posts = await _create_ai_post(topic)
309
+ if posts:
310
+ all_results.append({'topic': topic, 'posts': len(posts)})
311
+ LOG.info(f"✓ Created {len(posts)} post(s) for topic: '{topic}'")
312
+ else:
313
+ LOG.warning(f"✗ No posts created for topic: '{topic}'")
314
+ except Exception as e:
315
+ LOG.error(f"✗ Error creating post for topic '{topic}': {e}")
316
+ return all_results
317
+
318
+ results = asyncio.run(_do_all())
319
+
320
+ LOG.info("-" * 40)
321
+ LOG.info(f"Auto scheduler completed: {len(results)} topic(s) processed")
322
+ for r in results:
323
+ LOG.info(f" • {r['topic']}: {r['posts']} bài đã đăng")
324
+ LOG.info("=" * 50)
325
+
326
+ except Exception as e:
327
+ LOG.error(f"Auto scheduler error: {e}")
328
+
329
+
330
+ def _scheduler_loop():
331
+ """Background thread that checks every minute and runs at scheduled times."""
332
+ LOG.info("🕐 Auto scheduler background thread started")
333
+ LOG.info(f"⏰ Schedule: {', '.join(f'{h:02d}:{m:02d}' for h, m in SCHEDULE_TIMES)} (VN time)")
334
+ last_run_dates = {t: None for t in SCHEDULE_TIMES}
335
+
336
+ while True:
337
+ try:
338
+ now = datetime.now(VN_TZ)
339
+ current_key = (now.hour, now.minute)
340
+
341
+ for sched_time in SCHEDULE_TIMES:
342
+ if current_key == sched_time:
343
+ last_date = last_run_dates.get(sched_time)
344
+ if last_date != now.date():
345
+ last_run_dates[sched_time] = now.date()
346
+ LOG.info(f"⏰ Scheduled run at {now.hour:02d}:{now.minute:02d}")
347
+ _run_scheduled_posting()
348
+ break
349
+
350
+ time.sleep(60)
351
+ except Exception as e:
352
+ LOG.error(f"Scheduler loop error: {e}")
353
+ time.sleep(60)
354
+
355
+
356
+ def start_auto_scheduler():
357
+ """Start the auto scheduler in a background daemon thread."""
358
+ thread = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler")
359
+ thread.start()
360
+ LOG.info("🚀 Auto scheduler started successfully")
361
+ return thread