Spaces:
Running
Running
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- 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
|