bep40 commited on
Commit
b8db170
·
verified ·
1 Parent(s): 0e48985

Restore ai_ext.py from c93b544 - AI extension endpoints

Browse files
Files changed (1) hide show
  1. ai_ext.py +571 -252
ai_ext.py CHANGED
@@ -77,64 +77,46 @@ HEADERS = {
77
  LAST_QWEN_ERROR = ""
78
 
79
  # ===== TTS VOICE CONFIG =====
80
- # Multilingual neural voices grouped by country/language
81
- # Format: key -> {id, gender, name, country, lang, flag}
82
  TTS_VOICES = {
83
- # === VIETNAM ===
84
  "hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
85
  "namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
86
- # === USA (English) ===
87
  "en_us_f": {"id": "en-US-AvaNeural", "gender": "female", "name": "Ava", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
88
  "en_us_m": {"id": "en-US-BrianNeural", "gender": "male", "name": "Brian", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
89
- # === UK (English) ===
90
  "en_gb_f": {"id": "en-GB-SoniaNeural", "gender": "female", "name": "Sonia", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
91
  "en_gb_m": {"id": "en-GB-RyanNeural", "gender": "male", "name": "Ryan", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
92
- # === CHINA (Mandarin) ===
93
  "zh_cn_f": {"id": "zh-CN-XiaoxiaoNeural", "gender": "female", "name": "Xiaoxiao", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
94
  "zh_cn_m": {"id": "zh-CN-YunyangNeural", "gender": "male", "name": "Yunyang", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
95
  "zh_hk_f": {"id": "zh-HK-HiuGaaiNeural", "gender": "female", "name": "HiuGaai", "country": "Hồng Kông", "lang": "zh", "flag": "🇭🇰"},
96
- # === JAPANESE ===
97
  "ja_jp_f": {"id": "ja-JP-NanamiNeural", "gender": "female", "name": "Nanami", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
98
  "ja_jp_m": {"id": "ja-JP-KeitaNeural", "gender": "male", "name": "Keita", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
99
- # === KOREAN ===
100
  "ko_kr_f": {"id": "ko-KR-SunHiNeural", "gender": "female", "name": "Sun-Hi", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
101
  "ko_kr_m": {"id": "ko-KR-InJoonNeural", "gender": "male", "name": "InJoon", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
102
- # === FRENCH ===
103
  "fr_fr_f": {"id": "fr-FR-DeniseNeural", "gender": "female", "name": "Denise", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
104
  "fr_fr_m": {"id": "fr-FR-HenriNeural", "gender": "male", "name": "Henri", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
105
- # === GERMAN ===
106
  "de_de_f": {"id": "de-DE-KatjaNeural", "gender": "female", "name": "Katja", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
107
  "de_de_m": {"id": "de-DE-KillianNeural", "gender": "male", "name": "Killian", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
108
- # === SPANISH ===
109
  "es_es_f": {"id": "es-ES-XimenaNeural", "gender": "female", "name": "Ximena", "country": "Tây Ban Nha", "lang": "es", "flag": "🇪🇸"},
110
  "es_mx_m": {"id": "es-MX-JorgeNeural", "gender": "male", "name": "Jorge", "country": "Mexico", "lang": "es", "flag": "🇲🇽"},
111
- # === THAI ===
112
  "th_th_f": {"id": "th-TH-PremwadeeNeural", "gender": "female", "name": "Premwadee", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
113
  "th_th_m": {"id": "th-TH-NiwatNeural", "gender": "male", "name": "Niwat", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
114
- # === HINDI ===
115
  "hi_in_f": {"id": "hi-IN-SwaraNeural", "gender": "female", "name": "Swara", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
116
  "hi_in_m": {"id": "hi-IN-MadhurNeural", "gender": "male", "name": "Madhur", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
117
  }
118
  TTS_DEFAULT_VOICE = "hoaimy"
119
- TTS_DEFAULT_SPEED = 1.2 # 1.2x speed for faster reading
120
 
121
- # Topic → voice mapping (auto-detect based on topic keywords)
122
  TOPIC_VOICE_MAP = {
123
- # Sports → male voice
124
  "bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
125
  "premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
126
  "serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
127
  "tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
128
- # Lifestyle/Health/Entertainment → female voice
129
  "sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
130
  "âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
131
  "ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
132
  "tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
133
- # Tech/Science → male voice
134
  "công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
135
  "khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
136
  "laptop": "namminh", "game": "namminh",
137
- # News/Politics/Economy → male voice
138
  "chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
139
  "chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
140
  "xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
@@ -170,13 +152,11 @@ def _clean_ai_output(text: str) -> str:
170
  """Remove markdown artifacts, instruction leakage, and aggressively dedup content."""
171
  if not text:
172
  return ""
173
- # Remove markdown headings, bold, italic, horizontal rules
174
  text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
175
  text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
176
  text = re.sub(r'\*([^*]+)\*', r'\1', text)
177
  text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
178
  text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
179
- # Remove common AI instruction leakage phrases (entire line)
180
  leakage = [
181
  r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
182
  r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
@@ -188,9 +168,10 @@ def _clean_ai_output(text: str) -> str:
188
  for phrase in leakage:
189
  text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
190
  text = re.sub(r'\n{3,}', '\n\n', text)
191
- # --- Aggressive dedup: split into sentences, remove any that repeat ---
192
  def _norm(s):
193
  return re.sub(r'\s+', ' ', s.strip().lower())
 
194
  raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
195
  seen_sentences = set()
196
  unique_parts = []
@@ -204,7 +185,9 @@ def _clean_ai_output(text: str) -> str:
204
  partial = re.sub(r'\W+', '', n)
205
  for seen in seen_sentences:
206
  seen_clean = re.sub(r'\W+', '', seen)
207
- if partial and seen_clean and (partial in seen_clean or seen_clean in partial):
 
 
208
  shorter = min(len(partial), len(seen_clean))
209
  longer = max(len(partial), len(seen_clean))
210
  if shorter > 20 and shorter / longer > 0.75:
@@ -215,6 +198,7 @@ def _clean_ai_output(text: str) -> str:
215
  if n:
216
  seen_sentences.add(n)
217
  unique_parts.append(part)
 
218
  result = ' '.join(unique_parts).strip()
219
  lines = result.split('\n')
220
  final_lines = []
@@ -232,326 +216,661 @@ def _clean_ai_output(text: str) -> str:
232
  # ===== EXTRACT ALL IMAGES FROM ARTICLE =====
233
  def _extract_all_images(soup, base_url: str) -> List[Dict]:
234
  """Extract ALL content images from an article page using multi-strategy approach."""
235
- images = []; seen_urls = set()
236
- skip_patterns = ["avatar","icon","logo","button","banner-ad","tracking","beacon","pixel","1x1","spacer","emoji","sprite","placeholder","advertisement","ads","widget","sidebar","footer-logo"]
 
 
 
 
 
 
237
  def _add_image(src: str, alt: str = "", source_tag: str = "img"):
238
- if not src or src.startswith("data:"): return
 
239
  abs_url = urljoin(base_url, src.strip())
240
- if abs_url in seen_urls: return
241
- if any(p in abs_url.lower() for p in skip_patterns): return
 
 
242
  try:
243
- parsed = urlparse(abs_url); path = parsed.path.lower()
244
- if any(path.endswith(ext) for ext in ['.svg','.ico','.gif']): return
245
- except Exception: pass
246
- seen_urls.add(abs_url); images.append({"url":abs_url,"alt":alt,"source":source_tag})
 
 
 
 
 
247
  for img in soup.find_all("img"):
248
- src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or img.get("data-original") or img.get("data-srcset","").split(",")[0].strip().split(" ")[0])
249
- _add_image(src, alt=img.get("alt",""), source_tag="img")
 
 
250
  for img in soup.find_all("img", srcset=True):
251
  for part in img["srcset"].split(","):
252
  part = part.strip()
253
- if part: _add_image(part.split(" ")[0], alt=img.get("alt",""), source_tag="srcset")
 
 
254
  for picture in soup.find_all("picture"):
255
  for source in picture.find_all("source"):
256
- srcset = source.get("srcset","")
257
  for part in srcset.split(","):
258
  part = part.strip()
259
- if part: _add_image(part.split(" ")[0], source_tag="picture/srcset")
 
260
  fallback_img = picture.find("img")
261
- if fallback_img: _add_image(fallback_img.get("src") or fallback_img.get("data-src"), alt=fallback_img.get("alt",""), source_tag="picture/img")
 
 
 
 
 
 
262
  for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
263
- _add_image(img.get("data-src") or img.get("src"), alt=img.get("alt",""), source_tag="wp-image")
 
 
264
  for tag in soup.find_all(style=re.compile(r"background-image")):
265
- for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style","")): _add_image(m, source_tag="background-style")
 
 
266
  og_image = soup.find("meta", property="og:image")
267
- if og_image and og_image.get("content"): _add_image(og_image["content"], source_tag="og:image")
268
- tw_image = soup.find("meta", attrs={"name":"twitter:image"})
269
- if tw_image and tw_image.get("content"): _add_image(tw_image["content"], source_tag="twitter:image")
 
 
 
 
270
  for figure in soup.find_all("figure"):
271
  img = figure.find("img")
272
  if img:
273
  src = img.get("data-src") or img.get("src")
274
  figcaption = figure.find("figcaption")
275
- alt = figcaption.get_text(strip=True) if figcaption else img.get("alt","")
276
  _add_image(src, alt=alt, source_tag="figure")
 
277
  for a in soup.find_all("a", href=True):
278
  href = a["href"]
279
- if any(href.lower().endswith(ext) for ext in [".jpg",".jpeg",".png",".webp",".gif"]): _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
 
 
280
  return images
281
 
 
282
  # ===== JINA READER =====
283
  def _reader_url(target_url: str) -> str:
284
  safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
285
  return "https://r.jina.ai/http://" + safe
 
286
  def jina_reader_markdown(url: str) -> str:
287
  jr = _reader_url(url)
288
- r = requests.get(jr, headers={"Accept":"text/markdown,text/plain,*/*","X-Return-Format":"markdown","User-Agent":"Mozilla/5.0"}, timeout=35)
289
- r.raise_for_status(); return r.text or ""
 
 
290
  def _parse_jina_markdown(md: str, url: str):
291
- lines=[x.rstrip() for x in (md or "").splitlines()]; title=""; first_image=""; all_images=[]; content_lines=[]; in_content=False
 
292
  for ln in lines:
293
- if ln.startswith("Title:") and not title: title=_clean_text(ln.replace("Title:","",1)); continue
294
- if ln.startswith("URL Source:"): continue
295
- if ln.startswith("Markdown Content:"): in_content=True; continue
296
- for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)',ln):
297
- img_url=mimg.group(1)
298
- if img_url not in all_images: all_images.append(img_url)
299
- if not first_image: first_image=img_url
 
 
 
 
 
300
  if in_content or (title and not ln.startswith("Title:")):
301
- if ln.strip(): content_lines.append(ln)
302
- text="\n".join(content_lines); text=re.sub(r'!\[[^\]]*\]\([^)]+\)','',text); paras=[]
303
- for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)',text):
304
- t=_clean_text(re.sub(r'^#{1,6}\s*','',part))
305
- if len(t)>=40: paras.append(t)
306
- if len(paras)>=35: break
307
- if not title and paras: title=paras[0][:90]
308
- return {"url":url,"title":title or url,"summary":paras[0] if paras else "","text":"\n".join(paras),"image":first_image,"images":all_images,"via":"jina"}
309
-
310
- # ===== WEB SCRAPE =====
 
 
 
 
 
 
 
 
 
311
  def _best_content_block(soup):
312
- best,best_score=None,0
313
- for el in soup.find_all(["article","main","section","div"]):
314
- ps=el.find_all("p"); txt=" ".join(p.get_text(" ",strip=True) for p in ps); score=len(ps)*100+len(txt)
315
- cls=" ".join(el.get("class",[]))
316
- if any(k in cls.lower() for k in ["content","article","detail","body","post","entry"]): score+=800
317
- if score>best_score: best,best_score=el,score
 
 
 
 
318
  return best
 
319
  def scrape_any_url_direct(url: str):
320
- r=requests.get(url,headers=HEADERS,timeout=18)
321
- if r.status_code in {401,403,406,409,429,451,503}: raise RuntimeError(f"blocked status {r.status_code}")
322
- r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml")
323
- for tag in soup.find_all(["script","style","nav","footer","aside","form","noscript"]): tag.decompose()
324
- title=soup.find("h1").get_text(" ",strip=True) if soup.find("h1") else ""
 
 
 
 
325
  if not title:
326
- ogt=soup.find("meta",property="og:title") or soup.find("meta",attrs={"name":"title"})
327
- title=ogt.get("content","") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
328
- desc_tag=soup.find("meta",property="og:description") or soup.find("meta",attrs={"name":"description"})
329
- summary=desc_tag.get("content","") if desc_tag else ""
330
- img_tag=soup.find("meta",property="og:image") or soup.find("meta",attrs={"name":"twitter:image"})
331
- image=img_tag.get("content","") if img_tag else ""
332
- if image and image.startswith("//"): image="https:"+image
333
- all_images=_extract_all_images(soup,url); image_urls=[img["url"] for img in all_images]
334
- if image and image not in image_urls: image_urls.insert(0,image)
335
- elif image in image_urls: image_urls.remove(image); image_urls.insert(0,image)
336
- block=_best_content_block(soup) or soup; paras,seen_p=[],set()
 
 
 
 
 
 
 
 
 
 
 
337
  for p in block.find_all("p"):
338
- t=_clean_text(p.get_text(" ",strip=True))
339
- if len(t)>=40 and t not in seen_p: seen_p.add(t); paras.append(t)
340
- if len(paras)>=35: break
341
- if not title and paras: title=paras[0][:90]
342
- return {"url":url,"title":title or url,"summary":paras[0] if paras else "","text":"\n".join(paras),"image":image_urls[0] if image_urls else "","images":image_urls,"via":_domain(url)}
 
 
 
 
 
 
 
 
 
 
 
343
  def scrape_any_url(url: str):
344
- data=scrape_any_url_direct(url); raw_text=(data.get("summary","")+"\n"+data.get("text","")).strip()
345
- if len(raw_text)>=120: return data
 
 
 
346
  try:
347
- md=jina_reader_markdown(url)
348
  if md:
349
- jr=_parse_jina_markdown(md,url)
350
  if jr.get("text"):
351
- if data.get("title") and data["title"]!=url: jr["title"]=data["title"]
352
- if data.get("image"): jr["image"]=data["image"]
353
- if data.get("images"): jr["images"]=data["images"]
354
- jr["via"]=data.get("via",_domain(url))+" + jina"; return jr
355
- except Exception: pass
 
 
 
 
 
356
  return data
357
 
 
 
358
  def pollinations_image_url(topic: str) -> str:
359
- prompt="editorial illustration, Vietnamese news, "+topic
360
- return "https://image.pollinations.ai/prompt/"+quote(prompt,safe="")+"?width=1024&height=576&nologo=true"
 
361
 
 
362
  async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, image_urls: Optional[List[str]] = None):
363
- global LAST_QWEN_ERROR, HF_TOKEN; HF_TOKEN=_hf_token()
364
- if not HF_TOKEN: LAST_QWEN_ERROR="Không tìm thấy token"; return None
365
- if not AsyncInferenceClient: LAST_QWEN_ERROR="Thiếu huggingface_hub"; return None
366
- errors=[]; models=[]
367
- for m in [QWEN_VL_MODEL,"Qwen/Qwen2.5-VL-7B-Instruct","Qwen/Qwen2.5-VL-3B-Instruct"]:
368
- if m and m not in models: models.append(m)
 
 
 
 
 
 
369
  for model in models:
370
  try:
371
- client=AsyncInferenceClient(provider="auto",api_key=HF_TOKEN,timeout=90); content=[]
372
- all_img_urls=[]
373
- if image_urls: all_img_urls=image_urls[:6]
374
- elif image_url: all_img_urls=[image_url]
 
 
 
375
  for img_u in all_img_urls:
376
- if img_u and img_u.startswith("http"): content.append({"type":"image_url","image_url":{"url":img_u}})
377
- content.append({"type":"text","text":prompt})
378
- messages=[{"role":"system","content":"Bạn là biên tập viên báo điện tử tiếng Việt. NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. QUY TẮC CỨNG: (1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. (2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. (3) KHÔNG dùng Markdown (##, **, ---, *). (4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. (5) KHÔNG bịa thông tin ngoài nguồn. (6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. (7) Tối đa 200 từ. Ngắn gọn, súc tích."},{"role":"user","content":content}]
379
- resp=await client.chat_completion(model=model,messages=messages,max_tokens=max_tokens,temperature=0.3,top_p=0.8)
380
- txt=(resp.choices[0].message.content or "").strip()
381
- if txt: LAST_QWEN_ERROR=""; return txt
382
- except Exception as e: errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
383
- LAST_QWEN_ERROR=" | ".join(errors) or "Qwen không trả nội dung."; print("[qwen errors]",LAST_QWEN_ERROR); return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
 
385
  async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str):
386
- if edge_tts is None: raise RuntimeError("edge-tts chưa cài đặt")
387
- voice=TTS_VOICES.get(voice_id,TTS_VOICES[TTS_DEFAULT_VOICE])["id"]; pct=int((speed-1.0)*100); rate=f"+{pct}%" if pct>=0 else f"{pct}%"
388
- communicate=edge_tts.Communicate(text,voice,rate=rate); await communicate.save(out_path)
 
 
 
 
 
389
  def _generate_tts_gtts(text: str, out_path: str):
390
- if gTTS is None: raise RuntimeError("gTTS chưa cài đặt")
391
- gTTS(text,lang="vi").save(out_path)
 
 
392
 
393
- def _download_image(url,fallback_topic,out_path):
 
394
  if url:
395
  try:
396
- r=requests.get(url,headers=HEADERS,timeout=15)
397
- if r.status_code==200 and len(r.content)>1000:
398
- with open(out_path,"wb") as f: f.write(r.content)
 
399
  return out_path
400
- except Exception: pass
401
- gen=pollinations_image_url(fallback_topic)
 
402
  try:
403
- r=requests.get(gen,headers=HEADERS,timeout=25)
404
- if r.status_code==200 and len(r.content)>1000:
405
- with open(out_path,"wb") as f: f.write(r.content)
 
406
  return out_path
407
- except Exception: pass
408
- if Image: Image.new("RGB",(1080,860),(30,55,42)).save(out_path); return out_path
 
 
 
409
  raise RuntimeError("Không tạo được ảnh")
410
- def _make_short_frame(post,img_path,out_path):
411
- if Image is None: raise RuntimeError("Pillow chưa sẵn sàng")
412
- W,H=1080,1920; bg=Image.new("RGB",(W,H),(14,14,14))
 
 
 
413
  try:
414
- im=Image.open(img_path).convert("RGB"); target=(1080,860); im_ratio=im.width/im.height; target_ratio=target[0]/target[1]
415
- if im_ratio>target_ratio: new_h=target[1]; new_w=int(new_h*im_ratio)
416
- else: new_w=target[0]; new_h=int(new_w/im_ratio)
417
- im=im.resize((new_w,new_h)); left=(new_w-target[0])//2; top=(new_h-target[1])//2; im=im.crop((left,top,left+target[0],top+target[1])); bg.paste(im,(0,0))
418
- except Exception: pass
419
- draw=ImageDraw.Draw(bg)
420
- try: font_title=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",52); font_body=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",40); font_label=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",32)
421
- except Exception: font_title=font_body=font_label=None
422
- draw.rectangle((0,780,W,H),fill=(14,14,14)); draw.text((54,830),"VNEWS · Tường AI",fill=(92,184,122),font=font_label)
423
- title=post.get("title",""); words=title.split(); lines_t=[]; cur=""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  for w in words:
425
- if len(cur)+len(w)+1<=24: cur=(cur+" "+w).strip()
 
426
  else:
427
  if cur: lines_t.append(cur)
428
- cur=w
429
  if cur: lines_t.append(cur)
430
- draw.multiline_text((54,900),"\n".join(lines_t[:3]),fill=(255,255,255),font=font_title,spacing=10)
431
- body_text=post.get("text",""); words_b=body_text.split(); lines_b=[]; cur_b=""
 
432
  for w in words_b:
433
- if len(cur_b)+len(w)+1<=34: cur_b=(cur_b+" "+w).strip()
 
434
  else:
435
  if cur_b: lines_b.append(cur_b)
436
- cur_b=w
437
- if len(lines_b)>=10: break
438
- if cur_b and len(lines_b)<10: lines_b.append(cur_b)
439
- draw.multiline_text((54,1120),"\n".join(lines_b),fill=(220,220,220),font=font_body,spacing=12); bg.save(out_path,quality=92)
440
- def _short_script(post,max_chars=700):
441
- txt=_clean_text(post.get("text",""))
442
- if len(txt)>max_chars: txt=txt[:max_chars].rsplit(" ",1)[0]+"."
443
- title=_clean_text(post.get("title","")); return f"{title}. {txt}"
444
- async def _generate_short_video(post,post_id:str,voice_id:str=None,speed:float=None)->str:
 
 
 
 
 
 
445
  try:
446
- os.makedirs(SHORTS_DIR,exist_ok=True); out_mp4=os.path.join(SHORTS_DIR,_safe_name(post_id)+".mp4")
447
- if os.path.exists(out_mp4): return "/api/ai/short-file/"+post_id
448
- work=os.path.join(SHORTS_DIR,_safe_name(post_id)); os.makedirs(work,exist_ok=True)
449
- img_path=os.path.join(work,"image.jpg"); frame_path=os.path.join(work,"frame.jpg"); audio_path=os.path.join(work,"voice.mp3")
450
- _download_image(post.get("img"),post.get("title","AI news"),img_path); _make_short_frame(post,img_path,frame_path); script=_short_script(post)
451
- if voice_id is None: voice_id=_detect_voice_for_topic(post.get("title",""),post.get("text",""))
452
- if speed is None: speed=TTS_DEFAULT_SPEED
453
- try: await _generate_tts_edge(script,voice_id,speed,audio_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  except Exception as e:
455
  print(f"[TTS edge-tts error] {e}, falling back to gTTS")
456
- if gTTS: _generate_tts_gtts(script,audio_path)
457
- else: return ""
458
- cmd=["ffmpeg","-y","-loop","1","-i",frame_path,"-i",audio_path,"-shortest","-c:v","libx264","-tune","stillimage","-pix_fmt","yuv420p","-c:a","aac","-b:a","128k","-vf","scale=1080:1920",out_mp4]
459
- subprocess.run(cmd,check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180); return "/api/ai/short-file/"+post_id
460
- except Exception as e: print(f"[short video error] {e}"); return ""
461
- def make_post(title,text,image,source_url,kind,sources=None,images=None):
462
- return {"id":str(int(time.time()*1000))+str(random.randint(100,999)),"title":title,"text":text,"img":image,"url":source_url,"kind":kind,"sources":sources or [],"video":"","images":images or [],"ts":int(time.time())}
463
-
464
- def _build_rewrite_prompt(title:str,raw:str,images:List[str]=None)->str:
465
- image_info=""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  if images:
467
- num=len(images)
468
- if num==1: image_info="\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
469
- else: image_info=f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
470
- return f"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI.\n\nQUY TẮC BẮT BUỘC:\n1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc.\n2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.\n3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.\n4. KHÔNG dùng Markdown (##, **, ---, *).\n5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt".\n6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.\n7. Giữ sự thật, KHÔNG bịa thông tin.\n8. Tối đa 200 từ. Ngắn gọn, đủ ý.{image_info}\n\nTiêu đề gốc: {title}\n\nNội dung gốc:\n{raw[:14000]}"""
471
- def _build_topic_prompt(topic:str,ctx:str)->str:
472
- return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".\n\nQUY TẮC BẮT BUỘC:\n1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên văn.\n2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.\n3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.\n4. KHÔNG dùng Markdown (##, **, ---, *).\n5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".\n6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points.\n7. Giữ sự thật, KHÔNG bịa.\n8. Tối đa 200 từ. Ngắn gọn, đủ ý.\n\nNguồn thực tế:\n{ctx[:12000]}"""
 
 
 
 
 
 
 
 
 
 
 
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  @app.post("/api/rewrite_share")
475
- async def api_rewrite_share(request:Request):
476
- body=await request.json(); url=_clean_text(body.get("url",""))
477
- if not url.startswith("http"): return JSONResponse({"error":"missing url"},status_code=400)
478
- try: data=scrape_any_url(url)
479
- except Exception as e: return JSONResponse({"error":"Không đọc được bài viết: "+str(e)[:180]},status_code=422)
480
- raw=(data.get("summary","")+"\n"+data.get("text","")).strip()
481
- if len(raw)<60: return JSONResponse({"error":"Bài viết quá ngắn để tóm tắt"},status_code=422)
482
- images=data.get("images",[]); prompt=_build_rewrite_prompt(data.get("title",""),raw,images)
483
- text=await qwen_generate(prompt,image_urls=images if images else None,max_tokens=500)
484
- if not text: return JSONResponse({"error":"Qwen2.5-VL chưa sẵn sàng: "+LAST_QWEN_ERROR},status_code=503)
485
- text=_clean_ai_output(text); post=make_post(data.get("title") or "Bài viết",text,images[0] if images else data.get("image",""),url,"rewrite",images=images)
486
- video_url=await _generate_short_video(post,post["id"])
487
- if video_url: post["video"]=video_url
488
- posts=_load_wall(); posts.insert(0,post); _save_wall(posts); return JSONResponse({"post":post})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
 
490
  @app.post("/api/url_wall")
491
- async def api_url_wall(request:Request):
492
- body=await request.json(); url=_clean_text(body.get("url",""))
493
- if not url.startswith("http"): return JSONResponse({"error":"missing url"},status_code=400)
494
- try: data=scrape_any_url(url)
495
- except Exception as e: return JSONResponse({"error":"Không scrape được URL: "+str(e)[:180]},status_code=422)
496
- raw=(data.get("summary","")+"\n"+data.get("text","")).strip()
497
- if len(raw)<60: return JSONResponse({"error":"URL không có đủ nội dung"},status_code=422)
498
- images=data.get("images",[]); prompt=_build_rewrite_prompt(data.get("title",""),raw,images)
499
- text=await qwen_generate(prompt,image_urls=images if images else None,max_tokens=500)
500
- if not text: return JSONResponse({"error":"Qwen2.5-VL chưa sẵn sàng: "+LAST_QWEN_ERROR},status_code=503)
501
- text=_clean_ai_output(text); post=make_post(data.get("title") or "Bài viết",text,images[0] if images else data.get("image",""),url,"url",images=images)
502
- video_url=await _generate_short_video(post,post["id"])
503
- if video_url: post["video"]=video_url
504
- posts=_load_wall(); posts.insert(0,post); _save_wall(posts); return JSONResponse({"post":post})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
 
506
  @app.post("/api/topic_post")
507
- async def api_topic_post(request:Request):
508
- body=await request.json(); topic=_clean_text(body.get("topic",""))
509
- if not topic: return JSONResponse({"error":"missing topic"},status_code=400)
510
- ctx=_web_context(topic)
511
- if not ctx: return JSONResponse({"error":"Không lấy được dữ liệu cho chủ đề này"},status_code=422)
512
- image=pollinations_image_url(topic); prompt=_build_topic_prompt(topic,ctx)
513
- text=await qwen_generate(prompt,image_url=image,max_tokens=500)
514
- if not text: return JSONResponse({"error":"Qwen2.5-VL chưa sẵn sàng: "+LAST_QWEN_ERROR},status_code=503)
515
- text=_clean_ai_output(text); post=make_post(topic,text,image,"","topic")
516
- video_url=await _generate_short_video(post,post["id"])
517
- if video_url: post["video"]=video_url
518
- posts=_load_wall(); posts.insert(0,post); _save_wall(posts); return JSONResponse({"post":post})
 
 
 
 
 
519
 
 
 
 
 
 
 
 
 
 
 
 
520
  @app.get("/api/ai_wall")
521
- def api_ai_wall(): return JSONResponse({"posts":_load_wall()[:80]})
 
 
522
  @app.get("/api/wall")
523
- def api_wall(): return JSONResponse({"posts":_load_wall()[:80]})
 
 
524
 
 
525
  @app.post("/api/ai/short/{post_id}")
526
- async def api_ai_short(post_id:str,voice:str=Query(default=None),speed:float=Query(default=None)):
527
- posts=_load_wall(); post=next((p for p in posts if str(p.get("id"))==str(post_id)),None)
528
- if not post: return JSONResponse({"error":"post not found"},status_code=404)
529
- os.makedirs(SHORTS_DIR,exist_ok=True); out_mp4=os.path.join(SHORTS_DIR,_safe_name(post_id)+".mp4")
 
 
 
 
 
530
  if os.path.exists(out_mp4) and voice is None and speed is None:
531
- video_url="/api/ai/short-file/"+post_id
532
- for i,p in enumerate(posts):
533
- if str(p.get("id"))==str(post_id): posts[i]["video"]=video_url; break
534
- _save_wall(posts); return JSONResponse({"video":video_url})
535
- if voice is not None and voice not in TTS_VOICES: return JSONResponse({"error":f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"},status_code=400)
536
- video_url=await _generate_short_video(post,post_id,voice_id=voice,speed=speed)
 
 
 
 
 
 
537
  if video_url:
538
- for i,p in enumerate(posts):
539
- if str(p.get("id"))==str(post_id): posts[i]["video"]=video_url; break
540
- _save_wall(posts); return JSONResponse({"video":video_url})
541
- return JSONResponse({"error":"Không tạo được shorts"},status_code=500)
 
 
 
 
542
 
543
  @app.get("/api/ai/short-file/{post_id}")
544
- def api_ai_short_file(post_id:str):
545
- path=os.path.join(SHORTS_DIR,_safe_name(post_id)+".mp4")
546
- if not os.path.exists(path): return JSONResponse({"error":"not found"},status_code=404)
547
- return FileResponse(path,media_type="video/mp4",filename=f"vnews-ai-{post_id}.mp4")
 
 
548
 
549
  @app.get("/api/ai/status")
550
  def api_ai_status():
551
- return JSONResponse({"has_token":bool(_hf_token()),"client_imported":AsyncInferenceClient is not None,"model":QWEN_VL_MODEL,"last_error":LAST_QWEN_ERROR,"tts_ready":gTTS is not None or edge_tts is not None,"tts_engine":"edge-tts" if edge_tts else ("gtts" if gTTS else "none"),"tts_voices":{k:v["flag"]+" "+v["name"] for k,v in TTS_VOICES.items()},"tts_voice_count":len(TTS_VOICES),"tts_default_speed":TTS_DEFAULT_SPEED})
 
 
 
 
 
 
 
 
 
 
 
552
 
553
  @app.get("/api/ai/voices")
554
  def api_ai_voices():
555
- voices_out={}
556
- for k,v in TTS_VOICES.items(): voices_out[k]={"name":v["name"],"gender":v["gender"],"country":v["country"],"lang":v["lang"],"flag":v["flag"],"label":f"{v['flag']} {v['name']} ({v['gender']})"}
557
- return JSONResponse({"voices":voices_out,"default_voice":TTS_DEFAULT_VOICE,"default_speed":TTS_DEFAULT_SPEED,"topic_voice_map":TOPIC_VOICE_MAP})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  LAST_QWEN_ERROR = ""
78
 
79
  # ===== TTS VOICE CONFIG =====
 
 
80
  TTS_VOICES = {
 
81
  "hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
82
  "namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
 
83
  "en_us_f": {"id": "en-US-AvaNeural", "gender": "female", "name": "Ava", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
84
  "en_us_m": {"id": "en-US-BrianNeural", "gender": "male", "name": "Brian", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
 
85
  "en_gb_f": {"id": "en-GB-SoniaNeural", "gender": "female", "name": "Sonia", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
86
  "en_gb_m": {"id": "en-GB-RyanNeural", "gender": "male", "name": "Ryan", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
 
87
  "zh_cn_f": {"id": "zh-CN-XiaoxiaoNeural", "gender": "female", "name": "Xiaoxiao", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
88
  "zh_cn_m": {"id": "zh-CN-YunyangNeural", "gender": "male", "name": "Yunyang", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
89
  "zh_hk_f": {"id": "zh-HK-HiuGaaiNeural", "gender": "female", "name": "HiuGaai", "country": "Hồng Kông", "lang": "zh", "flag": "🇭🇰"},
 
90
  "ja_jp_f": {"id": "ja-JP-NanamiNeural", "gender": "female", "name": "Nanami", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
91
  "ja_jp_m": {"id": "ja-JP-KeitaNeural", "gender": "male", "name": "Keita", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
 
92
  "ko_kr_f": {"id": "ko-KR-SunHiNeural", "gender": "female", "name": "Sun-Hi", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
93
  "ko_kr_m": {"id": "ko-KR-InJoonNeural", "gender": "male", "name": "InJoon", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
 
94
  "fr_fr_f": {"id": "fr-FR-DeniseNeural", "gender": "female", "name": "Denise", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
95
  "fr_fr_m": {"id": "fr-FR-HenriNeural", "gender": "male", "name": "Henri", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
 
96
  "de_de_f": {"id": "de-DE-KatjaNeural", "gender": "female", "name": "Katja", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
97
  "de_de_m": {"id": "de-DE-KillianNeural", "gender": "male", "name": "Killian", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
 
98
  "es_es_f": {"id": "es-ES-XimenaNeural", "gender": "female", "name": "Ximena", "country": "Tây Ban Nha", "lang": "es", "flag": "🇪🇸"},
99
  "es_mx_m": {"id": "es-MX-JorgeNeural", "gender": "male", "name": "Jorge", "country": "Mexico", "lang": "es", "flag": "🇲🇽"},
 
100
  "th_th_f": {"id": "th-TH-PremwadeeNeural", "gender": "female", "name": "Premwadee", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
101
  "th_th_m": {"id": "th-TH-NiwatNeural", "gender": "male", "name": "Niwat", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
 
102
  "hi_in_f": {"id": "hi-IN-SwaraNeural", "gender": "female", "name": "Swara", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
103
  "hi_in_m": {"id": "hi-IN-MadhurNeural", "gender": "male", "name": "Madhur", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
104
  }
105
  TTS_DEFAULT_VOICE = "hoaimy"
106
+ TTS_DEFAULT_SPEED = 1.2
107
 
 
108
  TOPIC_VOICE_MAP = {
 
109
  "bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
110
  "premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
111
  "serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
112
  "tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
 
113
  "sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
114
  "âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
115
  "ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
116
  "tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
 
117
  "công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
118
  "khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
119
  "laptop": "namminh", "game": "namminh",
 
120
  "chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
121
  "chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
122
  "xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
 
152
  """Remove markdown artifacts, instruction leakage, and aggressively dedup content."""
153
  if not text:
154
  return ""
 
155
  text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
156
  text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
157
  text = re.sub(r'\*([^*]+)\*', r'\1', text)
158
  text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
159
  text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
 
160
  leakage = [
161
  r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
162
  r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
 
168
  for phrase in leakage:
169
  text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
170
  text = re.sub(r'\n{3,}', '\n\n', text)
171
+
172
  def _norm(s):
173
  return re.sub(r'\s+', ' ', s.strip().lower())
174
+
175
  raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
176
  seen_sentences = set()
177
  unique_parts = []
 
185
  partial = re.sub(r'\W+', '', n)
186
  for seen in seen_sentences:
187
  seen_clean = re.sub(r'\W+', '', seen)
188
+ if partial and seen_clean and (
189
+ partial in seen_clean or seen_clean in partial
190
+ ):
191
  shorter = min(len(partial), len(seen_clean))
192
  longer = max(len(partial), len(seen_clean))
193
  if shorter > 20 and shorter / longer > 0.75:
 
198
  if n:
199
  seen_sentences.add(n)
200
  unique_parts.append(part)
201
+
202
  result = ' '.join(unique_parts).strip()
203
  lines = result.split('\n')
204
  final_lines = []
 
216
  # ===== EXTRACT ALL IMAGES FROM ARTICLE =====
217
  def _extract_all_images(soup, base_url: str) -> List[Dict]:
218
  """Extract ALL content images from an article page using multi-strategy approach."""
219
+ images = []
220
+ seen_urls = set()
221
+ skip_patterns = [
222
+ "avatar", "icon", "logo", "button", "banner-ad", "tracking",
223
+ "beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
224
+ "advertisement", "ads", "widget", "sidebar", "footer-logo",
225
+ ]
226
+
227
  def _add_image(src: str, alt: str = "", source_tag: str = "img"):
228
+ if not src or src.startswith("data:"):
229
+ return
230
  abs_url = urljoin(base_url, src.strip())
231
+ if abs_url in seen_urls:
232
+ return
233
+ if any(p in abs_url.lower() for p in skip_patterns):
234
+ return
235
  try:
236
+ parsed = urlparse(abs_url)
237
+ path = parsed.path.lower()
238
+ if any(path.endswith(ext) for ext in ['.svg', '.ico', '.gif']):
239
+ return
240
+ except Exception:
241
+ pass
242
+ seen_urls.add(abs_url)
243
+ images.append({"url": abs_url, "alt": alt, "source": source_tag})
244
+
245
  for img in soup.find_all("img"):
246
+ src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
247
+ img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
248
+ _add_image(src, alt=img.get("alt", ""), source_tag="img")
249
+
250
  for img in soup.find_all("img", srcset=True):
251
  for part in img["srcset"].split(","):
252
  part = part.strip()
253
+ if part:
254
+ _add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
255
+
256
  for picture in soup.find_all("picture"):
257
  for source in picture.find_all("source"):
258
+ srcset = source.get("srcset", "")
259
  for part in srcset.split(","):
260
  part = part.strip()
261
+ if part:
262
+ _add_image(part.split(" ")[0], source_tag="picture/srcset")
263
  fallback_img = picture.find("img")
264
+ if fallback_img:
265
+ _add_image(
266
+ fallback_img.get("src") or fallback_img.get("data-src"),
267
+ alt=fallback_img.get("alt", ""),
268
+ source_tag="picture/img"
269
+ )
270
+
271
  for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
272
+ _add_image(img.get("data-src") or img.get("src"),
273
+ alt=img.get("alt", ""), source_tag="wp-image")
274
+
275
  for tag in soup.find_all(style=re.compile(r"background-image")):
276
+ for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
277
+ _add_image(m, source_tag="background-style")
278
+
279
  og_image = soup.find("meta", property="og:image")
280
+ if og_image and og_image.get("content"):
281
+ _add_image(og_image["content"], source_tag="og:image")
282
+
283
+ tw_image = soup.find("meta", attrs={"name": "twitter:image"})
284
+ if tw_image and tw_image.get("content"):
285
+ _add_image(tw_image["content"], source_tag="twitter:image")
286
+
287
  for figure in soup.find_all("figure"):
288
  img = figure.find("img")
289
  if img:
290
  src = img.get("data-src") or img.get("src")
291
  figcaption = figure.find("figcaption")
292
+ alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
293
  _add_image(src, alt=alt, source_tag="figure")
294
+
295
  for a in soup.find_all("a", href=True):
296
  href = a["href"]
297
+ if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]):
298
+ _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
299
+
300
  return images
301
 
302
+
303
  # ===== JINA READER =====
304
  def _reader_url(target_url: str) -> str:
305
  safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
306
  return "https://r.jina.ai/http://" + safe
307
+
308
  def jina_reader_markdown(url: str) -> str:
309
  jr = _reader_url(url)
310
+ r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
311
+ r.raise_for_status()
312
+ return r.text or ""
313
+
314
  def _parse_jina_markdown(md: str, url: str):
315
+ lines = [x.rstrip() for x in (md or "").splitlines()]
316
+ title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
317
  for ln in lines:
318
+ if ln.startswith("Title:") and not title:
319
+ title = _clean_text(ln.replace("Title:", "", 1)); continue
320
+ if ln.startswith("URL Source:"):
321
+ continue
322
+ if ln.startswith("Markdown Content:"):
323
+ in_content = True; continue
324
+ for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
325
+ img_url = mimg.group(1)
326
+ if img_url not in all_images:
327
+ all_images.append(img_url)
328
+ if not first_image:
329
+ first_image = img_url
330
  if in_content or (title and not ln.startswith("Title:")):
331
+ if ln.strip():
332
+ content_lines.append(ln)
333
+ text = "\n".join(content_lines)
334
+ text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
335
+ paras = []
336
+ for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
337
+ t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
338
+ if len(t) >= 40:
339
+ paras.append(t)
340
+ if len(paras) >= 35:
341
+ break
342
+ if not title and paras:
343
+ title = paras[0][:90]
344
+ return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
345
+ "text": "\n".join(paras), "image": first_image,
346
+ "images": all_images, "via": "jina"}
347
+
348
+
349
+ # ===== WEB SCRAPE (with full image extraction) =====
350
  def _best_content_block(soup):
351
+ best, best_score = None, 0
352
+ for el in soup.find_all(["article", "main", "section", "div"]):
353
+ ps = el.find_all("p")
354
+ txt = " ".join(p.get_text(" ", strip=True) for p in ps)
355
+ score = len(ps) * 100 + len(txt)
356
+ cls = " ".join(el.get("class", []))
357
+ if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
358
+ score += 800
359
+ if score > best_score:
360
+ best, best_score = el, score
361
  return best
362
+
363
  def scrape_any_url_direct(url: str):
364
+ r = requests.get(url, headers=HEADERS, timeout=18)
365
+ if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
366
+ raise RuntimeError(f"blocked status {r.status_code}")
367
+ r.encoding = "utf-8"
368
+ soup = BeautifulSoup(r.text, "lxml")
369
+ for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
370
+ tag.decompose()
371
+
372
+ title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
373
  if not title:
374
+ ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
375
+ title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
376
+
377
+ desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
378
+ summary = desc_tag.get("content", "") if desc_tag else ""
379
+
380
+ img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
381
+ image = img_tag.get("content", "") if img_tag else ""
382
+ if image and image.startswith("//"):
383
+ image = "https:" + image
384
+
385
+ all_images = _extract_all_images(soup, url)
386
+ image_urls = [img["url"] for img in all_images]
387
+
388
+ if image and image not in image_urls:
389
+ image_urls.insert(0, image)
390
+ elif image in image_urls:
391
+ image_urls.remove(image)
392
+ image_urls.insert(0, image)
393
+
394
+ block = _best_content_block(soup) or soup
395
+ paras, seen_p = [], set()
396
  for p in block.find_all("p"):
397
+ t = _clean_text(p.get_text(" ", strip=True))
398
+ if len(t) >= 40 and t not in seen_p:
399
+ seen_p.add(t)
400
+ paras.append(t)
401
+ if len(paras) >= 35:
402
+ break
403
+
404
+ if not title and paras:
405
+ title = paras[0][:90]
406
+
407
+ return {
408
+ "url": url, "title": title or url, "summary": paras[0] if paras else "",
409
+ "text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
410
+ "images": image_urls, "via": _domain(url)
411
+ }
412
+
413
  def scrape_any_url(url: str):
414
+ """Try direct scrape first, fall back to Jina Reader."""
415
+ data = scrape_any_url_direct(url)
416
+ raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
417
+ if len(raw_text) >= 120:
418
+ return data
419
  try:
420
+ md = jina_reader_markdown(url)
421
  if md:
422
+ jr = _parse_jina_markdown(md, url)
423
  if jr.get("text"):
424
+ if data.get("title") and data["title"] != url:
425
+ jr["title"] = data["title"]
426
+ if data.get("image"):
427
+ jr["image"] = data["image"]
428
+ if data.get("images"):
429
+ jr["images"] = data["images"]
430
+ jr["via"] = data.get("via", _domain(url)) + " + jina"
431
+ return jr
432
+ except Exception:
433
+ pass
434
  return data
435
 
436
+
437
+ # ===== POLLINATIONS IMAGE =====
438
  def pollinations_image_url(topic: str) -> str:
439
+ prompt = "editorial illustration, Vietnamese news, " + topic
440
+ return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
441
+
442
 
443
+ # ===== QWEN AI (strict, concise) =====
444
  async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, image_urls: Optional[List[str]] = None):
445
+ global LAST_QWEN_ERROR, HF_TOKEN
446
+ HF_TOKEN = _hf_token()
447
+ if not HF_TOKEN:
448
+ LAST_QWEN_ERROR = "Không tìm thấy token"
449
+ return None
450
+ if not AsyncInferenceClient:
451
+ LAST_QWEN_ERROR = "Thiếu huggingface_hub"
452
+ return None
453
+ errors = []; models = []
454
+ for m in [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]:
455
+ if m and m not in models:
456
+ models.append(m)
457
  for model in models:
458
  try:
459
+ client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=90)
460
+ content = []
461
+ all_img_urls = []
462
+ if image_urls:
463
+ all_img_urls = image_urls[:6]
464
+ elif image_url:
465
+ all_img_urls = [image_url]
466
  for img_u in all_img_urls:
467
+ if img_u and img_u.startswith("http"):
468
+ content.append({"type": "image_url", "image_url": {"url": img_u}})
469
+ content.append({"type": "text", "text": prompt})
470
+ messages = [
471
+ {"role": "system", "content": (
472
+ "Bạn biên tập viên báo điện tử tiếng Việt. "
473
+ "NHIỆM VỤ: Chỉ TÓM TắT nội dung, KHÔNG viết lại bài đầy đủ. "
474
+ "QUY TẮC CỨNG: "
475
+ "(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
476
+ "(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
477
+ "(3) KHÔNG dùng Markdown (##, **, ---, *). "
478
+ "(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
479
+ "(5) KHÔNG bịa thông tin ngoài nguồn. "
480
+ "(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
481
+ "(7) Tối đa 200 từ. Ngắn gọn, súc tích."
482
+ )},
483
+ {"role": "user", "content": content}
484
+ ]
485
+ resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
486
+ txt = (resp.choices[0].message.content or "").strip()
487
+ if txt:
488
+ LAST_QWEN_ERROR = ""
489
+ return txt
490
+ except Exception as e:
491
+ errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
492
+ LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
493
+ print("[qwen errors]", LAST_QWEN_ERROR)
494
+ return None
495
+
496
 
497
+ # ===== TTS GENERATION =====
498
  async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str):
499
+ if edge_tts is None:
500
+ raise RuntimeError("edge-tts chưa cài đặt")
501
+ voice = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])["id"]
502
+ pct = int((speed - 1.0) * 100)
503
+ rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
504
+ communicate = edge_tts.Communicate(text, voice, rate=rate)
505
+ await communicate.save(out_path)
506
+
507
  def _generate_tts_gtts(text: str, out_path: str):
508
+ if gTTS is None:
509
+ raise RuntimeError("gTTS chưa cài đặt")
510
+ gTTS(text, lang="vi").save(out_path)
511
+
512
 
513
+ # ===== SHORT VIDEO GENERATION =====
514
+ def _download_image(url, fallback_topic, out_path):
515
  if url:
516
  try:
517
+ r = requests.get(url, headers=HEADERS, timeout=15)
518
+ if r.status_code == 200 and len(r.content) > 1000:
519
+ with open(out_path, "wb") as f:
520
+ f.write(r.content)
521
  return out_path
522
+ except Exception:
523
+ pass
524
+ gen = pollinations_image_url(fallback_topic)
525
  try:
526
+ r = requests.get(gen, headers=HEADERS, timeout=25)
527
+ if r.status_code == 200 and len(r.content) > 1000:
528
+ with open(out_path, "wb") as f:
529
+ f.write(r.content)
530
  return out_path
531
+ except Exception:
532
+ pass
533
+ if Image:
534
+ Image.new("RGB", (1080, 860), (30, 55, 42)).save(out_path)
535
+ return out_path
536
  raise RuntimeError("Không tạo được ảnh")
537
+
538
+ def _make_short_frame(post, img_path, out_path):
539
+ if Image is None:
540
+ raise RuntimeError("Pillow chưa sẵn sàng")
541
+ W, H = 1080, 1920
542
+ bg = Image.new("RGB", (W, H), (14, 14, 14))
543
  try:
544
+ im = Image.open(img_path).convert("RGB")
545
+ target = (1080, 860)
546
+ im_ratio = im.width / im.height
547
+ target_ratio = target[0] / target[1]
548
+ if im_ratio > target_ratio:
549
+ new_h = target[1]; new_w = int(new_h * im_ratio)
550
+ else:
551
+ new_w = target[0]; new_h = int(new_w / im_ratio)
552
+ im = im.resize((new_w, new_h))
553
+ left = (new_w - target[0]) // 2; top = (new_h - target[1]) // 2
554
+ im = im.crop((left, top, left + target[0], top + target[1]))
555
+ bg.paste(im, (0, 0))
556
+ except Exception:
557
+ pass
558
+ draw = ImageDraw.Draw(bg)
559
+ try:
560
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
561
+ font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
562
+ font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
563
+ except Exception:
564
+ font_title = font_body = font_label = None
565
+ draw.rectangle((0, 780, W, H), fill=(14, 14, 14))
566
+ draw.text((54, 830), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
567
+ title = post.get("title", "")
568
+ words = title.split(); lines_t = []; cur = ""
569
  for w in words:
570
+ if len(cur) + len(w) + 1 <= 24:
571
+ cur = (cur + " " + w).strip()
572
  else:
573
  if cur: lines_t.append(cur)
574
+ cur = w
575
  if cur: lines_t.append(cur)
576
+ draw.multiline_text((54, 900), "\n".join(lines_t[:3]), fill=(255, 255, 255), font=font_title, spacing=10)
577
+ body_text = post.get("text", "")
578
+ words_b = body_text.split(); lines_b = []; cur_b = ""
579
  for w in words_b:
580
+ if len(cur_b) + len(w) + 1 <= 34:
581
+ cur_b = (cur_b + " " + w).strip()
582
  else:
583
  if cur_b: lines_b.append(cur_b)
584
+ cur_b = w
585
+ if len(lines_b) >= 10:
586
+ break
587
+ if cur_b and len(lines_b) < 10: lines_b.append(cur_b)
588
+ draw.multiline_text((54, 1120), "\n".join(lines_b), fill=(220, 220, 220), font=font_body, spacing=12)
589
+ bg.save(out_path, quality=92)
590
+
591
+ def _short_script(post, max_chars=700):
592
+ txt = _clean_text(post.get("text", ""))
593
+ if len(txt) > max_chars:
594
+ txt = txt[:max_chars].rsplit(" ", 1)[0] + "."
595
+ title = _clean_text(post.get("title", ""))
596
+ return f"{title}. {txt}"
597
+
598
+ async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str:
599
  try:
600
+ os.makedirs(SHORTS_DIR, exist_ok=True)
601
+ out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
602
+ if os.path.exists(out_mp4):
603
+ return "/api/ai/short-file/" + post_id
604
+
605
+ work = os.path.join(SHORTS_DIR, _safe_name(post_id))
606
+ os.makedirs(work, exist_ok=True)
607
+ img_path = os.path.join(work, "image.jpg")
608
+ frame_path = os.path.join(work, "frame.jpg")
609
+ audio_path = os.path.join(work, "voice.mp3")
610
+
611
+ _download_image(post.get("img"), post.get("title", "AI news"), img_path)
612
+ _make_short_frame(post, img_path, frame_path)
613
+ script = _short_script(post)
614
+
615
+ if voice_id is None:
616
+ voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", ""))
617
+ if speed is None:
618
+ speed = TTS_DEFAULT_SPEED
619
+
620
+ try:
621
+ await _generate_tts_edge(script, voice_id, speed, audio_path)
622
  except Exception as e:
623
  print(f"[TTS edge-tts error] {e}, falling back to gTTS")
624
+ if gTTS:
625
+ _generate_tts_gtts(script, audio_path)
626
+ else:
627
+ return ""
628
+
629
+ cmd = ["ffmpeg", "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
630
+ "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
631
+ "-c:a", "aac", "-b:a", "128k", "-vf", "scale=1080:1920", out_mp4]
632
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
633
+ return "/api/ai/short-file/" + post_id
634
+ except Exception as e:
635
+ print(f"[short video error] {e}")
636
+ return ""
637
+
638
+
639
+ # ===== MAKE POST =====
640
+ def make_post(title, text, image, source_url, kind, sources=None, images=None):
641
+ return {
642
+ "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
643
+ "title": title, "text": text, "img": image, "url": source_url,
644
+ "kind": kind, "sources": sources or [], "video": "",
645
+ "images": images or [], "ts": int(time.time())
646
+ }
647
+
648
+
649
+ # ===== SHARED PROMPT BUILDER =====
650
+ def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
651
+ image_info = ""
652
  if images:
653
+ num = len(images)
654
+ if num == 1:
655
+ image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
656
+ else:
657
+ image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
658
+
659
+ return f"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI.
660
+
661
+ QUY TẮC BẮT BUỘC:
662
+ 1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc.
663
+ 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
664
+ 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
665
+ 4. KHÔNG dùng Markdown (##, **, ---, *).
666
+ 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt".
667
+ 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
668
+ 7. Giữ sự thật, KHÔNG bịa thông tin.
669
+ 8. Tối đa 200 từ. Ngắn gọn, đủ ý.{image_info}
670
 
671
+ Tiêu đề gốc: {title}
672
+
673
+ Nội dung gốc:
674
+ {raw[:14000]}"""
675
+
676
+
677
+ def _build_topic_prompt(topic: str, ctx: str) -> str:
678
+ return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
679
+
680
+ QUY TẮC BẮT BUỘC:
681
+ 1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên văn.
682
+ 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
683
+ 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
684
+ 4. KHÔNG dùng Markdown (##, **, ---, *).
685
+ 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
686
+ 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points.
687
+ 7. Giữ sự thật, KHÔNG bịa.
688
+ 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
689
+
690
+ Nguồn thực tế:
691
+ {ctx[:12000]}"""
692
+
693
+
694
+ # ===== WRITE ENDPOINTS =====
695
  @app.post("/api/rewrite_share")
696
+ async def api_rewrite_share(request: Request):
697
+ body = await request.json()
698
+ url = _clean_text(body.get("url", ""))
699
+ if not url.startswith("http"):
700
+ return JSONResponse({"error": "missing url"}, status_code=400)
701
+ try:
702
+ data = scrape_any_url(url)
703
+ except Exception as e:
704
+ return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
705
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
706
+ if len(raw) < 60:
707
+ return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
708
+
709
+ images = data.get("images", [])
710
+ prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
711
+ text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=500)
712
+ if not text:
713
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
714
+ text = _clean_ai_output(text)
715
+ post = make_post(data.get("title") or "Bài viết", text,
716
+ images[0] if images else data.get("image", ""),
717
+ url, "rewrite", images=images)
718
+
719
+ video_url = await _generate_short_video(post, post["id"])
720
+ if video_url:
721
+ post["video"] = video_url
722
+
723
+ posts = _load_wall()
724
+ posts.insert(0, post)
725
+ _save_wall(posts)
726
+ return JSONResponse({"post": post})
727
+
728
 
729
  @app.post("/api/url_wall")
730
+ async def api_url_wall(request: Request):
731
+ body = await request.json()
732
+ url = _clean_text(body.get("url", ""))
733
+ if not url.startswith("http"):
734
+ return JSONResponse({"error": "missing url"}, status_code=400)
735
+ try:
736
+ data = scrape_any_url(url)
737
+ except Exception as e:
738
+ return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
739
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
740
+ if len(raw) < 60:
741
+ return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
742
+
743
+ images = data.get("images", [])
744
+ prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
745
+ text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=500)
746
+ if not text:
747
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
748
+ text = _clean_ai_output(text)
749
+ post = make_post(data.get("title") or "Bài viết", text,
750
+ images[0] if images else data.get("image", ""),
751
+ url, "url", images=images)
752
+
753
+ video_url = await _generate_short_video(post, post["id"])
754
+ if video_url:
755
+ post["video"] = video_url
756
+
757
+ posts = _load_wall()
758
+ posts.insert(0, post)
759
+ _save_wall(posts)
760
+ return JSONResponse({"post": post})
761
+
762
 
763
  @app.post("/api/topic_post")
764
+ async def api_topic_post(request: Request):
765
+ body = await request.json()
766
+ topic = _clean_text(body.get("topic", ""))
767
+ if not topic:
768
+ return JSONResponse({"error": "missing topic"}, status_code=400)
769
+
770
+ ctx = _web_context(topic)
771
+ if not ctx:
772
+ return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
773
+
774
+ image = pollinations_image_url(topic)
775
+ prompt = _build_topic_prompt(topic, ctx)
776
+ text = await qwen_generate(prompt, image_url=image, max_tokens=500)
777
+ if not text:
778
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
779
+ text = _clean_ai_output(text)
780
+ post = make_post(topic, text, image, "", "topic")
781
 
782
+ video_url = await _generate_short_video(post, post["id"])
783
+ if video_url:
784
+ post["video"] = video_url
785
+
786
+ posts = _load_wall()
787
+ posts.insert(0, post)
788
+ _save_wall(posts)
789
+ return JSONResponse({"post": post})
790
+
791
+
792
+ # ===== WALL ENDPOINTS =====
793
  @app.get("/api/ai_wall")
794
+ def api_ai_wall():
795
+ return JSONResponse({"posts": _load_wall()[:80]})
796
+
797
  @app.get("/api/wall")
798
+ def api_wall():
799
+ return JSONResponse({"posts": _load_wall()[:80]})
800
+
801
 
802
+ # ===== SHORT VIDEO ENDPOINT (with voice + speed params) =====
803
  @app.post("/api/ai/short/{post_id}")
804
+ async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None)):
805
+ posts = _load_wall()
806
+ post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
807
+ if not post:
808
+ return JSONResponse({"error": "post not found"}, status_code=404)
809
+
810
+ os.makedirs(SHORTS_DIR, exist_ok=True)
811
+ out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
812
+
813
  if os.path.exists(out_mp4) and voice is None and speed is None:
814
+ video_url = "/api/ai/short-file/" + post_id
815
+ for i, p in enumerate(posts):
816
+ if str(p.get("id")) == str(post_id):
817
+ posts[i]["video"] = video_url
818
+ break
819
+ _save_wall(posts)
820
+ return JSONResponse({"video": video_url})
821
+
822
+ if voice is not None and voice not in TTS_VOICES:
823
+ return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
824
+
825
+ video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed)
826
  if video_url:
827
+ for i, p in enumerate(posts):
828
+ if str(p.get("id")) == str(post_id):
829
+ posts[i]["video"] = video_url
830
+ break
831
+ _save_wall(posts)
832
+ return JSONResponse({"video": video_url})
833
+ return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
834
+
835
 
836
  @app.get("/api/ai/short-file/{post_id}")
837
+ def api_ai_short_file(post_id: str):
838
+ path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
839
+ if not os.path.exists(path):
840
+ return JSONResponse({"error": "not found"}, status_code=404)
841
+ return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
842
+
843
 
844
  @app.get("/api/ai/status")
845
  def api_ai_status():
846
+ return JSONResponse({
847
+ "has_token": bool(_hf_token()),
848
+ "client_imported": AsyncInferenceClient is not None,
849
+ "model": QWEN_VL_MODEL,
850
+ "last_error": LAST_QWEN_ERROR,
851
+ "tts_ready": gTTS is not None or edge_tts is not None,
852
+ "tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
853
+ "tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
854
+ "tts_voice_count": len(TTS_VOICES),
855
+ "tts_default_speed": TTS_DEFAULT_SPEED,
856
+ })
857
+
858
 
859
  @app.get("/api/ai/voices")
860
  def api_ai_voices():
861
+ voices_out = {}
862
+ for k, v in TTS_VOICES.items():
863
+ voices_out[k] = {
864
+ "name": v["name"],
865
+ "gender": v["gender"],
866
+ "country": v["country"],
867
+ "lang": v["lang"],
868
+ "flag": v["flag"],
869
+ "label": f"{v['flag']} {v['name']} ({v['gender']})",
870
+ }
871
+ return JSONResponse({
872
+ "voices": voices_out,
873
+ "default_voice": TTS_DEFAULT_VOICE,
874
+ "default_speed": TTS_DEFAULT_SPEED,
875
+ "topic_voice_map": TOPIC_VOICE_MAP,
876
+ })