bep40 commited on
Commit
471547c
·
verified ·
1 Parent(s): 44813b6

Upload auto_poster.py

Browse files
Files changed (1) hide show
  1. 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
+ }