Spaces:
Running
Running
Upload auto_poster.py
Browse files- auto_poster.py +311 -0
auto_poster.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VNEWS Auto Poster — Tự động đăng bài rewrite và short lên Tường AI.
|
| 3 |
+
|
| 4 |
+
Chạy nền trong runtime ai_patch:app. Mỗi ngày vào các khung giờ 7h, 13h, 19h:
|
| 5 |
+
1. Chọn một chủ đề nóng từ RSS báo Việt Nam.
|
| 6 |
+
2. Lấy bài nguồn, tóm tắt bằng AI (bài rewrite) và lưu lên Tường AI.
|
| 7 |
+
3. Tự động sinh video short (ffmpeg + edge-tts) cho bài vừa tạo.
|
| 8 |
+
|
| 9 |
+
Tận dụng lại toàn bộ logic có sẵn trong ai_patch.py / ai_ext.py để đảm bảo
|
| 10 |
+
định dạng bài và short giống hệt khi người dùng bấm thủ công.
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
import time
|
| 15 |
+
import json
|
| 16 |
+
import html as html_lib
|
| 17 |
+
import threading
|
| 18 |
+
import asyncio
|
| 19 |
+
import traceback
|
| 20 |
+
|
| 21 |
+
import ai_ext as base
|
| 22 |
+
import ai_patch
|
| 23 |
+
|
| 24 |
+
from urllib.parse import quote_plus
|
| 25 |
+
from bs4 import BeautifulSoup
|
| 26 |
+
|
| 27 |
+
UA = {
|
| 28 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 29 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# Khung giờ đăng tự động (giờ địa phương của Space, múi UTC+0 trên HF Spaces).
|
| 33 |
+
SCHEDULE_HOURS = [7, 13, 19]
|
| 34 |
+
SCHEDULE_MINUTE = 5 # đăng lệch 5 phút để tránh chạm giờ làm tròn
|
| 35 |
+
|
| 36 |
+
# RSS nóng từ các báo VN (dùng để chọn chủ đề).
|
| 37 |
+
VN_HOT_RSS = [
|
| 38 |
+
("VnExpress", "https://vnexpress.net/rss/tin-moi-nhat.rss"),
|
| 39 |
+
("Dân trí", "https://dantri.com.vn/rss/home.rss"),
|
| 40 |
+
("Vietnamnet", "https://vietnamnet.vn/rss/tin-moi-nhat.rss"),
|
| 41 |
+
("Thanh Niên", "https://thanhnien.vn/rss/home.rss"),
|
| 42 |
+
("Tuổi Trẻ", "https://tuoitre.vn/rss/tin-moi-nhat.rss"),
|
| 43 |
+
("VnExpress Thời sự", "https://vnexpress.net/rss/thoi-su.rss"),
|
| 44 |
+
("VnExpress Thế giới", "https://vnexpress.net/rss/the-gioi.rss"),
|
| 45 |
+
("VnExpress Kinh doanh", "https://vnexpress.net/rss/kinh-doanh.rss"),
|
| 46 |
+
("VnExpress Công nghệ", "https://vnexpress.net/rss/so-hoa.rss"),
|
| 47 |
+
("VnExpress Thể thao", "https://vnexpress.net/rss/the-thao.rss"),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
_STOP = set(
|
| 51 |
+
"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()
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
_lock = threading.Lock()
|
| 55 |
+
_state = {
|
| 56 |
+
"running": False,
|
| 57 |
+
"thread": None,
|
| 58 |
+
"last_run": 0, # epoch của lần chạy gần nhất
|
| 59 |
+
"last_slot": "", # slot "YYYY-MM-DD-HH" đã chạy gần nhất
|
| 60 |
+
"runs": [], # lịch sử 20 lần chạy gần nhất
|
| 61 |
+
"errors": [], # lịch sử lỗi gần nhất
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _log(kind, msg, extra=None):
|
| 66 |
+
entry = {"ts": int(time.time()), "kind": kind, "msg": msg, "extra": extra}
|
| 67 |
+
with _lock:
|
| 68 |
+
if kind == "error":
|
| 69 |
+
_state["errors"].insert(0, entry)
|
| 70 |
+
_state["errors"] = _state["errors"][:20]
|
| 71 |
+
else:
|
| 72 |
+
_state["runs"].insert(0, entry)
|
| 73 |
+
_state["runs"] = _state["runs"][:20]
|
| 74 |
+
print(f"[auto_poster:{kind}] {msg}" + (f" :: {extra}" if extra else ""))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ============================================================
|
| 78 |
+
# CHỌN CHỦ ĐỀ NÓNG
|
| 79 |
+
# ============================================================
|
| 80 |
+
def _clean(s):
|
| 81 |
+
return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _hot_candidates(limit=24):
|
| 85 |
+
"""Trả về list chủ đề nóng dạng {'topic':..., 'label':...} từ RSS VN."""
|
| 86 |
+
freq = {}
|
| 87 |
+
display = {}
|
| 88 |
+
try:
|
| 89 |
+
for name, url in VN_HOT_RSS:
|
| 90 |
+
try:
|
| 91 |
+
r = base.requests.get(url, headers=UA, timeout=6)
|
| 92 |
+
r.encoding = "utf-8"
|
| 93 |
+
soup = BeautifulSoup(r.text, "xml")
|
| 94 |
+
for it in soup.find_all("item")[:12]:
|
| 95 |
+
title = _clean(it.find("title").get_text(" ", strip=True) if it.find("title") else "")
|
| 96 |
+
if not title:
|
| 97 |
+
continue
|
| 98 |
+
title = re.sub(r"\s*[-|].*$", "", title)
|
| 99 |
+
words = [w for w in re.findall(r"[A-Za-zÀ-ỹ0-9]+", title)
|
| 100 |
+
if len(w) > 2 and w.lower() not in _STOP]
|
| 101 |
+
if len(words) < 2:
|
| 102 |
+
continue
|
| 103 |
+
for n in (3, 4, 2):
|
| 104 |
+
for i in range(max(0, len(words) - n + 1)):
|
| 105 |
+
phrase = " ".join(words[i:i + n])
|
| 106 |
+
if 8 <= len(phrase) <= 50:
|
| 107 |
+
key = phrase.lower()
|
| 108 |
+
freq[key] = freq.get(key, 0) + 1
|
| 109 |
+
display[key] = phrase
|
| 110 |
+
except Exception as e:
|
| 111 |
+
_log("warn", f"RSS {name} lỗi: {e}")
|
| 112 |
+
except Exception as e:
|
| 113 |
+
_log("error", "hot_candidates fail", str(e)[:200])
|
| 114 |
+
ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
|
| 115 |
+
out = []
|
| 116 |
+
seen = set()
|
| 117 |
+
for key, _ in ranked:
|
| 118 |
+
if key in seen:
|
| 119 |
+
continue
|
| 120 |
+
seen.add(key)
|
| 121 |
+
out.append({"topic": display[key], "label": "#" + re.sub(r"\s+", "", display[key].title())})
|
| 122 |
+
if len(out) >= limit:
|
| 123 |
+
break
|
| 124 |
+
# Fallback chủ đề VN cố định nếu RSS trống.
|
| 125 |
+
for kw in ["World Cup 2026", "Kinh tế Việt Nam", "Bóng đá Việt Nam", "Công nghệ AI", "Giá vàng", "Thời tiết"]:
|
| 126 |
+
if len(out) >= 24:
|
| 127 |
+
break
|
| 128 |
+
if kw.lower() not in seen:
|
| 129 |
+
seen.add(kw.lower())
|
| 130 |
+
out.append({"topic": kw, "label": "#" + re.sub(r"\s+", "", kw.title())})
|
| 131 |
+
return out
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def pick_topic():
|
| 135 |
+
"""Chọn một chủ đề chưa được tự động đăng gần đây."""
|
| 136 |
+
cands = _hot_candidates()
|
| 137 |
+
if not cands:
|
| 138 |
+
return "Tin tức Việt Nam hôm nay"
|
| 139 |
+
recent_titles = set()
|
| 140 |
+
with _lock:
|
| 141 |
+
for run in _state["runs"][:12]:
|
| 142 |
+
extra = run.get("extra") or {}
|
| 143 |
+
if extra.get("topic"):
|
| 144 |
+
recent_titles.add(extra["topic"].lower())
|
| 145 |
+
# Ưu tiên chủ đề chưa đăng gần đây.
|
| 146 |
+
for c in cands:
|
| 147 |
+
if c["topic"].lower() not in recent_titles:
|
| 148 |
+
return c["topic"]
|
| 149 |
+
return cands[0]["topic"]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ============================================================
|
| 153 |
+
# TẠO BÀI REWRITE (tái sử dụng logic ai_patch)
|
| 154 |
+
# ============================================================
|
| 155 |
+
async def _create_rewrite_post(topic, limit=4):
|
| 156 |
+
"""Tạo 1 bài rewrite từ chủ đề, trả về post dict (đã lưu wall)."""
|
| 157 |
+
articles = ai_patch._topic_source_articles(topic, limit=limit)
|
| 158 |
+
if not articles:
|
| 159 |
+
raise RuntimeError("Không lấy được bài nguồn cho chủ đề: " + topic)
|
| 160 |
+
art = articles[0] # 1 bài/đợt để Tường AI không bị ngập
|
| 161 |
+
prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
|
| 162 |
+
|
| 163 |
+
Chủ đề lọc: {topic}
|
| 164 |
+
Tiêu đề bài nguồn: {art['title']}
|
| 165 |
+
Nguồn: {art['via']}
|
| 166 |
+
|
| 167 |
+
Yêu cầu bắt buộc:
|
| 168 |
+
- Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
|
| 169 |
+
- Không trộn với bài khác.
|
| 170 |
+
- Không viết lại toàn bộ bài.
|
| 171 |
+
- Không lặp ý.
|
| 172 |
+
- 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 173 |
+
- Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
|
| 174 |
+
|
| 175 |
+
Nội dung bài:
|
| 176 |
+
{art['raw'][:14000]}"""
|
| 177 |
+
text = await base.qwen_generate(prompt, image_url=art.get("image") or None, max_tokens=1500)
|
| 178 |
+
text = ai_patch._postprocess_ai_text(text, max_units=20)
|
| 179 |
+
src = [art["source"]]
|
| 180 |
+
if "Nguồn tham khảo:" not in text:
|
| 181 |
+
text += "\n\n" + ai_patch._source_line(src)
|
| 182 |
+
post = base.make_post(
|
| 183 |
+
art["title"],
|
| 184 |
+
text,
|
| 185 |
+
art.get("image") or base.pollinations_image_url(art["title"]),
|
| 186 |
+
art.get("url") or "",
|
| 187 |
+
"auto_rewrite",
|
| 188 |
+
sources=src,
|
| 189 |
+
)
|
| 190 |
+
# Slides để hiển thị sau reload.
|
| 191 |
+
try:
|
| 192 |
+
page_data = ai_patch._scrape_article_images(art.get("url", ""))
|
| 193 |
+
if page_data and page_data.get("paragraphs"):
|
| 194 |
+
key_points = ai_patch._extract_key_points_for_slides(page_data["paragraphs"], max_points=12)
|
| 195 |
+
if key_points:
|
| 196 |
+
relevant_imgs = page_data.get("images", []) or ([page_data["og_img"]] if page_data.get("og_img") else [])
|
| 197 |
+
slides = []
|
| 198 |
+
for i, point in enumerate(key_points):
|
| 199 |
+
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else "")
|
| 200 |
+
slides.append({"text": point, "image": img, "index": i + 1})
|
| 201 |
+
post["slides"] = slides
|
| 202 |
+
except Exception:
|
| 203 |
+
pass
|
| 204 |
+
posts = base._load_ai_wall()
|
| 205 |
+
posts.insert(0, post)
|
| 206 |
+
base._save_ai_wall(posts)
|
| 207 |
+
return post
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ============================================================
|
| 211 |
+
# SINH SHORT (tái sử dụng logic video của ai_patch)
|
| 212 |
+
# ============================================================
|
| 213 |
+
def _make_short_for_post(post_id, voice="nu", emotion="neutral", speed=1.2):
|
| 214 |
+
"""Sinh video short cho post (dùng lại hàm đã tách từ route /api/ai/short)."""
|
| 215 |
+
if hasattr(ai_patch, "_generate_short_for_post"):
|
| 216 |
+
return ai_patch._generate_short_for_post(post_id, voice=voice, emotion=emotion, speed=speed)
|
| 217 |
+
raise RuntimeError("thiếu ai_patch._generate_short_for_post")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# ============================================================
|
| 221 |
+
# MỘT CHU KỲ CHẠY
|
| 222 |
+
# ============================================================
|
| 223 |
+
def run_once(topic=None, with_short=True):
|
| 224 |
+
"""Chạy 1 chu kỳ: tạo rewrite + (tùy chọn) sinh short. Trả về dict kết quả."""
|
| 225 |
+
started = time.time()
|
| 226 |
+
topic = topic or pick_topic()
|
| 227 |
+
try:
|
| 228 |
+
post = asyncio.run(_create_rewrite_post(topic, limit=4))
|
| 229 |
+
pid = str(post.get("id"))
|
| 230 |
+
out = {"topic": topic, "post_id": pid, "title": post.get("title"), "short": None}
|
| 231 |
+
if with_short and base.gTTS is not None:
|
| 232 |
+
try:
|
| 233 |
+
res = _make_short_for_post(pid, voice="nu", emotion="neutral", speed=1.2)
|
| 234 |
+
out["short"] = res.get("video") if isinstance(res, dict) else str(res)
|
| 235 |
+
except Exception as e:
|
| 236 |
+
_log("error", "short gen fail", f"{pid}: {e}")
|
| 237 |
+
out["short_error"] = str(e)[:200]
|
| 238 |
+
_log("run", f"Đã đăng tự động: {topic}", {"topic": topic, "post_id": pid, "short": bool(out.get("short"))})
|
| 239 |
+
with _lock:
|
| 240 |
+
_state["last_run"] = int(time.time())
|
| 241 |
+
return {"ok": True, **out}
|
| 242 |
+
except Exception as e:
|
| 243 |
+
_log("error", "run_once fail", f"{topic}: {e}\n{traceback.format_exc()[-600:]}")
|
| 244 |
+
with _lock:
|
| 245 |
+
_state["errors"].insert(0, {"ts": int(time.time()), "kind": "error", "msg": str(e)[:300]})
|
| 246 |
+
_state["errors"] = _state["errors"][:20]
|
| 247 |
+
return {"ok": False, "topic": topic, "error": str(e)[:300]}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ============================================================
|
| 251 |
+
# SCHEDULER LOOP
|
| 252 |
+
# ============================================================
|
| 253 |
+
def _slot_key():
|
| 254 |
+
t = time.localtime()
|
| 255 |
+
return f"{t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d}-{t.tm_hour:02d}"
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _should_fire():
|
| 259 |
+
t = time.localtime()
|
| 260 |
+
return t.tm_hour in SCHEDULE_HOURS and t.tm_min >= SCHEDULE_MINUTE
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _loop():
|
| 264 |
+
_log("start", "Auto poster scheduler started", {"hours": SCHEDULE_HOURS})
|
| 265 |
+
last_slot = ""
|
| 266 |
+
while True:
|
| 267 |
+
try:
|
| 268 |
+
if _should_fire():
|
| 269 |
+
slot = _slot_key()
|
| 270 |
+
with _lock:
|
| 271 |
+
fired = _state.get("last_slot") == slot
|
| 272 |
+
if not fired:
|
| 273 |
+
try:
|
| 274 |
+
run_once()
|
| 275 |
+
except Exception as e:
|
| 276 |
+
_log("error", "scheduler cycle failed", str(e)[:300])
|
| 277 |
+
with _lock:
|
| 278 |
+
_state["last_slot"] = slot
|
| 279 |
+
else:
|
| 280 |
+
# reset slot khi ra khỏi khung giờ để lần sau bắn lại
|
| 281 |
+
slot = _slot_key()
|
| 282 |
+
with _lock:
|
| 283 |
+
if _state.get("last_slot") and _state["last_slot"] != slot:
|
| 284 |
+
_state["last_slot"] = ""
|
| 285 |
+
except Exception as e:
|
| 286 |
+
_log("error", "loop error", str(e)[:200])
|
| 287 |
+
time.sleep(55)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def start():
|
| 291 |
+
with _lock:
|
| 292 |
+
if _state["running"] and _state["thread"] and _state["thread"].is_alive():
|
| 293 |
+
return False
|
| 294 |
+
_state["running"] = True
|
| 295 |
+
_state["thread"] = threading.Thread(target=_loop, name="vnews-auto-poster", daemon=True)
|
| 296 |
+
_state["thread"].start()
|
| 297 |
+
_log("start", "Auto poster thread launched")
|
| 298 |
+
return True
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def status():
|
| 302 |
+
with _lock:
|
| 303 |
+
return {
|
| 304 |
+
"running": bool(_state["running"] and _state["thread"] and _state["thread"].is_alive()),
|
| 305 |
+
"schedule_hours": SCHEDULE_HOURS,
|
| 306 |
+
"schedule_minute": SCHEDULE_MINUTE,
|
| 307 |
+
"last_run": _state["last_run"],
|
| 308 |
+
"last_slot": _state["last_slot"],
|
| 309 |
+
"runs": _state["runs"][:10],
|
| 310 |
+
"errors": _state["errors"][:5],
|
| 311 |
+
}
|