bep40 commited on
Commit
304f5ce
·
verified ·
1 Parent(s): e0ec8cc

Upload ai_ext.py

Browse files
Files changed (1) hide show
  1. ai_ext.py +277 -773
ai_ext.py CHANGED
@@ -77,46 +77,64 @@ HEADERS = {
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",
@@ -124,6 +142,7 @@ TOPIC_VOICE_MAP = {
124
 
125
 
126
  def _detect_voice_for_topic(title: str, text: str) -> str:
 
127
  combined = (title + " " + text[:500]).lower()
128
  for keyword, voice_id in TOPIC_VOICE_MAP.items():
129
  if keyword in combined:
@@ -146,109 +165,57 @@ def _safe_name(s):
146
  return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
147
 
148
 
149
- # ===== CLEAN AI OUTPUT — COMPLETE REWRITE =====
150
  def _clean_ai_output(text: str) -> str:
151
- """
152
- Aggressively clean AI output:
153
- 1. Remove markdown artifacts
154
- 2. Remove instruction leakage / meta-commentary
155
- 3. Split into sentences, deduplicate (exact + near-match)
156
- 4. Trim at complete sentence boundary (no mid-sentence cuts)
157
- 5. Remove consecutive duplicate lines
158
- """
159
  if not text:
160
  return ""
161
-
162
- # --- Step 1: Remove markdown ---
163
  text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
164
  text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
165
  text = re.sub(r'\*([^*]+)\*', r'\1', text)
166
  text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
167
  text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
168
-
169
- # --- Step 2: Remove instruction leakage (entire lines) ---
170
- leakage_patterns = [
171
  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',
172
  r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
173
  r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
174
  r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
175
  r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
176
  r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
177
- r'Dựa trên.*tôi đã', r'Theo nội dung', r'Tóm tắt như sau',
178
- r'Dưới đây là bài viết', r'Đây là phần', r'Bài tóm tắt',
179
- r'Nội dung tóm tắt', r'Đoạn văn sau', r'Viết lại.*sau',
180
  ]
181
- for phrase in leakage_patterns:
182
- text = re.sub(r'^[^\n]*' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
183
-
184
- # Remove lines that are just meta-instructions
185
- text = re.sub(r'^[^\n]*(?:QUY TẮC|bắt buộc|tối đa|không dùng)[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
186
-
187
  text = re.sub(r'\n{3,}', '\n\n', text)
188
- text = text.strip()
189
-
190
- if not text:
191
- return ""
192
-
193
- # --- Step 3: Split into sentences & deduplicate ---
194
- # Vietnamese sentences end with . ! ? or end of string
195
- # We split on sentence-ending punctuation followed by space or end
196
- raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
197
-
198
  def _norm(s):
199
- """Normalize for comparison: lowercase, collapse whitespace, remove punctuation."""
200
- s = re.sub(r'\s+', ' ', s.strip().lower())
201
- return re.sub(r'[^\w\s]', '', s)
202
-
203
  seen_sentences = set()
204
  unique_parts = []
205
-
206
  for part in raw_parts:
207
  n = _norm(part)
208
- if not n or len(n) < 5:
209
- # Very short fragments — keep them if they're not empty
210
- if part.strip():
211
- unique_parts.append(part)
212
- continue
213
-
214
- # Check exact match
215
- if n in seen_sentences:
216
- continue
217
-
218
- # Check near-duplicate: substring match
219
  is_dup = False
220
- n_clean = re.sub(r'\s+', '', n)
221
- for seen in list(seen_sentences):
222
- seen_clean = re.sub(r'\s+', '', seen)
223
- if not n_clean or not seen_clean:
224
- continue
225
- # One is substring of the other
226
- if n_clean in seen_clean or seen_clean in n_clean:
227
- shorter = min(len(n_clean), len(seen_clean))
228
- longer = max(len(n_clean), len(seen_clean))
229
- if shorter > 15 and shorter / longer > 0.7:
230
- is_dup = True
231
- break
232
- # High word overlap
233
  else:
234
- words_n = set(n.split())
235
- words_s = set(seen.split())
236
- if len(words_n) >= 3 and len(words_s) >= 3:
237
- overlap = len(words_n & words_s)
238
- ratio = overlap / min(len(words_n), len(words_s))
239
- if ratio > 0.75:
240
- is_dup = True
241
- break
242
-
243
  if is_dup:
244
  continue
245
-
246
- seen_sentences.add(n)
247
  unique_parts.append(part)
248
-
249
  result = ' '.join(unique_parts).strip()
250
-
251
- # --- Step 4: Remove consecutive duplicate lines ---
252
  lines = result.split('\n')
253
  final_lines = []
254
  prev_line = ""
@@ -259,795 +226,332 @@ def _clean_ai_output(text: str) -> str:
259
  final_lines.append(line)
260
  prev_line = stripped
261
  result = '\n'.join(final_lines).strip()
262
-
263
- # --- Step 5: Trim at complete sentence boundary ---
264
- # Find the last sentence-ending punctuation and cut there
265
- # This prevents mid-sentence truncation
266
- if result:
267
- # Find all sentence-ending positions
268
- end_matches = list(re.finditer(r'[.!?]\s*$', result))
269
- if not end_matches:
270
- # No ending punctuation — try to find the last complete sentence
271
- last_end = -1
272
- for m in re.finditer(r'[.!?](?=\s|$)', result):
273
- last_end = m.end()
274
- if last_end > 0:
275
- result = result[:last_end].strip()
276
- # else: already ends with punctuation, good
277
-
278
- # --- Step 6: Final cleanup ---
279
- # Remove any trailing incomplete sentence fragments after last punctuation
280
- # e.g. "Đây là câu 1. Đây là câu 2. Và một" → cut "Và một"
281
- last_period = result.rfind('.')
282
- last_exclaim = result.rfind('!')
283
- last_question = result.rfind('?')
284
- last_end = max(last_period, last_exclaim, last_question)
285
- if last_end > 0:
286
- after = result[last_end + 1:].strip()
287
- # If what follows the last sentence end is a short fragment (< 20 chars, no ending punct), cut it
288
- if after and len(after) < 20 and not re.search(r'[.!?]$', after):
289
- result = result[:last_end + 1].strip()
290
-
291
  return result
292
 
293
 
294
- # ===== EXTRACT ALL IMAGES FROM ARTICLE — ENHANCED =====
295
  def _extract_all_images(soup, base_url: str) -> List[Dict]:
296
  """Extract ALL content images from an article page using multi-strategy approach."""
297
- images = []
298
- seen_urls = set()
299
- skip_patterns = [
300
- "avatar", "icon", "logo", "button", "banner-ad", "tracking",
301
- "beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
302
- "advertisement", "ads", "widget", "sidebar", "footer-logo",
303
- "social", "share", "comment-avatar", "user-avatar",
304
- ]
305
-
306
  def _add_image(src: str, alt: str = "", source_tag: str = "img"):
307
- if not src or src.startswith("data:"):
308
- return
309
- # Normalize URL
310
- src = src.strip()
311
- if src.startswith("//"):
312
- src = "https:" + src
313
- abs_url = urljoin(base_url, src)
314
- if abs_url in seen_urls:
315
- return
316
- # Skip non-content images by URL pattern
317
- if any(p in abs_url.lower() for p in skip_patterns):
318
- return
319
- # Skip very small images (likely icons)
320
  try:
321
- parsed = urlparse(abs_url)
322
- path = parsed.path.lower()
323
- if any(path.endswith(ext) for ext in ['.svg', '.ico']):
324
- return
325
- # Skip if URL contains dimension patterns like 16x16, 32x32
326
- if re.search(r'/\d{1,3}x\d{1,3}/', path) or re.search(r'-\d{1,3}x\d{1,3}\.', path):
327
- return
328
- except Exception:
329
- pass
330
- seen_urls.add(abs_url)
331
- images.append({"url": abs_url, "alt": alt, "source": source_tag})
332
-
333
- # Strategy 1: Standard <img> tags with all lazy-load attributes
334
  for img in soup.find_all("img"):
335
- src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
336
- img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
337
- _add_image(src, alt=img.get("alt", ""), source_tag="img")
338
-
339
- # Strategy 2: srcset on <img>
340
  for img in soup.find_all("img", srcset=True):
341
  for part in img["srcset"].split(","):
342
  part = part.strip()
343
- if part:
344
- _add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
345
-
346
- # Strategy 3: <picture> with <source>
347
  for picture in soup.find_all("picture"):
348
  for source in picture.find_all("source"):
349
- srcset = source.get("srcset", "")
350
  for part in srcset.split(","):
351
  part = part.strip()
352
- if part:
353
- _add_image(part.split(" ")[0], source_tag="picture/srcset")
354
  fallback_img = picture.find("img")
355
- if fallback_img:
356
- _add_image(
357
- fallback_img.get("src") or fallback_img.get("data-src"),
358
- alt=fallback_img.get("alt", ""),
359
- source_tag="picture/img"
360
- )
361
-
362
- # Strategy 4: WordPress CMS patterns
363
  for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
364
- _add_image(img.get("data-src") or img.get("src"),
365
- alt=img.get("alt", ""), source_tag="wp-image")
366
-
367
- # Strategy 5: Background images in style attributes
368
  for tag in soup.find_all(style=re.compile(r"background-image")):
369
- for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
370
- _add_image(m, source_tag="background-style")
371
-
372
- # Strategy 6: og:image (featured/hero image)
373
  og_image = soup.find("meta", property="og:image")
374
- if og_image and og_image.get("content"):
375
- _add_image(og_image["content"], source_tag="og:image")
376
-
377
- # Strategy 7: twitter:image
378
- tw_image = soup.find("meta", attrs={"name": "twitter:image"})
379
- if tw_image and tw_image.get("content"):
380
- _add_image(tw_image["content"], source_tag="twitter:image")
381
-
382
- # Strategy 8: <figure> with <figcaption>
383
  for figure in soup.find_all("figure"):
384
  img = figure.find("img")
385
  if img:
386
  src = img.get("data-src") or img.get("src")
387
  figcaption = figure.find("figcaption")
388
- alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
389
  _add_image(src, alt=alt, source_tag="figure")
390
-
391
- # Strategy 9: <a> tags linking to images
392
  for a in soup.find_all("a", href=True):
393
  href = a["href"]
394
- if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]):
395
- _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
396
-
397
- # Strategy 10: VnExpress-specific — fig-parent / fig-image containers
398
- for fig_parent in soup.find_all(class_=re.compile(r"fig-parent|fig-image|image|thumb")):
399
- img = fig_parent.find("img")
400
- if img:
401
- src = img.get("data-src") or img.get("src") or img.get("data-original")
402
- _add_image(src, alt=img.get("alt", ""), source_tag="fig-parent")
403
- # Also check for background-image in fig-parent
404
- style = fig_parent.get("style", "")
405
- for m in re.findall(r'url\(["\']?(.*?)["\']?\)', style):
406
- _add_image(m, source_tag="fig-parent-bg")
407
-
408
- # Strategy 11: Dân Trí / VietnamNet — image in .content-detail, .article-body
409
- for container in soup.find_all(class_=re.compile(r"content-detail|article-body|article_content|detail-content|singular-content|fck_detail|knc-content|cms-body|article__body")):
410
- for img in container.find_all("img"):
411
- src = img.get("data-src") or img.get("src") or img.get("data-original")
412
- _add_image(src, alt=img.get("alt", ""), source_tag="content-block-img")
413
-
414
- # Strategy 12: Generic — any <img> inside <article> or <main>
415
- for scope in soup.find_all(["article", "main"]):
416
- for img in scope.find_all("img"):
417
- src = img.get("data-src") or img.get("src") or img.get("data-original")
418
- _add_image(src, alt=img.get("alt", ""), source_tag="article-scope")
419
-
420
  return images
421
 
422
-
423
  # ===== JINA READER =====
424
  def _reader_url(target_url: str) -> str:
425
  safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
426
  return "https://r.jina.ai/http://" + safe
427
-
428
  def jina_reader_markdown(url: str) -> str:
429
  jr = _reader_url(url)
430
- r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
431
- r.raise_for_status()
432
- return r.text or ""
433
-
434
  def _parse_jina_markdown(md: str, url: str):
435
- lines = [x.rstrip() for x in (md or "").splitlines()]
436
- title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
437
  for ln in lines:
438
- if ln.startswith("Title:") and not title:
439
- title = _clean_text(ln.replace("Title:", "", 1)); continue
440
- if ln.startswith("URL Source:"):
441
- continue
442
- if ln.startswith("Markdown Content:"):
443
- in_content = True; continue
444
- # Extract ALL images from markdown ![alt](url)
445
- for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
446
- img_url = mimg.group(1)
447
- if img_url not in all_images:
448
- all_images.append(img_url)
449
- if not first_image:
450
- first_image = img_url
451
  if in_content or (title and not ln.startswith("Title:")):
452
- if ln.strip():
453
- content_lines.append(ln)
454
- text = "\n".join(content_lines)
455
- text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
456
- paras = []
457
- for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
458
- t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
459
- if len(t) >= 40:
460
- paras.append(t)
461
- if len(paras) >= 35:
462
- break
463
- if not title and paras:
464
- title = paras[0][:90]
465
- return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
466
- "text": "\n".join(paras), "image": first_image,
467
- "images": all_images, "via": "jina"}
468
-
469
-
470
- # ===== WEB SCRAPE (with full image extraction) =====
471
  def _best_content_block(soup):
472
- best, best_score = None, 0
473
- for el in soup.find_all(["article", "main", "section", "div"]):
474
- ps = el.find_all("p")
475
- txt = " ".join(p.get_text(" ", strip=True) for p in ps)
476
- score = len(ps) * 100 + len(txt)
477
- cls = " ".join(el.get("class", []))
478
- if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
479
- score += 800
480
- if score > best_score:
481
- best, best_score = el, score
482
  return best
483
-
484
  def scrape_any_url_direct(url: str):
485
- r = requests.get(url, headers=HEADERS, timeout=18)
486
- if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
487
- raise RuntimeError(f"blocked status {r.status_code}")
488
- r.encoding = "utf-8"
489
- soup = BeautifulSoup(r.text, "lxml")
490
- for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
491
- tag.decompose()
492
-
493
- # Title
494
- title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
495
  if not title:
496
- ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
497
- title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
498
-
499
- # Summary
500
- desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
501
- summary = desc_tag.get("content", "") if desc_tag else ""
502
-
503
- # Featured image (og:image)
504
- img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
505
- image = img_tag.get("content", "") if img_tag else ""
506
- if image and image.startswith("//"):
507
- image = "https:" + image
508
-
509
- # Extract ALL images from the article
510
- all_images = _extract_all_images(soup, url)
511
- image_urls = [img["url"] for img in all_images]
512
-
513
- # Ensure featured image is first
514
- if image and image not in image_urls:
515
- image_urls.insert(0, image)
516
- elif image in image_urls:
517
- image_urls.remove(image)
518
- image_urls.insert(0, image)
519
-
520
- # Content paragraphs
521
- block = _best_content_block(soup) or soup
522
- paras, seen_p = [], set()
523
  for p in block.find_all("p"):
524
- t = _clean_text(p.get_text(" ", strip=True))
525
- if len(t) >= 40 and t not in seen_p:
526
- seen_p.add(t)
527
- paras.append(t)
528
- if len(paras) >= 35:
529
- break
530
-
531
- if not title and paras:
532
- title = paras[0][:90]
533
-
534
- return {
535
- "url": url, "title": title or url, "summary": paras[0] if paras else "",
536
- "text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
537
- "images": image_urls, "via": _domain(url)
538
- }
539
-
540
  def scrape_any_url(url: str):
541
- """Try direct scrape first, fall back to Jina Reader.
542
-
543
- KEY FIX: When falling back to Jina, MERGE images from both sources
544
- instead of overwriting. Direct scrape often finds more inline images.
545
- """
546
- data = scrape_any_url_direct(url)
547
- raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
548
- direct_images = data.get("images", [])
549
-
550
- if len(raw_text) >= 120:
551
- # Direct scrape got enough content — but still try Jina for more images
552
- # Only use Jina if direct scrape found very few images
553
- if len(direct_images) < 3:
554
- try:
555
- md = jina_reader_markdown(url)
556
- if md:
557
- jr = _parse_jina_markdown(md, url)
558
- if jr.get("images"):
559
- # MERGE: add Jina images that aren't already in direct scrape
560
- existing = set(direct_images)
561
- for img_url in jr["images"]:
562
- if img_url not in existing:
563
- direct_images.append(img_url)
564
- existing.add(img_url)
565
- data["images"] = direct_images
566
- except Exception:
567
- pass
568
- return data
569
-
570
- # Direct scrape didn't get enough content — fall back to Jina
571
  try:
572
- md = jina_reader_markdown(url)
573
  if md:
574
- jr = _parse_jina_markdown(md, url)
575
  if jr.get("text"):
576
- # Keep direct scrape's title/images if Jina's are worse
577
- if data.get("title") and data["title"] != url:
578
- jr["title"] = data["title"]
579
- # MERGE images: direct scrape images + Jina images
580
- merged_images = list(direct_images) # start with direct scrape images
581
- existing = set(merged_images)
582
- for img_url in jr.get("images", []):
583
- if img_url not in existing:
584
- merged_images.append(img_url)
585
- existing.add(img_url)
586
- jr["images"] = merged_images
587
- # Use direct scrape's image as first if available
588
- if data.get("image"):
589
- jr["image"] = data["image"]
590
- jr["via"] = data.get("via", _domain(url)) + " + jina"
591
- return jr
592
- except Exception:
593
- pass
594
  return data
595
 
596
-
597
- # ===== POLLINATIONS IMAGE =====
598
  def pollinations_image_url(topic: str) -> str:
599
- prompt = "editorial illustration, Vietnamese news, " + topic
600
- return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
601
-
602
-
603
- # ===== QWEN AI (strict, concise) =====
604
- async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 700, image_urls: Optional[List[str]] = None):
605
- global LAST_QWEN_ERROR, HF_TOKEN
606
- HF_TOKEN = _hf_token()
607
- if not HF_TOKEN:
608
- LAST_QWEN_ERROR = "Không tìm thấy token"
609
- return None
610
- if not AsyncInferenceClient:
611
- LAST_QWEN_ERROR = "Thiếu huggingface_hub"
612
- return None
613
- errors = []; models = []
614
- for m in [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]:
615
- if m and m not in models:
616
- models.append(m)
617
  for model in models:
618
  try:
619
- client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=90)
620
- content = []
621
- # Collect all images: image_urls list takes priority, fall back to single image_url
622
- all_img_urls = []
623
- if image_urls:
624
- all_img_urls = image_urls[:6] # max 6 images to avoid context overflow
625
- elif image_url:
626
- all_img_urls = [image_url]
627
  for img_u in all_img_urls:
628
- if img_u and img_u.startswith("http"):
629
- content.append({"type": "image_url", "image_url": {"url": img_u}})
630
- content.append({"type": "text", "text": prompt})
631
- messages = [
632
- {"role": "system", "content": (
633
- "Bạn biên tập viên báo điện tử tiếng Việt. "
634
- "NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. "
635
- "QUY TẮC CỨNG: "
636
- "(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. "
637
- "(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
638
- "(3) KHÔNG dùng Markdown (##, **, ---, *). "
639
- "(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'. "
640
- "(5) KHÔNG bịa thông tin ngoài nguồn. "
641
- "(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
642
- "(7) Tối đa 200 từ. Ngắn gọn, súc tích. "
643
- "(8) KHÔNG sao chép nguyên văn từ bài gốc. Viết lại bằng cách diễn đạt khác hoàn toàn."
644
- )},
645
- {"role": "user", "content": content}
646
- ]
647
- resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
648
- txt = (resp.choices[0].message.content or "").strip()
649
- if txt:
650
- LAST_QWEN_ERROR = ""
651
- return txt
652
- except Exception as e:
653
- errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
654
- LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
655
- print("[qwen errors]", LAST_QWEN_ERROR)
656
- return None
657
-
658
 
659
- # ===== TTS GENERATION =====
660
  async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str):
661
- if edge_tts is None:
662
- raise RuntimeError("edge-tts chưa cài đặt")
663
- voice = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])["id"]
664
- pct = int((speed - 1.0) * 100)
665
- rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
666
- communicate = edge_tts.Communicate(text, voice, rate=rate)
667
- await communicate.save(out_path)
668
-
669
  def _generate_tts_gtts(text: str, out_path: str):
670
- if gTTS is None:
671
- raise RuntimeError("gTTS chưa cài đặt")
672
- gTTS(text, lang="vi").save(out_path)
673
-
674
 
675
- # ===== SHORT VIDEO GENERATION =====
676
- def _download_image(url, fallback_topic, out_path):
677
  if url:
678
  try:
679
- r = requests.get(url, headers=HEADERS, timeout=15)
680
- if r.status_code == 200 and len(r.content) > 1000:
681
- with open(out_path, "wb") as f:
682
- f.write(r.content)
683
  return out_path
684
- except Exception:
685
- pass
686
- gen = pollinations_image_url(fallback_topic)
687
  try:
688
- r = requests.get(gen, headers=HEADERS, timeout=25)
689
- if r.status_code == 200 and len(r.content) > 1000:
690
- with open(out_path, "wb") as f:
691
- f.write(r.content)
692
  return out_path
693
- except Exception:
694
- pass
695
- if Image:
696
- Image.new("RGB", (1080, 860), (30, 55, 42)).save(out_path)
697
- return out_path
698
  raise RuntimeError("Không tạo được ảnh")
699
-
700
- def _make_short_frame(post, img_path, out_path):
701
- if Image is None:
702
- raise RuntimeError("Pillow chưa sẵn sàng")
703
- W, H = 1080, 1920
704
- bg = Image.new("RGB", (W, H), (14, 14, 14))
705
  try:
706
- im = Image.open(img_path).convert("RGB")
707
- target = (1080, 860)
708
- im_ratio = im.width / im.height
709
- target_ratio = target[0] / target[1]
710
- if im_ratio > target_ratio:
711
- new_h = target[1]; new_w = int(new_h * im_ratio)
712
- else:
713
- new_w = target[0]; new_h = int(new_w / im_ratio)
714
- im = im.resize((new_w, new_h))
715
- left = (new_w - target[0]) // 2; top = (new_h - target[1]) // 2
716
- im = im.crop((left, top, left + target[0], top + target[1]))
717
- bg.paste(im, (0, 0))
718
- except Exception:
719
- pass
720
- draw = ImageDraw.Draw(bg)
721
- try:
722
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
723
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
724
- font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
725
- except Exception:
726
- font_title = font_body = font_label = None
727
- draw.rectangle((0, 780, W, H), fill=(14, 14, 14))
728
- draw.text((54, 830), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
729
- title = post.get("title", "")
730
- words = title.split(); lines_t = []; cur = ""
731
  for w in words:
732
- if len(cur) + len(w) + 1 <= 24:
733
- cur = (cur + " " + w).strip()
734
  else:
735
  if cur: lines_t.append(cur)
736
- cur = w
737
  if cur: lines_t.append(cur)
738
- draw.multiline_text((54, 900), "\n".join(lines_t[:3]), fill=(255, 255, 255), font=font_title, spacing=10)
739
- body_text = post.get("text", "")
740
- words_b = body_text.split(); lines_b = []; cur_b = ""
741
  for w in words_b:
742
- if len(cur_b) + len(w) + 1 <= 34:
743
- cur_b = (cur_b + " " + w).strip()
744
  else:
745
  if cur_b: lines_b.append(cur_b)
746
- cur_b = w
747
- if len(lines_b) >= 10:
748
- break
749
- if cur_b and len(lines_b) < 10: lines_b.append(cur_b)
750
- draw.multiline_text((54, 1120), "\n".join(lines_b), fill=(220, 220, 220), font=font_body, spacing=12)
751
- bg.save(out_path, quality=92)
752
-
753
- def _short_script(post, max_chars=700):
754
- txt = _clean_text(post.get("text", ""))
755
- if len(txt) > max_chars:
756
- # Trim at complete sentence
757
- trimmed = txt[:max_chars]
758
- last_end = -1
759
- for m in re.finditer(r'[.!?](?=\s|$)', trimmed):
760
- last_end = m.end()
761
- if last_end > 0:
762
- txt = trimmed[:last_end]
763
- else:
764
- txt = trimmed.rsplit(" ", 1)[0] + "."
765
- title = _clean_text(post.get("title", ""))
766
- return f"{title}. {txt}"
767
-
768
- async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str:
769
  try:
770
- os.makedirs(SHORTS_DIR, exist_ok=True)
771
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
772
- if os.path.exists(out_mp4):
773
- return "/api/ai/short-file/" + post_id
774
-
775
- work = os.path.join(SHORTS_DIR, _safe_name(post_id))
776
- os.makedirs(work, exist_ok=True)
777
- img_path = os.path.join(work, "image.jpg")
778
- frame_path = os.path.join(work, "frame.jpg")
779
- audio_path = os.path.join(work, "voice.mp3")
780
-
781
- _download_image(post.get("img"), post.get("title", "AI news"), img_path)
782
- _make_short_frame(post, img_path, frame_path)
783
- script = _short_script(post)
784
-
785
- if voice_id is None:
786
- voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", ""))
787
- if speed is None:
788
- speed = TTS_DEFAULT_SPEED
789
-
790
- try:
791
- await _generate_tts_edge(script, voice_id, speed, audio_path)
792
  except Exception as e:
793
  print(f"[TTS edge-tts error] {e}, falling back to gTTS")
794
- if gTTS:
795
- _generate_tts_gtts(script, audio_path)
796
- else:
797
- return ""
798
-
799
- cmd = ["ffmpeg", "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
800
- "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
801
- "-c:a", "aac", "-b:a", "128k", "-vf", "scale=1080:1920", out_mp4]
802
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
803
- return "/api/ai/short-file/" + post_id
804
- except Exception as e:
805
- print(f"[short video error] {e}")
806
- return ""
807
-
808
-
809
- # ===== MAKE POST =====
810
- def make_post(title, text, image, source_url, kind, sources=None, images=None):
811
- return {
812
- "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
813
- "title": title, "text": text, "img": image, "url": source_url,
814
- "kind": kind, "sources": sources or [], "video": "",
815
- "images": images or [], "ts": int(time.time())
816
- }
817
-
818
-
819
- # ===== SHARED PROMPT BUILDER — STRICT REWRITE =====
820
- def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
821
- image_info = ""
822
  if images:
823
- num = len(images)
824
- if num == 1:
825
- 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)."
826
- else:
827
- 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)."
828
-
829
- return f"""Đọc bài viết dưới đây và viết một bài TÓM TẮT NGẮN trên Tường AI.
830
-
831
- ⚠️ QUY TẮC CỨNG — VI PHẠM = BỎ QUA:
832
- 1. KHÔNG sao chép nguyên văn bất kỳ câu nào từ bài gốc. Phải diễn đạt LẠI hoàn toàn bằng ngôn ngữ riêng.
833
- 2. KHÔNG lặp lại bất kỳ thông tin nào. Mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN.
834
- 3. Nếu 2 câu diễn đạt cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
835
- 4. KHÔNG dùng Markdown (##, **, ---, *).
836
- 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", "Sau đây là", "Dựa trên".
837
- 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
838
- 7. Giữ sự thật, KHÔNG bịa thông tin.
839
- 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
840
- 9. Mỗi câu phải trọn vẹn, không bị ngắt giữa chừng.{image_info}
841
-
842
- Tiêu đề gốc: {title}
843
-
844
- Nội dung gốc:
845
- {raw[:14000]}
846
-
847
- Chỉ viết phần tóm tắt, không thêm gì khác:"""
848
-
849
-
850
- def _build_topic_prompt(topic: str, ctx: str) -> str:
851
- return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
852
-
853
- ⚠️ QUY TẮC CỨNG:
854
- 1. KHÔNG sao chép nguyên văn từ nguồn. Phải diễn đạt LẠI hoàn toàn.
855
- 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.
856
- 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
857
- 4. KHÔNG dùng Markdown (##, **, ---, *).
858
- 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
859
- 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points.
860
- 7. Giữ sự thật, KHÔNG bịa.
861
- 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
862
- 9. Mỗi câu phải trọn vẹn.
863
-
864
- Nguồn thực tế:
865
- {ctx[:12000]}
866
 
867
- Chỉ viết phần tóm tắt, không thêm gì khác:"""
868
-
869
-
870
- # ===== WRITE ENDPOINTS =====
871
  @app.post("/api/rewrite_share")
872
- async def api_rewrite_share(request: Request):
873
- body = await request.json()
874
- url = _clean_text(body.get("url", ""))
875
- if not url.startswith("http"):
876
- return JSONResponse({"error": "missing url"}, status_code=400)
877
- try:
878
- data = scrape_any_url(url)
879
- except Exception as e:
880
- return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
881
- raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
882
- if len(raw) < 60:
883
- return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
884
-
885
- images = data.get("images", [])
886
- prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
887
- text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700)
888
- if not text:
889
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
890
- text = _clean_ai_output(text)
891
- post = make_post(data.get("title") or "Bài viết", text,
892
- images[0] if images else data.get("image", ""),
893
- url, "rewrite", images=images)
894
-
895
- # Auto-generate short video
896
- video_url = await _generate_short_video(post, post["id"])
897
- if video_url:
898
- post["video"] = video_url
899
-
900
- posts = _load_wall()
901
- posts.insert(0, post)
902
- _save_wall(posts)
903
- return JSONResponse({"post": post})
904
-
905
 
906
  @app.post("/api/url_wall")
907
- async def api_url_wall(request: Request):
908
- body = await request.json()
909
- url = _clean_text(body.get("url", ""))
910
- if not url.startswith("http"):
911
- return JSONResponse({"error": "missing url"}, status_code=400)
912
- try:
913
- data = scrape_any_url(url)
914
- except Exception as e:
915
- return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
916
- raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
917
- if len(raw) < 60:
918
- return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
919
-
920
- images = data.get("images", [])
921
- prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
922
- text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700)
923
- if not text:
924
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
925
- text = _clean_ai_output(text)
926
- post = make_post(data.get("title") or "Bài viết", text,
927
- images[0] if images else data.get("image", ""),
928
- url, "url", images=images)
929
-
930
- video_url = await _generate_short_video(post, post["id"])
931
- if video_url:
932
- post["video"] = video_url
933
-
934
- posts = _load_wall()
935
- posts.insert(0, post)
936
- _save_wall(posts)
937
- return JSONResponse({"post": post})
938
-
939
 
940
  @app.post("/api/topic_post")
941
- async def api_topic_post(request: Request):
942
- body = await request.json()
943
- topic = _clean_text(body.get("topic", ""))
944
- if not topic:
945
- return JSONResponse({"error": "missing topic"}, status_code=400)
 
 
 
 
 
 
 
946
 
947
- ctx = _web_context(topic)
948
- if not ctx:
949
- return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
950
-
951
- image = pollinations_image_url(topic)
952
- prompt = _build_topic_prompt(topic, ctx)
953
- text = await qwen_generate(prompt, image_url=image, max_tokens=700)
954
- if not text:
955
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
956
- text = _clean_ai_output(text)
957
- post = make_post(topic, text, image, "", "topic")
958
-
959
- video_url = await _generate_short_video(post, post["id"])
960
- if video_url:
961
- post["video"] = video_url
962
-
963
- posts = _load_wall()
964
- posts.insert(0, post)
965
- _save_wall(posts)
966
- return JSONResponse({"post": post})
967
-
968
-
969
- # ===== WALL ENDPOINTS =====
970
  @app.get("/api/ai_wall")
971
- def api_ai_wall():
972
- return JSONResponse({"posts": _load_wall()[:80]})
973
-
974
  @app.get("/api/wall")
975
- def api_wall():
976
- return JSONResponse({"posts": _load_wall()[:80]})
977
 
978
-
979
- # ===== SHORT VIDEO ENDPOINT =====
980
  @app.post("/api/ai/short/{post_id}")
981
- async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None)):
982
- posts = _load_wall()
983
- post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
984
- if not post:
985
- return JSONResponse({"error": "post not found"}, status_code=404)
986
-
987
- os.makedirs(SHORTS_DIR, exist_ok=True)
988
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
989
-
990
  if os.path.exists(out_mp4) and voice is None and speed is None:
991
- video_url = "/api/ai/short-file/" + post_id
992
- for i, p in enumerate(posts):
993
- if str(p.get("id")) == str(post_id):
994
- posts[i]["video"] = video_url
995
- break
996
- _save_wall(posts)
997
- return JSONResponse({"video": video_url})
998
-
999
- if voice is not None and voice not in TTS_VOICES:
1000
- return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
1001
-
1002
- video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed)
1003
  if video_url:
1004
- for i, p in enumerate(posts):
1005
- if str(p.get("id")) == str(post_id):
1006
- posts[i]["video"] = video_url
1007
- break
1008
- _save_wall(posts)
1009
- return JSONResponse({"video": video_url})
1010
- return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
1011
-
1012
 
1013
  @app.get("/api/ai/short-file/{post_id}")
1014
- def api_ai_short_file(post_id: str):
1015
- path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
1016
- if not os.path.exists(path):
1017
- return JSONResponse({"error": "not found"}, status_code=404)
1018
- return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
1019
-
1020
 
1021
  @app.get("/api/ai/status")
1022
  def api_ai_status():
1023
- return JSONResponse({
1024
- "has_token": bool(_hf_token()),
1025
- "client_imported": AsyncInferenceClient is not None,
1026
- "model": QWEN_VL_MODEL,
1027
- "last_error": LAST_QWEN_ERROR,
1028
- "tts_ready": gTTS is not None or edge_tts is not None,
1029
- "tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
1030
- "tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
1031
- "tts_voice_count": len(TTS_VOICES),
1032
- "tts_default_speed": TTS_DEFAULT_SPEED,
1033
- })
1034
-
1035
 
1036
  @app.get("/api/ai/voices")
1037
  def api_ai_voices():
1038
- voices_out = {}
1039
- for k, v in TTS_VOICES.items():
1040
- voices_out[k] = {
1041
- "name": v["name"],
1042
- "gender": v["gender"],
1043
- "country": v["country"],
1044
- "lang": v["lang"],
1045
- "flag": v["flag"],
1046
- "label": f"{v['flag']} {v['name']} ({v['gender']})",
1047
- }
1048
- return JSONResponse({
1049
- "voices": voices_out,
1050
- "default_voice": TTS_DEFAULT_VOICE,
1051
- "default_speed": TTS_DEFAULT_SPEED,
1052
- "topic_voice_map": TOPIC_VOICE_MAP,
1053
- })
 
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",
 
142
 
143
 
144
  def _detect_voice_for_topic(title: str, text: str) -> str:
145
+ """Auto-detect the best voice based on topic keywords."""
146
  combined = (title + " " + text[:500]).lower()
147
  for keyword, voice_id in TOPIC_VOICE_MAP.items():
148
  if keyword in combined:
 
165
  return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
166
 
167
 
168
+ # ===== CLEAN AI OUTPUT =====
169
  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',
183
  r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
184
  r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
185
  r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
186
  r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
 
 
 
187
  ]
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 = []
 
197
  for part in raw_parts:
198
  n = _norm(part)
 
 
 
 
 
 
 
 
 
 
 
199
  is_dup = False
200
+ if n:
201
+ if n in seen_sentences:
202
+ is_dup = True
 
 
 
 
 
 
 
 
 
 
203
  else:
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:
211
+ is_dup = True
212
+ break
213
  if is_dup:
214
  continue
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 = []
221
  prev_line = ""
 
226
  final_lines.append(line)
227
  prev_line = stripped
228
  result = '\n'.join(final_lines).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  return result
230
 
231
 
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})