bep40 commited on
Commit
7d117b1
·
verified ·
1 Parent(s): e474194

Revert to c726397: restore 100% to target commit

Browse files
Files changed (2) hide show
  1. ai_ext.py +1 -134
  2. app_v2_entry.py +1341 -48
ai_ext.py CHANGED
@@ -197,137 +197,4 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
197
  break
198
  if chunks:
199
  return "\n".join("• " + c for c in chunks)
200
- return "• Không có đủ nội dung để tóm tắt."
201
-
202
-
203
- # ===== URL scraping & article processing =====
204
- HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
205
-
206
- try:
207
- _shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
208
- except Exception:
209
- _shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
210
- SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts")
211
- os.makedirs(SHORTS_DIR, exist_ok=True)
212
-
213
- import random as _random2
214
- from datetime import datetime, timezone, timedelta
215
- _VN_TZ = timezone(timedelta(hours=7))
216
-
217
-
218
- def _safe_name(filename: str) -> str:
219
- """Sanitize filename."""
220
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
221
-
222
-
223
- def pollinations_image_url(topic: str) -> str:
224
- """Generate a placeholder image URL via Pollinations."""
225
- try:
226
- return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
227
- except Exception:
228
- return ""
229
-
230
-
231
- def _download_image(url: str, fallback_title: str, out_path: str) -> str:
232
- """Download an image from URL or create a placeholder."""
233
- if url:
234
- try:
235
- r = requests.get(url, headers=HEADERS, timeout=15)
236
- if r.status_code == 200 and len(r.content) > 1200:
237
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
238
- with open(out_path, "wb") as f:
239
- f.write(r.content)
240
- return out_path
241
- except Exception:
242
- pass
243
- # Fallback: create a placeholder image
244
- try:
245
- from PIL import Image, ImageDraw, ImageFont
246
- img = Image.new("RGB", (1080, 760), (24, 24, 24))
247
- draw = ImageDraw.Draw(img)
248
- try:
249
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
250
- except Exception:
251
- font = None
252
- text = (fallback_title or "VNEWS")[:40]
253
- try:
254
- bbox = draw.textbbox((0, 0), text, font=font)
255
- tw = bbox[2] - bbox[0]
256
- except Exception:
257
- tw = len(text) * 24
258
- draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
259
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
260
- img.save(out_path, quality=90)
261
- return out_path
262
- except Exception:
263
- return out_path
264
-
265
-
266
- def scrape_any_url(url: str) -> dict:
267
- """Scrape article content from any URL."""
268
- if not url or not url.startswith("http"):
269
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""}
270
- try:
271
- r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
272
- if r.status_code != 200 or not r.text:
273
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
274
- r.encoding = "utf-8"
275
- soup = BeautifulSoup(r.text, "lxml")
276
- for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript", "iframe", ".ads", ".ad", ".banner-ads", ".fb-comments", ".fb-root", ".social-share", ".related-news", ".breadcrumb"]):
277
- tag.decompose()
278
- title = ""
279
- ogt = soup.find("meta", property="og:title")
280
- if ogt:
281
- title = ogt.get("content", "")
282
- h1 = soup.find("h1")
283
- if not title and h1:
284
- title = h1.get_text(strip=True)
285
- if not title:
286
- t = soup.find("title")
287
- if t:
288
- title = t.get_text(strip=True)
289
- og_image = ""
290
- ogi = soup.find("meta", property="og:image")
291
- if ogi:
292
- og_image = ogi.get("content", "")
293
- if og_image.startswith("//"):
294
- og_image = "https:" + og_image
295
- summary = ""
296
- ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
297
- if ogd:
298
- summary = ogd.get("content", "")[:500]
299
- body_text = []
300
- for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
301
- el = soup.select_one(sel)
302
- if el and len(el.find_all("p")) >= 2:
303
- for p in el.find_all("p"):
304
- t = _clean_text(p.get_text(strip=True))
305
- if t and len(t) > 30:
306
- body_text.append(t)
307
- break
308
- if not body_text and soup.body:
309
- for p in soup.body.find_all("p"):
310
- t = _clean_text(p.get_text(strip=True))
311
- if t and len(t) > 30:
312
- body_text.append(t)
313
- text = "\n".join(body_text)
314
- return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url}
315
- except Exception as e:
316
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
317
-
318
-
319
- def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict:
320
- """Create a wall post dict."""
321
- import random as _r2
322
- now = int(time.time() * 1000)
323
- return {
324
- "id": str(now) + str(_r2.randint(100, 999)),
325
- "title": (title or "Bài viết")[:200],
326
- "text": (text or "")[:5000],
327
- "img": img or "",
328
- "url": url or "",
329
- "kind": kind or "auto",
330
- "sources": sources or [],
331
- "created": now,
332
- "created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"),
333
- }
 
197
  break
198
  if chunks:
199
  return "\n".join("• " + c for c in chunks)
200
+ return "• Không có đủ nội dung để tóm tắt."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_v2_entry.py CHANGED
@@ -226,7 +226,7 @@ _STOP=set('và của các những một được trong với cho tại sau trư
226
  def _has_kw(topic,title):
227
  tl=topic.lower();tt=(title or'').lower()
228
  if tl in tt:return True
229
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w.lower() not in _STOP]
230
  if not words:return True
231
  return any(w in tt for w in words)
232
 
@@ -498,9 +498,9 @@ def _get_hot_topics():
498
  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)
499
  if is_dup:continue
500
  seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
501
- if len/topics>=20:break
502
  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']:
503
- if len/topics>=24:break
504
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
505
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
506
 
@@ -810,7 +810,7 @@ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
810
  @app.get('/api/wc2026/match/{eid}')
811
  def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
812
 
813
- DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
814
  os.makedirs(DATA_DIR,exist_ok=True)
815
  IF=os.path.join(DATA_DIR,'interactions_v2.json')
816
  CF=os.path.join(DATA_DIR,'comments_v2.json')
@@ -821,54 +821,43 @@ os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
821
  _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
822
  def _lj(p):
823
  try:
824
- p=str(p)
825
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
826
  except:pass
827
- return[]
828
  def _sj(p,d):
829
- try:
830
- p=str(p)
831
- os.makedirs(os.path.dirname(p),exist_ok=True)
832
- tmp=p+'.tmp'
833
- with open(tmp,'w',encoding='utf-8') as f:
834
- json.dump(d,f,ensure_ascii=False)
835
- os.replace(tmp,p)
836
  except:pass
837
 
838
- def _wall_posts_list():
839
- posts = _load_wall_posts()
840
- return posts if isinstance(posts, list) else []
 
 
841
 
842
- def _append_wall_post(post):
843
- posts = _wall_posts_list()
844
- posts.insert(0, post)
845
- posts = posts[:200]
846
- _save_wall_posts(posts)
847
- return posts
848
 
849
- @app.get('/api/ai/wall')
850
- def api_ai_wall():
851
- return JSONResponse({"posts": _wall_posts_list()})
852
 
853
- @app.post("/api/ai/short/{post_id}")
854
- def api_ai_short(post_id: str, request: Request):
855
- """Generate a short video for a wall post.
 
 
 
 
 
856
 
857
- This endpoint intentionally returns a clear JSON error when video generation is not
858
- available yet, instead of crashing FastAPI with an invalid JSON response.
859
- """
860
- post = None
861
- post_id_s = str(post_id)
862
- for p in _wall_posts_list():
863
- pid = str(p.get('id', ''))
864
- if pid == post_id_s or pid.startswith(post_id_s):
865
- post = p
866
- break
867
- if not post:
868
- return JSONResponse({'error': 'Không tìm thấy bài viết', 'post_id': post_id}, status_code=404)
869
- if post.get('video'):
870
- return JSONResponse({'video': post['video'], 'post': post})
871
- return JSONResponse({'error': 'Chưa có video cho bài này. Vui lòng upload video trước.'}, status_code=409)
872
 
873
  @app.get('/api/wall')
874
  def api_wall():
@@ -1004,7 +993,7 @@ _UNIQUE_CHARS = {
1004
  _STOPWORDS = {
1005
  'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
1006
  'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
1007
- 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
1008
  'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
1009
  }
1010
 
@@ -1056,9 +1045,1313 @@ _EMOTION_KEYWORDS = {
1056
  'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
1057
  },
1058
  'sad': {
1059
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'gloomy', 'sorrow', 'unfortunately'],
1060
- 'pt': ['triste', 'infeliz', 'terrível', 'horroroso', 'miserável', 'deprimido'],
1061
- 'es': ['triste', 'infeliz', 'terrible', 'horroroso', 'miserable', 'deprimido'],
1062
- 'vi': ['buồn', 'thảm hại', 'tệ hại', 'khủng khiếp', 'miserable', 'buồn ', 'thất vọng', 'tuyệt vọng'],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1063
  },
1064
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def _has_kw(topic,title):
227
  tl=topic.lower();tt=(title or'').lower()
228
  if tl in tt:return True
229
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
230
  if not words:return True
231
  return any(w in tt for w in words)
232
 
 
498
  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)
499
  if is_dup:continue
500
  seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
501
+ if len(topics)>=20:break
502
  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']:
503
+ if len(topics)>=24:break
504
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
505
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
506
 
 
810
  @app.get('/api/wc2026/match/{eid}')
811
  def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
812
 
813
+ DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
814
  os.makedirs(DATA_DIR,exist_ok=True)
815
  IF=os.path.join(DATA_DIR,'interactions_v2.json')
816
  CF=os.path.join(DATA_DIR,'comments_v2.json')
 
821
  _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
822
  def _lj(p):
823
  try:
 
824
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
825
  except:pass
826
+ return{}
827
  def _sj(p,d):
828
+ try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
 
 
 
 
 
 
829
  except:pass
830
 
831
+ @app.post('/api/v2/interact')
832
+ async def _int(request:Request):
833
+ b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
834
+ if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
835
+ with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
836
 
837
+ @app.get('/api/v2/interactions')
838
+ def _gi(id:str=Query(...)):
839
+ with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
 
 
 
840
 
841
+ @app.get('/api/v2/comments')
842
+ def _gc(id:str=Query(...)):
843
+ with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
844
 
845
+ @app.post('/api/v2/comment')
846
+ async def _pc(request:Request):
847
+ b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
848
+ if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
849
+ c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
850
+ with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
851
+ with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
852
+ return JSONResponse({'comments':cms})
853
 
854
+ def _load_wall_posts():
855
+ with _wl_lock:
856
+ return _lj(WALL_FILE)
857
+
858
+ def _save_wall_posts(posts):
859
+ with _wl_lock:
860
+ _sj(WALL_FILE, posts)
 
 
 
 
 
 
 
 
861
 
862
  @app.get('/api/wall')
863
  def api_wall():
 
993
  _STOPWORDS = {
994
  'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
995
  'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
996
+ 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
997
  'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
998
  }
999
 
 
1045
  'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
1046
  },
1047
  'sad': {
1048
+ 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1049
+ 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1050
+ 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1051
+ 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
1052
+ },
1053
+ 'excited': {
1054
+ 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1055
+ 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1056
+ 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1057
+ 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
1058
+ },
1059
+ 'humorous': {
1060
+ 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1061
+ 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1062
+ 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1063
+ 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
1064
+ },
1065
+ 'serious': {
1066
+ 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1067
+ 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1068
+ 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1069
+ 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
1070
+ },
1071
+ }
1072
+
1073
+ def detect_emotion(text, language='vietnamese'):
1074
+ """Detect emotion from text using keyword matching."""
1075
+ if not text:
1076
+ return 'neutral'
1077
+ text_lower = text.lower()
1078
+
1079
+ scores = {}
1080
+ for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1081
+ keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1082
+ score = sum(1 for kw in keywords if kw in text_lower)
1083
+ scores[emotion] = score
1084
+
1085
+ if max(scores.values()) == 0:
1086
+ return 'neutral'
1087
+
1088
+ return max(scores, key=scores.get)
1089
+
1090
+ def detect_language_and_emotion(title, text):
1091
+ """Detect both language and emotion from article content."""
1092
+ combined = f"{title} {text}"
1093
+ lang = detect_language(combined)
1094
+ emotion = detect_emotion(combined, lang)
1095
+ return lang, emotion
1096
+
1097
+ # Voice selection based on language and emotion (using MultilingualNeural voices)
1098
+ VOICE_BY_LANG_EMOTION = {
1099
+ 'vietnamese': {
1100
+ 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1101
+ 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1102
+ 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1103
+ 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1104
+ 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1105
+ 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1106
+ },
1107
+ 'portuguese': {
1108
+ 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1109
+ 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1110
+ 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1111
+ 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1112
+ 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1113
+ 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1114
+ },
1115
+ 'english': {
1116
+ 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1117
+ 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1118
+ 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1119
+ 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1120
+ 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1121
+ 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1122
+ },
1123
+ 'french': {
1124
+ 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1125
+ 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1126
+ 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1127
+ 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1128
+ 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1129
+ 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1130
+ },
1131
+ 'german': {
1132
+ 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1133
+ 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1134
+ 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1135
+ 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1136
+ 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1137
+ 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1138
+ },
1139
+ 'korean': {
1140
+ 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1141
+ 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1142
+ 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1143
+ 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1144
+ 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1145
+ 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1146
+ },
1147
+ 'italian': {
1148
+ 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1149
+ 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1150
+ 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1151
+ 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1152
+ 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1153
+ 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1154
  },
1155
  }
1156
+
1157
+ # All valid voice IDs (new MultilingualNeural format)
1158
+ VALID_VOICES = {
1159
+ 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1160
+ 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1161
+ 'pt-BR-ThalitaMultilingualNeural',
1162
+ 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1163
+ 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1164
+ 'ko-KR-HyunsuMultilingualNeural',
1165
+ 'it-IT-GiuseppeMultilingualNeural',
1166
+ }
1167
+
1168
+ def get_voice_for_content(title, text, preferred_voice=None):
1169
+ """Get appropriate voice based on content language and emotion."""
1170
+ # Accept the new MultilingualNeural voices directly
1171
+ if preferred_voice and preferred_voice in VALID_VOICES:
1172
+ return preferred_voice
1173
+
1174
+ # Also accept old shorthand voice IDs and map them to new format
1175
+ old_voice_map = {
1176
+ 'hoaimy': 'vi-VN-HoaiMyNeural',
1177
+ 'namminh': 'vi-VN-NamMinhNeural',
1178
+ 'andrew': 'en-US-AndrewMultilingualNeural',
1179
+ 'jenny': 'en-US-AndrewMultilingualNeural',
1180
+ 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1181
+ 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1182
+ 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1183
+ 'ela': 'en-US-AndrewMultilingualNeural',
1184
+ 'es_carlos': 'en-US-AndrewMultilingualNeural',
1185
+ 'denise': 'fr-FR-VivienneMultilingualNeural',
1186
+ 'katja': 'de-DE-SeraphinaMultilingualNeural',
1187
+ 'nanami': 'en-US-AndrewMultilingualNeural',
1188
+ 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1189
+ 'xiaochen': 'en-US-AndrewMultilingualNeural',
1190
+ }
1191
+ if preferred_voice and preferred_voice in old_voice_map:
1192
+ return old_voice_map[preferred_voice]
1193
+
1194
+ lang, emotion = detect_language_and_emotion(title, text)
1195
+ lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1196
+ voice, _ = lang_map.get(emotion, lang_map['neutral'])
1197
+ return voice
1198
+
1199
+
1200
+ def _is_relevant_image(img_url, title, text):
1201
+ """Check if an image is relevant to the article content."""
1202
+ if not img_url:
1203
+ return False
1204
+ skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1205
+ 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1206
+ 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1207
+ img_lower = img_url.lower()
1208
+ for p in skip_patterns:
1209
+ if p in img_lower:
1210
+ return False
1211
+ if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1212
+ return False
1213
+ return True
1214
+
1215
+
1216
+ def _filter_relevant_images(images, title, text, max_images=8):
1217
+ """Filter and rank images by relevance to article content."""
1218
+ if not images:
1219
+ return []
1220
+ seen = set()
1221
+ relevant = []
1222
+ for img in images:
1223
+ if img in seen:
1224
+ continue
1225
+ seen.add(img)
1226
+ if _is_relevant_image(img, title, text):
1227
+ relevant.append(img)
1228
+ return relevant[:max_images]
1229
+
1230
+
1231
+ def _scrape_article_for_rewrite(url):
1232
+ """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1233
+ try:
1234
+ r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1235
+ r.encoding = 'utf-8'
1236
+ soup = BeautifulSoup(r.text, 'lxml')
1237
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1238
+ tag.decompose()
1239
+ h1 = soup.find('h1')
1240
+ ogt = soup.find('meta', property='og:title')
1241
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1242
+ ogi = soup.find('meta', property='og:image')
1243
+ og_img = ogi.get('content', '') if ogi else ''
1244
+ if og_img and og_img.startswith('//'):
1245
+ og_img = 'https:' + og_img
1246
+ block = None
1247
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1248
+ el = soup.select_one(sel)
1249
+ if el and len(el.find_all('p')) >= 2:
1250
+ block = el
1251
+ break
1252
+ if not block:
1253
+ block = soup.body or soup
1254
+ paragraphs = []
1255
+ all_images = []
1256
+ seen_imgs = set()
1257
+ if og_img and og_img not in seen_imgs:
1258
+ all_images.append(og_img)
1259
+ seen_imgs.add(og_img)
1260
+ for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1261
+ if el.name == 'p':
1262
+ t = _clean(el.get_text(strip=True))
1263
+ if t and len(t) > 40:
1264
+ paragraphs.append(t)
1265
+ elif el.name in ('figure', 'img'):
1266
+ im = el if el.name == 'img' else el.find('img')
1267
+ if im:
1268
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1269
+ if src and 'base64' not in src:
1270
+ if src.startswith('//'):
1271
+ src = 'https:' + src
1272
+ if src not in seen_imgs:
1273
+ all_images.append(src)
1274
+ seen_imgs.add(src)
1275
+ # Filter to relevant images only
1276
+ relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1277
+ return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1278
+ except Exception:
1279
+ return None
1280
+
1281
+
1282
+ def _extract_key_points_rw(paragraphs, max_points=5):
1283
+ r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1284
+
1285
+ Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1286
+ Now splits on all sentence boundaries and takes valid sentences until max_points.
1287
+ """
1288
+ points = []
1289
+
1290
+ for p in paragraphs:
1291
+ if len(points) >= max_points:
1292
+ break
1293
+
1294
+ p = _clean(p)
1295
+ if not p:
1296
+ continue
1297
+
1298
+ # Split paragraph into sentences using Vietnamese + English punctuation
1299
+ sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1300
+ sentences = [s.strip() for s in sentences if s.strip()]
1301
+
1302
+ for sentence in sentences:
1303
+ if len(points) >= max_points:
1304
+ break
1305
+
1306
+ # Clean sentence - remove extra whitespace
1307
+ sentence = _clean(sentence)
1308
+
1309
+ if len(sentence) < 30:
1310
+ continue
1311
+
1312
+ # Check for duplicates
1313
+ if any(sentence[:60] in existing for existing in points):
1314
+ continue
1315
+
1316
+ # Ensure sentence ends with punctuation
1317
+ if not sentence.endswith(('.', '!', '?')):
1318
+ sentence = sentence + '.'
1319
+
1320
+ points.append(sentence)
1321
+
1322
+ # If no valid sentences found, take chunks from raw text
1323
+ if not points:
1324
+ raw = '\n'.join(paragraphs)
1325
+ for i in range(0, min(len(raw), max_points * 300), 280):
1326
+ chunk = _clean(raw[i:i+280])
1327
+ if len(chunk) >= 30 and chunk not in points:
1328
+ points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1329
+ if len(points) >= max_points:
1330
+ break
1331
+
1332
+ return points
1333
+
1334
+
1335
+ @app.post("/api/rewrite_slide")
1336
+ async def api_rewrite_slide(request: Request):
1337
+ """Fast rewrite as SLIDES - no AI needed, instant response."""
1338
+ body = await request.json()
1339
+ url = _clean(body.get("url", ""))
1340
+ context = body.get("context", "")
1341
+ preferred_voice = body.get("voice", "") # Accept custom voice selection
1342
+ if not url and not context:
1343
+ return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1344
+ data = None
1345
+ if url and url.startswith("http"):
1346
+ data = _scrape_article_for_rewrite(url)
1347
+ if not data and context:
1348
+ paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1349
+ data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1350
+ if not data or not data.get('paragraphs'):
1351
+ return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1352
+ points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1353
+ if not points:
1354
+ return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1355
+ images = data.get('images', [])
1356
+ slides = []
1357
+ for i, point in enumerate(points):
1358
+ img = images[i] if i < len(images) else (images[-1] if images else '')
1359
+ if img and 'cdnphoto.dantri' in img:
1360
+ img = '/api/proxy/img?url=' + _quote2(img, safe='')
1361
+ slides.append({'text': point, 'image': img, 'index': i + 1})
1362
+ summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1363
+
1364
+ # Auto-detect language and emotion
1365
+ lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1366
+ # Use preferred voice if provided, otherwise auto-detect
1367
+ voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1368
+
1369
+ post = {
1370
+ "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1371
+ "title": data['title'],
1372
+ "text": summary_text,
1373
+ "img": images[0] if images else '',
1374
+ "url": url,
1375
+ "kind": "slide_summary",
1376
+ "slides": slides,
1377
+ "images": images[:10],
1378
+ "video": "",
1379
+ "voice": voice,
1380
+ "emotion": emotion,
1381
+ "language": lang,
1382
+ "ts": int(time.time())
1383
+ }
1384
+ posts = _load_wall_posts()
1385
+ posts.insert(0, post)
1386
+ _save_wall_posts(posts)
1387
+ return JSONResponse({"post": post, "slides": slides})
1388
+
1389
+
1390
+ @app.post("/api/rewrite_share")
1391
+ async def api_rewrite_share(request: Request):
1392
+ """Rewrite article and post to Tường AI with SLIDES + AI text."""
1393
+ body = await request.json()
1394
+ url = _clean(body.get("url", ""))
1395
+ ctx = _clean(body.get("context", ""))
1396
+ preferred_voice = body.get("voice", "") # Accept custom voice selection
1397
+ if not url and not ctx:
1398
+ return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1399
+ data = None
1400
+ if url and url.startswith("http"):
1401
+ data = _scrape_article_for_rewrite(url)
1402
+ if not data and ctx:
1403
+ paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1404
+ data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1405
+ if not data or not data.get('paragraphs'):
1406
+ return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1407
+ raw_text = '\n'.join(data['paragraphs'])
1408
+ if len(raw_text) < 50:
1409
+ raw_text = ctx[:14000]
1410
+ if len(raw_text) < 50:
1411
+ return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1412
+ domain = ''
1413
+ try:
1414
+ from urllib.parse import urlparse
1415
+ domain = urlparse(url).netloc.replace('www.', '')
1416
+ except:
1417
+ pass
1418
+
1419
+ # Generate AI summary text
1420
+ ai_text = None
1421
+ try:
1422
+ import ai_ext
1423
+ if hasattr(ai_ext, 'qwen_generate'):
1424
+ prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
1425
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1426
+ except Exception:
1427
+ pass
1428
+ if not ai_text or len(ai_text) < 80:
1429
+ key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1430
+ if key_pts:
1431
+ ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1432
+ else:
1433
+ ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1434
+
1435
+ # Build slides from key points (FIX: include slides in rewrite_share too!)
1436
+ points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1437
+ images = data.get('images', [])
1438
+ slides = []
1439
+ for i, point in enumerate(points):
1440
+ img = images[i] if i < len(images) else (images[-1] if images else '')
1441
+ if img and 'cdnphoto.dantri' in img:
1442
+ img = '/api/proxy/img?url=' + _quote2(img, safe='')
1443
+ slides.append({'text': point, 'image': img, 'index': i + 1})
1444
+
1445
+ # Auto-detect language and emotion
1446
+ lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1447
+ # Use preferred voice if provided, otherwise auto-detect
1448
+ voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1449
+
1450
+ post = {
1451
+ "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1452
+ "title": data['title'],
1453
+ "text": ai_text,
1454
+ "img": images[0] if images else '',
1455
+ "url": url,
1456
+ "kind": "rewrite",
1457
+ "slides": slides,
1458
+ "images": images[:10],
1459
+ "video": "",
1460
+ "voice": voice,
1461
+ "emotion": emotion,
1462
+ "language": lang,
1463
+ "ts": int(time.time())
1464
+ }
1465
+ posts = _load_wall_posts()
1466
+ posts.insert(0, post)
1467
+ _save_wall_posts(posts)
1468
+ return JSONResponse({"post": post, "slides": slides})
1469
+
1470
+
1471
+ @app.post("/api/url_wall")
1472
+ async def api_url_wall(request: Request):
1473
+ """Submit URL to add to Tường AI."""
1474
+ body = await request.json()
1475
+ url = _clean(body.get("url", ""))
1476
+ if not url or not url.startswith('http'):
1477
+ return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1478
+ # Reuse rewrite_share logic
1479
+ req._body = json.dumps({"url": url}).encode()
1480
+ return await api_rewrite_share(request)
1481
+
1482
+
1483
+ # ===== PERSONAL OPINION POST v2: AI tổng hợp bài viết từ quan điểm + nguồn tin HOT =====
1484
+
1485
+ # ===== KEYWORD EXTRACTION FROM OPINION =====
1486
+ _STOP_WORDS_EX = set("""
1487
+ và của các những một được trong với cho tại sau trước khi không người
1488
+ 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ì
1489
+ này đã để về lại nên cũng rất như vì do nếu sẽ nếu thế nhưng mà vẫn
1490
+ đang vào ra hơn đây đó nào cả cùng đã từng hãy còn chỉ cũng đều khiến
1491
+ được đã bị bởi qua những lúc cái gì cô chú bác anh chị em bạn tôi mình
1492
+ ông bà thầy vì vậy chính phải ấy đấy đâu đó thôi nhé đấy ạ nhỉ
1493
+ ngày tháng năm giờ phút giây tuần tháng quý
1494
+ """.strip().split())
1495
+
1496
+ def _extract_keywords_from_opinion(text, max_keywords=5):
1497
+ """Extract meaningful keywords from user's opinion for news search."""
1498
+ if not text:
1499
+ return []
1500
+ text = text.lower()
1501
+ text = re.sub(r'https?://\S+', '', text)
1502
+ text = re.sub(r'[^\w\sÀ-ỹ]', ' ', text)
1503
+ text = re.sub(r'\s+', ' ', text).strip()
1504
+ words = [w for w in text.split() if len(w) > 2 and w not in _STOP_WORDS_EX]
1505
+ word_scores = {}
1506
+ for w in words:
1507
+ word_scores[w] = word_scores.get(w, 0) + 1
1508
+ sorted_words = sorted(word_scores.items(), key=lambda x: -x[1])
1509
+ top_words = [w for w, s in sorted_words[:max_keywords]]
1510
+ phrases = []
1511
+ for i in range(len(words) - 1):
1512
+ phrase = words[i] + ' ' + words[i + 1]
1513
+ if len(phrase) > 5:
1514
+ phrases.append(phrase)
1515
+ phrase_scores = {}
1516
+ for p in phrases:
1517
+ phrase_scores[p] = phrase_scores.get(p, 0) + 1
1518
+ sorted_phrases = sorted(phrase_scores.items(), key=lambda x: -x[1])
1519
+ top_phrases = [p for p, s in sorted_phrases[:3]]
1520
+ result = []
1521
+ for p in top_phrases:
1522
+ if p not in result:
1523
+ result.append(p)
1524
+ for w in top_words:
1525
+ if w not in result:
1526
+ result.append(w)
1527
+ return result[:max_keywords]
1528
+
1529
+
1530
+ @app.post("/api/personal_post/preview")
1531
+ async def api_personal_post_preview(request: Request):
1532
+ """Preview personal post: fetch full articles, let AI compose logical article with images."""
1533
+ body = await request.json()
1534
+ opinion = _clean(body.get("opinion", ""))
1535
+ selected_topics = body.get("selected_topics", []) or []
1536
+ selected_sources = body.get("selected_sources", []) or []
1537
+
1538
+ if not opinion or len(opinion) < 10:
1539
+ return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
1540
+
1541
+ # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
1542
+ keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
1543
+ if keywords:
1544
+ selected_topics = keywords[:3]
1545
+ else:
1546
+ # Fallback: hot topics
1547
+ hot = _get_hot_topics()
1548
+ selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
1549
+
1550
+ # Tìm nguồn tin
1551
+ all_sources = []
1552
+ seen_urls = set()
1553
+ for topic in selected_topics[:3]:
1554
+ sources = _search_all(topic, limit=5)
1555
+ for s in sources:
1556
+ if s.get("url") and s["url"] not in seen_urls:
1557
+ seen_urls.add(s["url"])
1558
+ all_sources.append(s)
1559
+ if len(all_sources) >= 6:
1560
+ break
1561
+ if len(all_sources) >= 6:
1562
+ break
1563
+
1564
+ for src in selected_sources:
1565
+ if src.get("url") and src["url"] not in seen_urls:
1566
+ all_sources.insert(0, src)
1567
+
1568
+ # Scrape nội dung đầy đủ từng nguồn (paragraphs + images)
1569
+ source_details = []
1570
+ source_images = []
1571
+ for src in all_sources[:5]:
1572
+ url = src.get("url", "")
1573
+ if not url:
1574
+ continue
1575
+ try:
1576
+ art = _scrape_article_for_rewrite(url)
1577
+ if art:
1578
+ src_detail = {
1579
+ "title": art.get("title", src.get("title", "")),
1580
+ "url": url,
1581
+ "via": src.get("via", ""),
1582
+ "paragraphs": art.get("paragraphs", [])[:8],
1583
+ "images": art.get("images", [])[:3],
1584
+ "og_image": art.get("og_img", "")
1585
+ }
1586
+ source_details.append(src_detail)
1587
+ # Collect images for proxy
1588
+ for img in art.get("images", [])[:2]:
1589
+ if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
1590
+ img = "/api/proxy/img?url=" + _quote2(img, safe="")
1591
+ source_images.append(img)
1592
+ except:
1593
+ pass
1594
+ if len(source_details) >= 5:
1595
+ break
1596
+
1597
+ # Tạo title từ opinion
1598
+ opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
1599
+ title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
1600
+ title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
1601
+ title = title[:80]
1602
+
1603
+ # AI sinh bài viết hoàn chỉnh
1604
+ ai_text = None
1605
+ try:
1606
+ import ai_ext
1607
+ if hasattr(ai_ext, 'qwen_generate'):
1608
+ # Build detailed context from source articles
1609
+ source_context = ""
1610
+ for i, sd in enumerate(source_details[:5]):
1611
+ src_title = sd.get("title", "")
1612
+ src_via = sd.get("via", "")
1613
+ src_paras = sd.get("paragraphs", [])
1614
+ source_context += f"\n=== Nguồn {i+1}: {src_title} ({src_via}) ===\n"
1615
+ for j, p in enumerate(src_paras[:4]):
1616
+ source_context += f" - {p[:300]}\n"
1617
+
1618
+ prompt = (
1619
+ "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
1620
+ "=== NGUỒN TIN THAM KHẢO ===\n" + source_context + "\n\n"
1621
+ "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
1622
+ "Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n"
1623
+ "\n"
1624
+ "QUAN TRỌNG NHẤT: MỗI SLIDE PHẢI KẾT HỢP QUAN ĐIỂM CÁ NHÂN + NỘI DUNG NGUỒN TIN, KHÔNG PHẢI CHỈ NÓI VỀ NGUỒN TIN.\n"
1625
+ "\n"
1626
+ "SLIDE 1 - MỞ ĐẦU:\n"
1627
+ "- NHIỆN HỮU QUAN ĐIỂM CÁ NHÂN LÊN ĐẦU\n"
1628
+ "- Giới thiệu chủ đề, nêu rõ quan điểm của bạn (dựa vào QUAN ĐIỂM CÁ NHÂN ở trên)\n"
1629
+ "- 2-4 câu hoàn chỉnh\n"
1630
+ "\n"
1631
+ "SLIDE 2-3-4-5 - PHÂN TÍCH:\n"
1632
+ "- Mỗi slide: B�Commencer bằng QUAN ĐIỂM CÁ NHÂN, sau đó dẫn chứng từ 1 nguồn tin\n"
1633
+ "- Ví dụ: \"Theo quan điểm của tôi, đây là vấn đề cần lưu ý. Theo VnExpress...\"\n"
1634
+ "- Dẫn chứng từ nguồn (ghi rõ tên báo: Theo VnExpress, Theo Thanh Niên...)\n"
1635
+ "- 2-4 câu hoàn chỉnh mỗi slide\n"
1636
+ "\n"
1637
+ "SLIDE 6 - KẾT LUẬN:\n"
1638
+ "- Tổng kết quan điểm cá nhân, đưa ra nhận định cuối cùng\n"
1639
+ "- 2-3 câu hoàn chỉnh\n"
1640
+ "\n"
1641
+ "Định dạng đầu ra:\n"
1642
+ "---SLIDE 1---\n"
1643
+ "[nội dung đoạn văn slide 1]\n"
1644
+ "---SLIDE 2---\n"
1645
+ "[n��i dung đoạn văn slide 2]\n"
1646
+ "...v.v...\n"
1647
+ "\n"
1648
+ "QUAN TRỌNG:\n"
1649
+ "- Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu\n"
1650
+ "- PHẢI KẾT THÚC BẰNG DẤU CHẤM (.) HOẢN TOÀN\n"
1651
+ "- Kết hợp QUAN ĐIỂM CÁ NHÂN với NỘI DUNG NGUỒN TIN\n"
1652
+ "- Không gạch đầu dòng, không bullet points\n"
1653
+ "- Viết liền mạch tự nhiên, giọng văn báo chí\n"
1654
+ "- Mỗi slide phải khác nhau, không lặp ý\n"
1655
+ "- Độ dài: 300-600 từ"
1656
+ )
1657
+ ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
1658
+ except:
1659
+ pass
1660
+
1661
+ if not ai_text or len(ai_text) < 100:
1662
+ # Fallback: build article manually
1663
+ ai_text = "## " + title + "\n\n"
1664
+ ai_text += opinion + "\n\n"
1665
+ for i, sd in enumerate(source_details[:5]):
1666
+ ai_text += "### " + sd.get("title", f"Nguồn {i+1}") + "\n"
1667
+ for p in sd.get("paragraphs", [])[:3]:
1668
+ ai_text += p[:250] + "\n"
1669
+ ai_text += "*Nguồn: " + sd.get("via", "") + "*\n\n"
1670
+ ai_text += "\n---\n*Bài viết tổng hợp từ quan điểm cá nhân và các nguồn tin liên quan*"
1671
+
1672
+ # Parse slides từ AI output (format: ---SLIDE N--- content)
1673
+ slides = []
1674
+ if ai_text:
1675
+ # Try to parse the ---SLIDE--- format
1676
+ pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
1677
+ matches = re.findall(pattern, ai_text, re.DOTALL)
1678
+
1679
+ if matches:
1680
+ for idx, (num, content) in enumerate(matches):
1681
+ # Normalize: ensure complete sentences
1682
+ text = _ensure_sentence_complete(content)
1683
+ if len(text) > 40:
1684
+ img = source_images[idx] if idx < len(source_images) else ""
1685
+ slides.append({"text": text, "image": img, "index": idx + 1})
1686
+
1687
+ # If we have parsed slides, ensure minimum 3
1688
+ if len(slides) < 3:
1689
+ # Use parsed slides as base, fill remaining from AI text
1690
+ used_indices = set()
1691
+ for s in slides:
1692
+ used_indices.add(s['index'] - 1)
1693
+
1694
+ # Split remaining AI text into more slides
1695
+ sentences = re.split(r'(?<=[.!?])\s+', ai_text)
1696
+ current_chunk = ""
1697
+ next_idx = len(slides)
1698
+
1699
+ for sent in sentences:
1700
+ sent = _ensure_sentence_complete(sent)
1701
+ if len(sent) < 20:
1702
+ continue
1703
+
1704
+ # Skip if this sentence is already in parsed slides
1705
+ found = False
1706
+ for slide in slides:
1707
+ if sent[:50] in slide['text']:
1708
+ found = True
1709
+ break
1710
+
1711
+ if found:
1712
+ continue
1713
+
1714
+ if current_chunk and len(current_chunk + " " + sent) <= 380:
1715
+ current_chunk += " " + sent
1716
+ else:
1717
+ if len(current_chunk) > 50:
1718
+ img = source_images[next_idx] if next_idx < len(source_images) else ""
1719
+ slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
1720
+ current_chunk = sent
1721
+ next_idx += 1
1722
+
1723
+ # Add final chunk
1724
+ if len(current_chunk) > 50 and next_idx < 6:
1725
+ img = source_images[next_idx] if next_idx < len(source_images) else ""
1726
+ slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
1727
+
1728
+ # Ultimate fallback: create slides from opinion + source
1729
+ if len(slides) < 2:
1730
+ slides = []
1731
+ # Slide 1: opinion
1732
+ if opinion and len(opinion) > 20:
1733
+ slides.append({"text": opinion[:450], "image": source_images[0] if source_images else "", "index": 1})
1734
+
1735
+ # Slide 2-6: from AI text or sources
1736
+ if ai_text:
1737
+ sentences = re.split(r'(?<=[.!?])\s+', ai_text)
1738
+ for i, sent in enumerate(sentences[:5]):
1739
+ text = _ensure_sentence_complete(_clean(sent))
1740
+ if len(text) > 60:
1741
+ if len(slides) < 6:
1742
+ img = source_images[len(slides)] if len(slides) < len(source_images) else ""
1743
+ slides.append({"text": text, "image": img, "index": len(slides) + 1})
1744
+
1745
+ # Fill remaining with key points from sources - KẾT HỢP VỚI QUAN ĐIỂM CÁ NHÂN
1746
+ src_idx = len(slides)
1747
+ while len(slides) < 4 and src_idx < len(source_details):
1748
+ paragraphs = source_details[src_idx].get("paragraphs", [])
1749
+ src_title = source_details[src_idx].get("title", "")
1750
+ src_via = source_details[src_idx].get("via", "")
1751
+ for p in paragraphs[:2]:
1752
+ if len(p) > 60 and len(slides) < 6:
1753
+ # Kết hợp opinion với nội dung source
1754
+ combined = f"Theo góc nhìn của tôi, {opinion[:100]}... Theo {src_via}: {p[:250]}"
1755
+ img = source_images[len(slides)] if len(slides) < len(source_images) else ""
1756
+ slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
1757
+ break # Mỗi nguồn 1 slide
1758
+ src_idx += 1
1759
+
1760
+ # Final fallback: ensure at least 2-3 slides
1761
+ while len(slides) < 3:
1762
+ idx = len(slides)
1763
+ if idx == 0 and opinion:
1764
+ slides.append({"text": opinion[:400], "image": "", "index": 1})
1765
+ elif ai_text:
1766
+ slides.append({"text": ai_text[idx*300:(idx+1)*300], "image": "", "index": idx + 1})
1767
+ else:
1768
+ slides.append({"text": f"Nguồn tham khảo {idx + 1}", "image": "", "index": idx + 1})
1769
+
1770
+ preview = {
1771
+ "title": title,
1772
+ "text": ai_text,
1773
+ "opinion": opinion,
1774
+ "images": source_images[:10],
1775
+ "sources": source_details[:5],
1776
+ "slides": slides[:6] # Max 6 slides
1777
+ }
1778
+
1779
+ return JSONResponse({"preview": preview})
1780
+
1781
+
1782
+ @app.post("/api/personal_post")
1783
+ async def api_personal_post(request: Request):
1784
+ """Create and save personal opinion post."""
1785
+ body = await request.json()
1786
+ opinion = _clean(body.get("opinion", ""))
1787
+ selected_topics = body.get("selected_topics", []) or []
1788
+ selected_sources = body.get("selected_sources", []) or []
1789
+ custom_title = body.get("custom_title", "")
1790
+ custom_slides = body.get("custom_slides", [])
1791
+
1792
+ if not opinion or len(opinion) < 10:
1793
+ return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
1794
+
1795
+ if not selected_topics:
1796
+ # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
1797
+ keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
1798
+ if keywords:
1799
+ selected_topics = keywords[:3]
1800
+ else:
1801
+ hot = _get_hot_topics()
1802
+ selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
1803
+
1804
+ all_sources = []
1805
+ seen_urls = set()
1806
+ for topic in selected_topics[:3]:
1807
+ sources = _search_all(topic, limit=5)
1808
+ for s in sources:
1809
+ if s.get("url") and s["url"] not in seen_urls:
1810
+ seen_urls.add(s["url"])
1811
+ all_sources.append(s)
1812
+ if len(all_sources) >= 6:
1813
+ break
1814
+ if len(all_sources) >= 6:
1815
+ break
1816
+
1817
+ for src in selected_sources:
1818
+ if src.get("url") and src["url"] not in seen_urls:
1819
+ all_sources.insert(0, src)
1820
+
1821
+ source_details = []
1822
+ source_images = []
1823
+ for src in all_sources[:5]:
1824
+ url = src.get("url", "")
1825
+ if not url:
1826
+ continue
1827
+ try:
1828
+ art = _scrape_article_for_rewrite(url)
1829
+ if art:
1830
+ src_detail = {
1831
+ "title": art.get("title", src.get("title", "")),
1832
+ "url": url,
1833
+ "via": src.get("via", ""),
1834
+ "paragraphs": art.get("paragraphs", [])[:6],
1835
+ "images": art.get("images", [])[:2],
1836
+ "og_image": art.get("og_img", "")
1837
+ }
1838
+ source_details.append(src_detail)
1839
+ for img in art.get("images", [])[:2]:
1840
+ if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
1841
+ img = "/api/proxy/img?url=" + _quote2(img, safe="")
1842
+ source_images.append(img)
1843
+ except:
1844
+ pass
1845
+
1846
+ # Title
1847
+ if custom_title:
1848
+ title = custom_title[:80]
1849
+ else:
1850
+ opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
1851
+ title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
1852
+ title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
1853
+ title = title[:80]
1854
+
1855
+ # AI sinh bài
1856
+ ai_text = None
1857
+ try:
1858
+ import ai_ext
1859
+ if hasattr(ai_ext, 'qwen_generate'):
1860
+ source_context = ""
1861
+ for i, sd in enumerate(source_details[:5]):
1862
+ src_title = sd.get("title", "")
1863
+ src_via = sd.get("via", "")
1864
+ src_paras = sd.get("paragraphs", [])
1865
+ source_context += f"\nNguồn {i+1}: {src_title} ({src_via})\n"
1866
+ for j, p in enumerate(src_paras[:3]):
1867
+ source_context += f" - {p[:300]}\n"
1868
+ prompt = (
1869
+ "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
1870
+ "=== NGUỒN TIN ===\n" + source_context + "\n\n"
1871
+ "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
1872
+ "Viết bài thành 5-6 ĐOẠN VĂN NG���N, mỗi đoạn là 1 SLIDE.\n"
1873
+ "\n"
1874
+ "SLIDE 1 - MỞ ĐẦU: Giới thiệu chủ đề, nêu quan điểm cá nhân (2-4 câu hoàn chỉnh)\n"
1875
+ "SLIDE 2-3-4-5 - PHÂN TÍCH: Mỗi slide dùng 1 nguồn tin cụ thể, kết hợp quan điểm cá nhân, ghi rõ nguồn (Theo VnExpress...), 2-4 câu hoàn chỉnh, thành 1 đoạn văn hoàn chỉnh\n"
1876
+ "SLIDE 6 - KẾT LUẬN: Tổng kết quan điểm, nhận định cuối cùng (2-3 câu hoàn chỉnh)\n"
1877
+ "\n"
1878
+ "Định dạng:\n"
1879
+ "---SLIDE 1---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n---SLIDE 2---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n...\n"
1880
+ "\n"
1881
+ "QUAN TRỌNG: Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu, PHẢI KẾT THÚC BẰNG DẤU CHẤM (.). Kết hợp QUAN ĐIỂM + NGUỒN TIN. Không gạch đầu dòng. Viết liền mạch. 300-600 từ."
1882
+ )
1883
+ ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
1884
+ except:
1885
+ pass
1886
+
1887
+ if not ai_text or len(ai_text) < 100:
1888
+ ai_text = "## " + title + "\n\n" + opinion + "\n\n"
1889
+ for i, sd in enumerate(source_details[:5]):
1890
+ ai_text += "### " + sd.get("title", "") + "\n"
1891
+ for p in sd.get("paragraphs", [])[:2]:
1892
+ ai_text += p[:250] + "\n"
1893
+ ai_text += "\n---\n*Nguồn: " + sd.get("via", "") + "*\n\n"
1894
+
1895
+ # Tạo slides
1896
+ if custom_slides and len(custom_slides) > 0:
1897
+ slides = []
1898
+ for i, slide in enumerate(custom_slides):
1899
+ slides.append({
1900
+ "text": slide.get("text", ""),
1901
+ "image": slide.get("image", ""),
1902
+ "index": i + 1
1903
+ })
1904
+ else:
1905
+ slides = []
1906
+ # Parse từ AI output (format: ---SLIDE N---)
1907
+ if ai_text:
1908
+ pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
1909
+ matches = re.findall(pattern, ai_text, re.DOTALL)
1910
+ if matches:
1911
+ for idx, (num, content) in enumerate(matches):
1912
+ # Normalize: ensure complete sentences
1913
+ text = _ensure_sentence_complete(content)
1914
+ if len(text) > 30:
1915
+ img = source_images[idx] if idx < len(source_images) else ""
1916
+ slides.append({"text": text, "image": img, "index": idx + 1})
1917
+
1918
+ # Fallback: split by paragraphs
1919
+ if len(slides) < 3:
1920
+ paragraphs = [p.strip() for p in re.split(r'\n\n+', ai_text) if p.strip()]
1921
+ slides = []
1922
+ para_count = 0
1923
+ for p in paragraphs:
1924
+ p = p.strip()
1925
+ if p.startswith('#') or p.startswith('---') or p.startswith('*Nguồn'):
1926
+ continue
1927
+ # Normalize: ensure complete sentences
1928
+ p_normalized = _ensure_sentence_complete(p)
1929
+ if len(p_normalized) > 50:
1930
+ img = source_images[para_count] if para_count < len(source_images) else ""
1931
+ slides.append({"text": p_normalized, "image": img, "index": para_count + 1})
1932
+ para_count += 1
1933
+ if para_count >= 6:
1934
+ break
1935
+
1936
+ if len(slides) < 2:
1937
+ slides = []
1938
+ # Slide 1: QUAN ĐIỂM CÁ NHÂN (BẮT BUỘC)
1939
+ slides.append({"text": f"Theo quan điểm cá nhân: {opinion[:400]}", "image": source_images[0] if source_images else "", "index": 1})
1940
+
1941
+ # Slide 2-6: KẾT HỢP QUAN ĐIỂM + SOURCE
1942
+ for i in range(min(5, len(source_details))):
1943
+ if len(slides) >= 6:
1944
+ break
1945
+ src = source_details[i]
1946
+ src_via = src.get("via", "")
1947
+ src_paras = src.get("paragraphs", [])
1948
+
1949
+ src_text = ""
1950
+ for p in src_paras[:2]:
1951
+ p = p.strip()[:280]
1952
+ if len(p) > 50:
1953
+ src_text = p
1954
+ break
1955
+
1956
+ if src_text:
1957
+ combined = f"Theo góc nhìn cá nhân, {opinion[:60]}. Theo {src_via}: {src_text}"
1958
+ img = source_images[len(slides)] if len(slides) < len(source_images) else (source_images[-1] if source_images else "")
1959
+ slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
1960
+
1961
+ lang, emotion = detect_language_and_emotion(title, ai_text)
1962
+ voice = get_voice_for_content(title, ai_text)
1963
+
1964
+ post = {
1965
+ "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1966
+ "title": title,
1967
+ "text": ai_text,
1968
+ "img": source_images[0] if source_images else "",
1969
+ "url": "",
1970
+ "kind": "personal_opinion",
1971
+ "slides": slides,
1972
+ "images": source_images[:10],
1973
+ "video": "",
1974
+ "voice": voice,
1975
+ "emotion": emotion,
1976
+ "language": lang,
1977
+ "ts": int(time.time()),
1978
+ "sources": source_details[:5]
1979
+ }
1980
+
1981
+ posts = _load_wall_posts()
1982
+ posts.insert(0, post)
1983
+ _save_wall_posts(posts)
1984
+
1985
+ return JSONResponse({"post": post, "slides": slides})
1986
+
1987
+
1988
+ # ===== END PERSONAL OPINION POST v2 =====
1989
+
1990
+ def _bg():
1991
+ time.sleep(15)
1992
+ while True:
1993
+ try:get_wc2026_all()
1994
+ except:pass
1995
+ time.sleep(90)
1996
+ threading.Thread(target=_bg,daemon=True).start()
1997
+
1998
+ # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1999
+ _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
2000
+ _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
2001
+
2002
+ def _load_auto_log():
2003
+ try:
2004
+ if os.path.exists(_AUTO_LOG):
2005
+ with open(_AUTO_LOG, 'r') as f:
2006
+ return json.load(f)
2007
+ except: pass
2008
+ return {}
2009
+
2010
+ def _save_auto_log(log):
2011
+ try:
2012
+ tmp = _AUTO_LOG + '.tmp'
2013
+ with open(tmp, 'w') as f:
2014
+ json.dump(log, f)
2015
+ os.replace(tmp, _AUTO_LOG)
2016
+ except: pass
2017
+
2018
+ async def _auto_fetch_short(post_id):
2019
+ """Try to auto-generate a short for a post."""
2020
+ try:
2021
+ import httpx
2022
+ async with httpx.AsyncClient(timeout=180) as cl:
2023
+ r = await cl.post(
2024
+ f"http://localhost:7860/api/ai/short/{post_id}",
2025
+ json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
2026
+ headers={"Content-Type":"application/json"}
2027
+ )
2028
+ if r.status_code < 300:
2029
+ sj = r.json()
2030
+ if sj.get('video'):
2031
+ posts = _load_wall_posts()
2032
+ for p in posts:
2033
+ if p.get('id') == post_id:
2034
+ p['video'] = sj['video']
2035
+ break
2036
+ _save_wall_posts(posts)
2037
+ return True
2038
+ except: pass
2039
+ return False
2040
+
2041
+ async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
2042
+ """Rewrite one topic: find articles, summarize, post to wall, trigger short.
2043
+ used_urls: shared set to avoid duplicate articles across topics.
2044
+ post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
2045
+ from urllib.parse import quote as _q
2046
+ # Get MORE items to support 1-3 posts per topic
2047
+ items = _search_all(topic, limit=12)
2048
+ # Skip URLs already used by another topic
2049
+ if used_urls is not None:
2050
+ filtered = [it for it in items if it.get('url') not in used_urls]
2051
+ if filtered:
2052
+ items = filtered
2053
+ if not items or post_index >= len(items):
2054
+ return False
2055
+
2056
+ # Get article at post_index (0,1,2 for multiple posts)
2057
+ item = items[post_index] # post_index allows multiple articles per topic
2058
+ url = item.get('url', '')
2059
+ title = item.get('title', topic)
2060
+ if url and used_urls is not None:
2061
+ used_urls.add(url)
2062
+ if not url.startswith('http'):
2063
+ return False
2064
+
2065
+ data = _scrape_article_for_rewrite(url)
2066
+ if not data or not data.get('paragraphs'):
2067
+ return False
2068
+
2069
+ raw_text = '\n'.join(data['paragraphs'])
2070
+ ai_text = None
2071
+
2072
+ # Try AI generation
2073
+ try:
2074
+ import ai_ext
2075
+ prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
2076
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
2077
+ except: pass
2078
+
2079
+ if not ai_text or len(ai_text) < 80:
2080
+ pts = data['paragraphs'][:6]
2081
+ ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
2082
+ via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
2083
+ ai_text += f"\n\nNguồn tham khảo: {via}"
2084
+
2085
+ # Build slides
2086
+ images = data.get('images', [])
2087
+ pts = data['paragraphs'][:10]
2088
+ slides = []
2089
+ for i, p in enumerate(pts[:8]):
2090
+ img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
2091
+ slides.append({'text': p[:300], 'image': img, 'index': i + 1})
2092
+
2093
+ post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
2094
+ post = {
2095
+ "id": post_id, "title": data.get('title', title)[:200],
2096
+ "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
2097
+ "url": url, "kind": "auto_rewrite", "slides": slides,
2098
+ "images": images[:10], "video": "",
2099
+ "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
2100
+ "language": "vietnamese", "ts": int(time.time()),
2101
+ "auto_scheduled": True, "slot": slot_label,
2102
+ }
2103
+
2104
+ posts = _load_wall_posts()
2105
+ posts.insert(0, post)
2106
+ _save_wall_posts(posts)
2107
+
2108
+ # Trigger short generation async
2109
+ threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
2110
+ return True
2111
+
2112
+ async def _do_scheduled_run(slot_label):
2113
+ """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
2114
+ print(f"[auto] Starting scheduled rewrite for {slot_label}")
2115
+
2116
+ # Get top hot topics, skip duplicates
2117
+ all_topics = _get_hot_topics()
2118
+ seen_topics = set()
2119
+ unique_topics = []
2120
+ for t in all_topics:
2121
+ kw = t.get('topic', '').lower().strip()
2122
+ if kw and len(kw) > 5 and kw not in seen_topics:
2123
+ is_dup = False
2124
+ for s in seen_topics:
2125
+ # Check if one topic is substring of another
2126
+ if kw in s or s in kw:
2127
+ is_dup = True
2128
+ break
2129
+ if not is_dup:
2130
+ seen_topics.add(kw)
2131
+ unique_topics.append(t)
2132
+ if len(unique_topics) >= 3:
2133
+ break
2134
+
2135
+ job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
2136
+ if not job_topics:
2137
+ print(f"[auto] No hot topics found, skipping")
2138
+ return
2139
+
2140
+ print(f"[auto] Running 3 topics: {job_topics}")
2141
+
2142
+ # Track used URLs to avoid cross-topic duplicates
2143
+ _used_urls = set()
2144
+ results = []
2145
+
2146
+ # Process each topic, create 1-3 posts per topic
2147
+ for jt in job_topics:
2148
+ for post_idx in range(3): # Try up to 3 posts per topic
2149
+ try:
2150
+ ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
2151
+ if ok:
2152
+ results.append((jt, post_idx, True))
2153
+ print(f"[auto] Created post {post_idx+1} for '{jt}'")
2154
+ else:
2155
+ # No more articles for this topic
2156
+ break
2157
+ except Exception as e:
2158
+ print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
2159
+ results.append((jt, post_idx, False))
2160
+ await asyncio.sleep(1) # Small delay between posts
2161
+
2162
+ # Ensure at least 3 posts total (fallback if needed)
2163
+ successful_posts = sum(1 for _, _, ok in results if ok)
2164
+ print(f"[auto] Done {slot_label}: {successful_posts} posts created")
2165
+
2166
+ # Log
2167
+ from datetime import datetime, timezone, timedelta
2168
+ VN_TZ_SCHED = timezone(timedelta(hours=7))
2169
+ today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
2170
+ log = _load_auto_log()
2171
+ if today_str not in log: log[today_str] = {}
2172
+ log[today_str][slot_label] = {
2173
+ 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
2174
+ 'count': successful_posts,
2175
+ 'total': len(job_topics),
2176
+ }
2177
+ _save_auto_log(log)
2178
+
2179
+ def _scheduler_loop():
2180
+ """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
2181
+ On startup, check for any missed slots today and run them immediately."""
2182
+ time.sleep(35)
2183
+ from datetime import datetime, timezone, timedelta
2184
+ VN_TZ_SCHED = timezone(timedelta(hours=7))
2185
+
2186
+ _last_run_date = ""
2187
+ _last_run_slots = set()
2188
+
2189
+ # On startup: check log for missed slots today
2190
+ try:
2191
+ start_now = datetime.now(VN_TZ_SCHED)
2192
+ today_str = start_now.strftime('%Y-%m-%d')
2193
+ current_hour = start_now.hour
2194
+ current_minute = start_now.minute
2195
+ log = _load_auto_log()
2196
+ today_log = log.get(today_str, {})
2197
+ for h, label in _AUTO_SCHEDULE_TIMES:
2198
+ # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
2199
+ should_run = False
2200
+ if h < current_hour:
2201
+ should_run = True
2202
+ elif h == current_hour and current_minute > 10:
2203
+ should_run = True
2204
+ if should_run and label not in today_log:
2205
+ print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
2206
+ _run_scheduled_sync(label)
2207
+ _last_run_slots.add(label)
2208
+ except Exception as e:
2209
+ print(f"[auto] Catch-up check error: {e}")
2210
+
2211
+ while True:
2212
+ try:
2213
+ now = datetime.now(VN_TZ_SCHED)
2214
+ today = now.strftime('%Y-%m-%d')
2215
+ hour = now.hour
2216
+ minute = now.minute
2217
+
2218
+ if today != _last_run_date:
2219
+ _last_run_date = today
2220
+ _last_run_slots = set()
2221
+
2222
+ slot = None
2223
+ for h, label in _AUTO_SCHEDULE_TIMES:
2224
+ if hour == h and 0 <= minute < 5:
2225
+ slot = label
2226
+ break
2227
+
2228
+ if slot and slot not in _last_run_slots:
2229
+ _last_run_slots.add(slot)
2230
+ _run_scheduled_sync(slot)
2231
+ except Exception as e:
2232
+ print(f"[auto] Loop error: {e}")
2233
+
2234
+ time.sleep(60)
2235
+
2236
+ threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
2237
+
2238
+ @app.get('/api/debug/auto_schedule')
2239
+ async def debug_auto_schedule(slot: str = '07:00'):
2240
+ """Manually trigger auto scheduler for debugging."""
2241
+ try:
2242
+ # Check if we can access the data directory
2243
+ log = _load_auto_log()
2244
+ topics = _get_hot_topics()[:3]
2245
+ job_topics = [t['topic'] for t in topics if t.get('topic')]
2246
+ return JSONResponse({
2247
+ "slot": slot,
2248
+ "log": log,
2249
+ "hot_topics": job_topics,
2250
+ "wall_posts_count": len(_load_wall_posts()),
2251
+ "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
2252
+ "data_dir_exists": os.path.isdir(DATA_DIR),
2253
+ })
2254
+ except Exception as e:
2255
+ return JSONResponse({"error": str(e)}, status_code=500)
2256
+
2257
+ def _run_scheduled_sync(slot):
2258
+ """Run _do_scheduled_run in a separate event loop (for background thread)."""
2259
+ loop = asyncio.new_event_loop()
2260
+ asyncio.set_event_loop(loop)
2261
+ try:
2262
+ loop.run_until_complete(_do_scheduled_run(slot))
2263
+ except Exception as e:
2264
+ print(f"[auto] Background run error: {e}")
2265
+ finally:
2266
+ loop.close()
2267
+
2268
+ @app.get('/api/debug/trigger_auto')
2269
+ async def debug_trigger_auto(slot: str = '19:00'):
2270
+ """Trigger _do_scheduled_run in background thread (non-blocking)."""
2271
+ threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
2272
+ return JSONResponse({"status": "started", "slot": slot})
2273
+
2274
+ # ===== SHORTS RSS PROXY ENDPOINT =====
2275
+ @app.get("/api/shorts/rss")
2276
+ def shorts_rss():
2277
+ """Get shorts from YouTube RSS feeds server-side"""
2278
+ import xml.etree.ElementTree as ET
2279
+ import html as html_lib2
2280
+ import re as re2
2281
+
2282
+ YOUTUBE_CHANNELS = {
2283
+ "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
2284
+ "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
2285
+ }
2286
+
2287
+ shorts = []
2288
+ seen = set()
2289
+
2290
+ for handle, channel_id in YOUTUBE_CHANNELS.items():
2291
+ try:
2292
+ rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
2293
+ r = req.get(rss_url, headers=HEADERS, timeout=15)
2294
+ if r.status_code != 200:
2295
+ continue
2296
+
2297
+ root = ET.fromstring(r.text)
2298
+ ns = {
2299
+ 'atom': 'http://www.w3.org/2005/Atom',
2300
+ 'yt': 'http://www.youtube.com/xml/schemas/2015',
2301
+ 'media': 'http://search.yahoo.com/mrss/'
2302
+ }
2303
+
2304
+ for entry in root.findall('atom:entry', ns)[:30]:
2305
+ title_el = entry.find('atom:title', ns)
2306
+ title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
2307
+
2308
+ link_el = entry.find('atom:link', ns)
2309
+ link = link_el.get('href', '') if link_el is not None else ''
2310
+
2311
+ vid_el = entry.find('yt:videoId', ns)
2312
+ vid = vid_el.text if vid_el is not None else ''
2313
+
2314
+ if not vid or vid in seen:
2315
+ continue
2316
+
2317
+ # Check if it's a short
2318
+ is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
2319
+
2320
+ if not is_short:
2321
+ desc_el = entry.find('media:description', ns)
2322
+ if desc_el is not None and desc_el.text:
2323
+ if '#shorts' in desc_el.text.lower():
2324
+ is_short = True
2325
+
2326
+ if not is_short:
2327
+ continue
2328
+
2329
+ seen.add(vid)
2330
+
2331
+ # Get thumbnail
2332
+ thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
2333
+ media_group = entry.find('media:group', ns)
2334
+ if media_group is not None:
2335
+ thumb_el = media_group.find('media:thumbnail', ns)
2336
+ if thumb_el is not None:
2337
+ thumb = thumb_el.get('url', thumb)
2338
+
2339
+ shorts.append({
2340
+ 'id': vid,
2341
+ 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
2342
+ 'img': thumb,
2343
+ 'link': f'https://www.youtube.com/shorts/{vid}',
2344
+ 'channel': handle,
2345
+ 'source': 'yt'
2346
+ })
2347
+
2348
+ if len(shorts) >= 40:
2349
+ break
2350
+
2351
+ except Exception as e:
2352
+ print(f"RSS error for {handle}: {e}")
2353
+ continue
2354
+
2355
+ return {"shorts": shorts, "count": len(shorts)}
2356
+
2357
+ app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')