Spaces:
Running
Running
Upload ai_ext.py
Browse files
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
|
| 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 |
-
|
| 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
|
| 182 |
-
text = re.sub(r'^
|
| 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 |
-
|
| 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 |
-
|
| 200 |
-
|
| 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 |
-
|
| 221 |
-
|
| 222 |
-
|
| 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 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
if is_dup:
|
| 244 |
continue
|
| 245 |
-
|
| 246 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 309 |
-
|
| 310 |
-
|
| 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
|
| 323 |
-
|
| 324 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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",
|
| 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":
|
| 431 |
-
r.raise_for_status()
|
| 432 |
-
return r.text or ""
|
| 433 |
-
|
| 434 |
def _parse_jina_markdown(md: str, url: str):
|
| 435 |
-
lines
|
| 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 |
-
|
| 440 |
-
if ln.startswith("
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 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 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 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,
|
| 473 |
-
for el in soup.find_all(["article",
|
| 474 |
-
ps
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 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
|
| 486 |
-
if r.status_code in {401,
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 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
|
| 497 |
-
title
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
image
|
| 506 |
-
|
| 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
|
| 525 |
-
if len(t)
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 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 |
-
"""
|
| 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
|
| 573 |
if md:
|
| 574 |
-
jr
|
| 575 |
if jr.get("text"):
|
| 576 |
-
|
| 577 |
-
if data.get("
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 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
|
| 600 |
-
return "https://image.pollinations.ai/prompt/"
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 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
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 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 |
-
|
| 630 |
-
content.
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 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 |
-
|
| 663 |
-
|
| 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 |
-
|
| 672 |
-
gTTS(text, lang="vi").save(out_path)
|
| 673 |
-
|
| 674 |
|
| 675 |
-
|
| 676 |
-
def _download_image(url, fallback_topic, out_path):
|
| 677 |
if url:
|
| 678 |
try:
|
| 679 |
-
r
|
| 680 |
-
if r.status_code
|
| 681 |
-
with open(out_path,
|
| 682 |
-
f.write(r.content)
|
| 683 |
return out_path
|
| 684 |
-
except Exception:
|
| 685 |
-
|
| 686 |
-
gen = pollinations_image_url(fallback_topic)
|
| 687 |
try:
|
| 688 |
-
r
|
| 689 |
-
if r.status_code
|
| 690 |
-
with open(out_path,
|
| 691 |
-
f.write(r.content)
|
| 692 |
return out_path
|
| 693 |
-
except Exception:
|
| 694 |
-
|
| 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 |
-
|
| 701 |
-
|
| 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
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 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)
|
| 733 |
-
cur = (cur + " " + w).strip()
|
| 734 |
else:
|
| 735 |
if cur: lines_t.append(cur)
|
| 736 |
-
cur
|
| 737 |
if cur: lines_t.append(cur)
|
| 738 |
-
draw.multiline_text((54,
|
| 739 |
-
body_text
|
| 740 |
-
words_b = body_text.split(); lines_b = []; cur_b = ""
|
| 741 |
for w in words_b:
|
| 742 |
-
if len(cur_b)
|
| 743 |
-
cur_b = (cur_b + " " + w).strip()
|
| 744 |
else:
|
| 745 |
if cur_b: lines_b.append(cur_b)
|
| 746 |
-
cur_b
|
| 747 |
-
if len(lines_b)
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 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,
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 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 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 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
|
| 824 |
-
if num
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 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:
|
| 873 |
-
body
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 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:
|
| 908 |
-
body
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 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:
|
| 942 |
-
body
|
| 943 |
-
topic
|
| 944 |
-
|
| 945 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 982 |
-
posts =
|
| 983 |
-
|
| 984 |
-
|
| 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
|
| 992 |
-
for i,
|
| 993 |
-
if str(p.get("id"))
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 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,
|
| 1005 |
-
if str(p.get("id"))
|
| 1006 |
-
|
| 1007 |
-
|
| 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:
|
| 1015 |
-
path
|
| 1016 |
-
if not os.path.exists(path):
|
| 1017 |
-
|
| 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,
|
| 1040 |
-
|
| 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})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|