Spaces:
Running
Running
Upload auto_scheduler.py
Browse files- auto_scheduler.py +101 -44
auto_scheduler.py
CHANGED
|
@@ -27,25 +27,20 @@ SCHEDULE_STATE_FILE = os.path.join(DATA_DIR, 'scheduler_state.json')
|
|
| 27 |
def _load_state():
|
| 28 |
try:
|
| 29 |
if os.path.exists(SCHEDULE_STATE_FILE):
|
| 30 |
-
with open(SCHEDULE_STATE_FILE, 'r') as f:
|
| 31 |
-
|
| 32 |
-
except Exception:
|
| 33 |
-
pass
|
| 34 |
return {}
|
| 35 |
|
| 36 |
def _save_state(state):
|
| 37 |
try:
|
| 38 |
tmp = SCHEDULE_STATE_FILE + '.tmp'
|
| 39 |
-
with open(tmp, 'w') as f:
|
| 40 |
-
json.dump(state, f, ensure_ascii=False)
|
| 41 |
os.replace(tmp, SCHEDULE_STATE_FILE)
|
| 42 |
-
except Exception as e:
|
| 43 |
-
LOG.warning(f"Cannot save scheduler state: {e}")
|
| 44 |
|
| 45 |
_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())
|
| 46 |
|
| 47 |
-
def _clean(s):
|
| 48 |
-
return re.sub(r"\s+", " ", str(s or "")).strip()
|
| 49 |
|
| 50 |
def _get_hot_topics():
|
| 51 |
freq = {}; display = {}
|
|
@@ -79,8 +74,7 @@ def _get_hot_topics():
|
|
| 79 |
key = phrase.lower()
|
| 80 |
freq[key] = freq.get(key, 0) + 1
|
| 81 |
display[key] = phrase
|
| 82 |
-
except
|
| 83 |
-
continue
|
| 84 |
ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
|
| 85 |
topics = []; seen = set()
|
| 86 |
for key, count in ranked:
|
|
@@ -96,21 +90,14 @@ def _get_hot_topics():
|
|
| 96 |
topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0})
|
| 97 |
return topics[:24]
|
| 98 |
|
| 99 |
-
_ai_ext = None
|
| 100 |
-
_ai_patch = None
|
| 101 |
-
|
| 102 |
def _get_ai_ext():
|
| 103 |
global _ai_ext
|
| 104 |
-
if _ai_ext is None:
|
| 105 |
-
import ai_ext as m
|
| 106 |
-
_ai_ext = m
|
| 107 |
return _ai_ext
|
| 108 |
-
|
| 109 |
def _get_ai_patch():
|
| 110 |
global _ai_patch
|
| 111 |
-
if _ai_patch is None:
|
| 112 |
-
import ai_patch as m
|
| 113 |
-
_ai_patch = m
|
| 114 |
return _ai_patch
|
| 115 |
|
| 116 |
_RSS_FEEDS = [
|
|
@@ -127,8 +114,7 @@ _RSS_FEEDS = [
|
|
| 127 |
]
|
| 128 |
|
| 129 |
def _search_articles_by_topic(topic, limit=4):
|
| 130 |
-
all_articles = []
|
| 131 |
-
seen_urls = set()
|
| 132 |
topic_lower = topic.lower()
|
| 133 |
topic_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower))
|
| 134 |
for feed_url, source in _RSS_FEEDS:
|
|
@@ -157,18 +143,13 @@ def _search_articles_by_topic(topic, limit=4):
|
|
| 157 |
ogi = art_soup.find('meta', property='og:image')
|
| 158 |
if ogi: img = ogi.get('content', '')
|
| 159 |
except: pass
|
| 160 |
-
all_articles.append({
|
| 161 |
-
'title': title, 'url': link, 'raw': desc or title, 'image': img,
|
| 162 |
-
'via': source,
|
| 163 |
-
'source': {'title': title, 'url': link, 'excerpt': (desc or title)[:700], 'via': source}
|
| 164 |
-
})
|
| 165 |
if len(all_articles) >= limit: break
|
| 166 |
except: continue
|
| 167 |
return all_articles[:limit]
|
| 168 |
|
| 169 |
async def _create_ai_post(topic):
|
| 170 |
-
ai_ext = _get_ai_ext()
|
| 171 |
-
ai_patch = _get_ai_patch()
|
| 172 |
articles = _search_articles_by_topic(topic, limit=4)
|
| 173 |
if not articles:
|
| 174 |
LOG.warning(f"No articles for topic: {topic}. Fallback.")
|
|
@@ -200,9 +181,8 @@ async def _create_ai_post(topic):
|
|
| 200 |
except: pass
|
| 201 |
posts.append(post)
|
| 202 |
except Exception as e:
|
| 203 |
-
LOG.error(f"Error
|
| 204 |
-
if not posts:
|
| 205 |
-
return await _create_fallback_post(topic, ai_ext, ai_patch)
|
| 206 |
wall = posts + wall
|
| 207 |
ai_ext._save_ai_wall(wall)
|
| 208 |
for post in posts:
|
|
@@ -211,7 +191,7 @@ async def _create_ai_post(topic):
|
|
| 211 |
return posts
|
| 212 |
|
| 213 |
async def _create_fallback_post(topic, ai_ext, ai_patch):
|
| 214 |
-
LOG.info(f"Fallback
|
| 215 |
try:
|
| 216 |
text = f"• {topic} đang là chủ đề nóng hôm nay.\n• Theo dõi VNEWS để cập nhật tin tức mới nhất."
|
| 217 |
img = ai_ext.pollination_image_url(topic)
|
|
@@ -244,8 +224,7 @@ def _try_generate_short(post):
|
|
| 244 |
if p.get('id') == post_id: wall[i] = post; break
|
| 245 |
ai_ext._save_ai_wall(wall); return
|
| 246 |
threading.Thread(target=lambda: _generate_short_worker(post, segments, post_id, suffix, out_mp4), daemon=True).start()
|
| 247 |
-
except Exception as e:
|
| 248 |
-
LOG.warning(f"Short init: {e}")
|
| 249 |
|
| 250 |
def _generate_short_worker(post, segments, post_id, suffix, out_mp4):
|
| 251 |
import subprocess
|
|
@@ -292,10 +271,8 @@ def _generate_short_worker(post, segments, post_id, suffix, out_mp4):
|
|
| 292 |
if p.get('id') == post_id: wall[i] = post; break
|
| 293 |
ai_ext._save_ai_wall(wall)
|
| 294 |
LOG.info(f"Short: {post_id}")
|
| 295 |
-
except Exception as e:
|
| 296 |
-
LOG.warning(f"Short fail: {e}")
|
| 297 |
|
| 298 |
-
# ===== FIX v4: _run_async helper - works from both sync and async contexts =====
|
| 299 |
def _run_async(coro):
|
| 300 |
"""Run async coroutine safely regardless of current event loop state."""
|
| 301 |
try:
|
|
@@ -308,10 +285,90 @@ def _run_async(coro):
|
|
| 308 |
|
| 309 |
def _run_scheduled_posting():
|
| 310 |
LOG.info("=" * 50)
|
| 311 |
-
LOG.info("
|
| 312 |
LOG.info("=" * 50)
|
| 313 |
try:
|
| 314 |
hot_topics = _get_hot_topics()
|
| 315 |
-
if not hot_topics:
|
| 316 |
-
|
| 317 |
-
seen_labels = set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def _load_state():
|
| 28 |
try:
|
| 29 |
if os.path.exists(SCHEDULE_STATE_FILE):
|
| 30 |
+
with open(SCHEDULE_STATE_FILE, 'r') as f: return json.load(f)
|
| 31 |
+
except: pass
|
|
|
|
|
|
|
| 32 |
return {}
|
| 33 |
|
| 34 |
def _save_state(state):
|
| 35 |
try:
|
| 36 |
tmp = SCHEDULE_STATE_FILE + '.tmp'
|
| 37 |
+
with open(tmp, 'w') as f: json.dump(state, f, ensure_ascii=False)
|
|
|
|
| 38 |
os.replace(tmp, SCHEDULE_STATE_FILE)
|
| 39 |
+
except Exception as e: LOG.warning(f"Cannot save state: {e}")
|
|
|
|
| 40 |
|
| 41 |
_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())
|
| 42 |
|
| 43 |
+
def _clean(s): return re.sub(r"\s+", " ", str(s or "")).strip()
|
|
|
|
| 44 |
|
| 45 |
def _get_hot_topics():
|
| 46 |
freq = {}; display = {}
|
|
|
|
| 74 |
key = phrase.lower()
|
| 75 |
freq[key] = freq.get(key, 0) + 1
|
| 76 |
display[key] = phrase
|
| 77 |
+
except: continue
|
|
|
|
| 78 |
ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
|
| 79 |
topics = []; seen = set()
|
| 80 |
for key, count in ranked:
|
|
|
|
| 90 |
topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0})
|
| 91 |
return topics[:24]
|
| 92 |
|
| 93 |
+
_ai_ext = None; _ai_patch = None
|
|
|
|
|
|
|
| 94 |
def _get_ai_ext():
|
| 95 |
global _ai_ext
|
| 96 |
+
if _ai_ext is None: import ai_ext as m; _ai_ext = m
|
|
|
|
|
|
|
| 97 |
return _ai_ext
|
|
|
|
| 98 |
def _get_ai_patch():
|
| 99 |
global _ai_patch
|
| 100 |
+
if _ai_patch is None: import ai_patch as m; _ai_patch = m
|
|
|
|
|
|
|
| 101 |
return _ai_patch
|
| 102 |
|
| 103 |
_RSS_FEEDS = [
|
|
|
|
| 114 |
]
|
| 115 |
|
| 116 |
def _search_articles_by_topic(topic, limit=4):
|
| 117 |
+
all_articles = []; seen_urls = set()
|
|
|
|
| 118 |
topic_lower = topic.lower()
|
| 119 |
topic_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower))
|
| 120 |
for feed_url, source in _RSS_FEEDS:
|
|
|
|
| 143 |
ogi = art_soup.find('meta', property='og:image')
|
| 144 |
if ogi: img = ogi.get('content', '')
|
| 145 |
except: pass
|
| 146 |
+
all_articles.append({'title': title, 'url': link, 'raw': desc or title, 'image': img, 'via': source, 'source': {'title': title, 'url': link, 'excerpt': (desc or title)[:700], 'via': source}})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if len(all_articles) >= limit: break
|
| 148 |
except: continue
|
| 149 |
return all_articles[:limit]
|
| 150 |
|
| 151 |
async def _create_ai_post(topic):
|
| 152 |
+
ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
|
|
|
|
| 153 |
articles = _search_articles_by_topic(topic, limit=4)
|
| 154 |
if not articles:
|
| 155 |
LOG.warning(f"No articles for topic: {topic}. Fallback.")
|
|
|
|
| 181 |
except: pass
|
| 182 |
posts.append(post)
|
| 183 |
except Exception as e:
|
| 184 |
+
LOG.error(f"Error post: {e}")
|
| 185 |
+
if not posts: return await _create_fallback_post(topic, ai_ext, ai_patch)
|
|
|
|
| 186 |
wall = posts + wall
|
| 187 |
ai_ext._save_ai_wall(wall)
|
| 188 |
for post in posts:
|
|
|
|
| 191 |
return posts
|
| 192 |
|
| 193 |
async def _create_fallback_post(topic, ai_ext, ai_patch):
|
| 194 |
+
LOG.info(f"Fallback: {topic}")
|
| 195 |
try:
|
| 196 |
text = f"• {topic} đang là chủ đề nóng hôm nay.\n• Theo dõi VNEWS để cập nhật tin tức mới nhất."
|
| 197 |
img = ai_ext.pollination_image_url(topic)
|
|
|
|
| 224 |
if p.get('id') == post_id: wall[i] = post; break
|
| 225 |
ai_ext._save_ai_wall(wall); return
|
| 226 |
threading.Thread(target=lambda: _generate_short_worker(post, segments, post_id, suffix, out_mp4), daemon=True).start()
|
| 227 |
+
except Exception as e: LOG.warning(f"Short init: {e}")
|
|
|
|
| 228 |
|
| 229 |
def _generate_short_worker(post, segments, post_id, suffix, out_mp4):
|
| 230 |
import subprocess
|
|
|
|
| 271 |
if p.get('id') == post_id: wall[i] = post; break
|
| 272 |
ai_ext._save_ai_wall(wall)
|
| 273 |
LOG.info(f"Short: {post_id}")
|
| 274 |
+
except Exception as e: LOG.warning(f"Short fail: {e}")
|
|
|
|
| 275 |
|
|
|
|
| 276 |
def _run_async(coro):
|
| 277 |
"""Run async coroutine safely regardless of current event loop state."""
|
| 278 |
try:
|
|
|
|
| 285 |
|
| 286 |
def _run_scheduled_posting():
|
| 287 |
LOG.info("=" * 50)
|
| 288 |
+
LOG.info("Scheduler triggered at %s", datetime.now(VN_TZ).strftime('%H:%M %d/%m/%Y'))
|
| 289 |
LOG.info("=" * 50)
|
| 290 |
try:
|
| 291 |
hot_topics = _get_hot_topics()
|
| 292 |
+
if not hot_topics:
|
| 293 |
+
LOG.warning("No hot topics"); return
|
| 294 |
+
selected = []; seen_labels = set()
|
| 295 |
+
for t in hot_topics:
|
| 296 |
+
label = t.get('label', '')
|
| 297 |
+
if label and label not in seen_labels:
|
| 298 |
+
seen_labels.add(label); selected.append(t['topic'])
|
| 299 |
+
if len(selected) >= 3: break
|
| 300 |
+
if len(selected) < 3:
|
| 301 |
+
selected = ['Thời sự Việt Nam', 'Kinh tế Việt Nam', 'Thể thao']
|
| 302 |
+
LOG.info(f"Topics: {selected}")
|
| 303 |
+
async def _do_all():
|
| 304 |
+
results = []
|
| 305 |
+
for topic in selected:
|
| 306 |
+
try:
|
| 307 |
+
posts = await _create_ai_post(topic)
|
| 308 |
+
results.append({'topic': topic, 'posts': len(posts) if posts else 0})
|
| 309 |
+
LOG.info(f"{'✓' if posts else '✗'} {topic}: {len(posts) if posts else 0} posts")
|
| 310 |
+
except Exception as e:
|
| 311 |
+
LOG.error(f"Error {topic}: {e}")
|
| 312 |
+
results.append({'topic': topic, 'posts': 0})
|
| 313 |
+
return results
|
| 314 |
+
results = _run_async(_do_all())
|
| 315 |
+
LOG.info(f"Done: {len(results)} topics")
|
| 316 |
+
for r in results: LOG.info(f" • {r['topic']}: {r['posts']} bài")
|
| 317 |
+
except Exception as e:
|
| 318 |
+
LOG.error(f"Scheduler error: {e}", exc_info=True)
|
| 319 |
+
|
| 320 |
+
def _check_missed_slots():
|
| 321 |
+
try:
|
| 322 |
+
state = _load_state()
|
| 323 |
+
today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 324 |
+
now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
|
| 325 |
+
ran = state.get(today_str, {})
|
| 326 |
+
for s in SCHEDULE_TIMES:
|
| 327 |
+
lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
|
| 328 |
+
if ran.get(lbl): continue
|
| 329 |
+
if cur_mins >= sm:
|
| 330 |
+
LOG.info(f"Catch-up: {lbl}")
|
| 331 |
+
_run_scheduled_posting()
|
| 332 |
+
if today_str not in state: state[today_str] = {}
|
| 333 |
+
state[today_str][lbl] = True; _save_state(state)
|
| 334 |
+
except Exception as e: LOG.error(f"Catch-up: {e}")
|
| 335 |
+
|
| 336 |
+
def _scheduler_loop():
|
| 337 |
+
LOG.info("Scheduler started")
|
| 338 |
+
LOG.info(f"Schedule: {', '.join(f'{h:02d}:{m:02d}' for h,m in SCHEDULE_TIMES)} VN")
|
| 339 |
+
state = _load_state(); today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 340 |
+
ran = state.get(today_str, {})
|
| 341 |
+
now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
|
| 342 |
+
for s in SCHEDULE_TIMES:
|
| 343 |
+
lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
|
| 344 |
+
if ran.get(lbl): LOG.info(f" ✓ {lbl} done"); continue
|
| 345 |
+
if cur_mins >= sm:
|
| 346 |
+
LOG.info(f" → {lbl} missed! Catch-up")
|
| 347 |
+
_run_scheduled_posting()
|
| 348 |
+
if today_str not in state: state[today_str] = {}
|
| 349 |
+
state[today_str][lbl] = True; _save_state(state)
|
| 350 |
+
else: LOG.info(f" ⏩ {lbl} upcoming")
|
| 351 |
+
while True:
|
| 352 |
+
try:
|
| 353 |
+
now = datetime.now(VN_TZ)
|
| 354 |
+
ck = (now.hour, now.minute)
|
| 355 |
+
state = _load_state(); today_str = now.strftime('%Y-%m-%d')
|
| 356 |
+
ran = state.get(today_str, {})
|
| 357 |
+
for s in SCHEDULE_TIMES:
|
| 358 |
+
lbl = SCHEDULE_LABELS[s]
|
| 359 |
+
if ck == s and not ran.get(lbl):
|
| 360 |
+
LOG.info(f"On-time: {lbl}")
|
| 361 |
+
_run_scheduled_posting()
|
| 362 |
+
if today_str not in state: state[today_str] = {}
|
| 363 |
+
state[today_str][lbl] = True; _save_state(state)
|
| 364 |
+
break
|
| 365 |
+
time.sleep(60)
|
| 366 |
+
except Exception as e:
|
| 367 |
+
LOG.error(f"Loop: {e}")
|
| 368 |
+
time.sleep(60)
|
| 369 |
+
|
| 370 |
+
def start_auto_scheduler():
|
| 371 |
+
t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler")
|
| 372 |
+
t.start()
|
| 373 |
+
LOG.info("Auto scheduler started")
|
| 374 |
+
return t
|