bep40 commited on
Commit
89bccb2
·
verified ·
1 Parent(s): cc8a3ab

Delete restore_runner.py, patch_runtime.py, patch_extra.py, app_entry.py, app_clean.py, wc2026_scraper.py, ai_ext.py, ai_fix2.py, ai_patch.py, app_v2_entry.py, Dockerfile, .gitattributes

Browse files
Files changed (11) hide show
  1. Dockerfile +0 -15
  2. ai_ext.py +0 -876
  3. ai_fix2.py +0 -360
  4. ai_patch.py +0 -739
  5. app_clean.py +0 -69
  6. app_entry.py +0 -17
  7. app_v2_entry.py +0 -286
  8. patch_extra.py +0 -50
  9. patch_runtime.py +0 -274
  10. restore_runner.py +0 -31
  11. wc2026_scraper.py +0 -174
Dockerfile DELETED
@@ -1,15 +0,0 @@
1
- FROM python:3.11-slim
2
-
3
- WORKDIR /app
4
-
5
- RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
6
- RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 lxml jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts
7
-
8
- COPY . .
9
-
10
- EXPOSE 7860
11
-
12
- # CRITICAL: Use app_v2_entry.py which imports ONLY main.py (core APIs)
13
- # This bypasses the entire injection chain (app_run → app_final → ai_runtime_final6)
14
- # Guarantees index_v2.html is served clean without old JS patches
15
- CMD ["uvicorn", "app_v2_entry:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_ext.py DELETED
@@ -1,876 +0,0 @@
1
- """VNEWS AI Extension - rewrite + auto short video generation.
2
- Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
3
- /api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
4
-
5
- Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
6
- TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
7
- """
8
- import os, re, json, time, random, html as html_lib, subprocess, asyncio
9
- from urllib.parse import quote_plus, quote, urlparse, urljoin
10
- from typing import Optional, List, Dict
11
- import requests
12
- from bs4 import BeautifulSoup
13
- from fastapi import Request, Query
14
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
15
-
16
- from main import app
17
-
18
- # Import wall store from main.py so we read/write the SAME file
19
- try:
20
- from main import _load_wall, _save_wall, _web_context # noqa: F401
21
- except ImportError:
22
- _data_dir = "/data" if os.path.isdir("/data") else "/app/data"
23
- _wall_file = os.path.join(_data_dir, "wall_posts.json")
24
- def _load_wall():
25
- try:
26
- if os.path.exists(_wall_file):
27
- with open(_wall_file, "r", encoding="utf-8") as f:
28
- return json.load(f)
29
- except Exception:
30
- pass
31
- return []
32
- def _save_wall(posts):
33
- try:
34
- os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
35
- tmp = _wall_file + ".tmp"
36
- with open(tmp, "w", encoding="utf-8") as f:
37
- json.dump(posts[:100], f, ensure_ascii=False)
38
- os.replace(tmp, _wall_file)
39
- except Exception:
40
- pass
41
- def _web_context(topic):
42
- return ""
43
-
44
- try:
45
- from huggingface_hub import AsyncInferenceClient
46
- except Exception:
47
- AsyncInferenceClient = None
48
- try:
49
- from gtts import gTTS
50
- except Exception:
51
- gTTS = None
52
- try:
53
- from PIL import Image, ImageDraw, ImageFont
54
- except Exception:
55
- Image = ImageDraw = ImageFont = None
56
- try:
57
- import edge_tts
58
- except Exception:
59
- edge_tts = None
60
-
61
-
62
- def _hf_token():
63
- for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
64
- v = os.getenv(k, "").strip()
65
- if v:
66
- return v
67
- return ""
68
-
69
- HF_TOKEN = _hf_token()
70
- QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
71
- DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
72
- SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
73
- HEADERS = {
74
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
75
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
76
- }
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",
123
- }
124
-
125
-
126
- def _detect_voice_for_topic(title: str, text: str) -> str:
127
- """Auto-detect the best voice based on topic keywords."""
128
- combined = (title + " " + text[:500]).lower()
129
- for keyword, voice_id in TOPIC_VOICE_MAP.items():
130
- if keyword in combined:
131
- return voice_id
132
- return TTS_DEFAULT_VOICE
133
-
134
-
135
- # ===== TEXT HELPERS =====
136
- def _clean_text(s: str) -> str:
137
- s = html_lib.unescape(s or "")
138
- return re.sub(r"\s+", " ", s).strip()
139
-
140
- def _domain(u):
141
- try:
142
- return urlparse(u).netloc.replace("www.", "")
143
- except Exception:
144
- return ""
145
-
146
- def _safe_name(s):
147
- return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
148
-
149
-
150
- # ===== CLEAN AI OUTPUT =====
151
- def _clean_ai_output(text: str) -> str:
152
- """Remove markdown artifacts, instruction leakage, and aggressively dedup content."""
153
- if not text:
154
- return ""
155
- text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
156
- text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
157
- text = re.sub(r'\*([^*]+)\*', r'\1', text)
158
- text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
159
- text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
160
- leakage = [
161
- r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
162
- r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
163
- r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
164
- r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
165
- r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
166
- r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
167
- ]
168
- for phrase in leakage:
169
- text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
170
- text = re.sub(r'\n{3,}', '\n\n', text)
171
-
172
- def _norm(s):
173
- return re.sub(r'\s+', ' ', s.strip().lower())
174
-
175
- raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
176
- seen_sentences = set()
177
- unique_parts = []
178
- for part in raw_parts:
179
- n = _norm(part)
180
- is_dup = False
181
- if n:
182
- if n in seen_sentences:
183
- is_dup = True
184
- else:
185
- partial = re.sub(r'\W+', '', n)
186
- for seen in seen_sentences:
187
- seen_clean = re.sub(r'\W+', '', seen)
188
- if partial and seen_clean and (
189
- partial in seen_clean or seen_clean in partial
190
- ):
191
- shorter = min(len(partial), len(seen_clean))
192
- longer = max(len(partial), len(seen_clean))
193
- if shorter > 20 and shorter / longer > 0.75:
194
- is_dup = True
195
- break
196
- if is_dup:
197
- continue
198
- if n:
199
- seen_sentences.add(n)
200
- unique_parts.append(part)
201
-
202
- result = ' '.join(unique_parts).strip()
203
- lines = result.split('\n')
204
- final_lines = []
205
- prev_line = ""
206
- for line in lines:
207
- stripped = line.strip()
208
- if stripped and stripped == prev_line:
209
- continue
210
- final_lines.append(line)
211
- prev_line = stripped
212
- result = '\n'.join(final_lines).strip()
213
- return result
214
-
215
-
216
- # ===== EXTRACT ALL IMAGES FROM ARTICLE =====
217
- def _extract_all_images(soup, base_url: str) -> List[Dict]:
218
- """Extract ALL content images from an article page using multi-strategy approach."""
219
- images = []
220
- seen_urls = set()
221
- skip_patterns = [
222
- "avatar", "icon", "logo", "button", "banner-ad", "tracking",
223
- "beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
224
- "advertisement", "ads", "widget", "sidebar", "footer-logo",
225
- ]
226
-
227
- def _add_image(src: str, alt: str = "", source_tag: str = "img"):
228
- if not src or src.startswith("data:"):
229
- return
230
- abs_url = urljoin(base_url, src.strip())
231
- if abs_url in seen_urls:
232
- return
233
- if any(p in abs_url.lower() for p in skip_patterns):
234
- return
235
- try:
236
- parsed = urlparse(abs_url)
237
- path = parsed.path.lower()
238
- if any(path.endswith(ext) for ext in ['.svg', '.ico', '.gif']):
239
- return
240
- except Exception:
241
- pass
242
- seen_urls.add(abs_url)
243
- images.append({"url": abs_url, "alt": alt, "source": source_tag})
244
-
245
- for img in soup.find_all("img"):
246
- src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
247
- img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
248
- _add_image(src, alt=img.get("alt", ""), source_tag="img")
249
-
250
- for img in soup.find_all("img", srcset=True):
251
- for part in img["srcset"].split(","):
252
- part = part.strip()
253
- if part:
254
- _add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
255
-
256
- for picture in soup.find_all("picture"):
257
- for source in picture.find_all("source"):
258
- srcset = source.get("srcset", "")
259
- for part in srcset.split(","):
260
- part = part.strip()
261
- if part:
262
- _add_image(part.split(" ")[0], source_tag="picture/srcset")
263
- fallback_img = picture.find("img")
264
- if fallback_img:
265
- _add_image(
266
- fallback_img.get("src") or fallback_img.get("data-src"),
267
- alt=fallback_img.get("alt", ""),
268
- source_tag="picture/img"
269
- )
270
-
271
- for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
272
- _add_image(img.get("data-src") or img.get("src"),
273
- alt=img.get("alt", ""), source_tag="wp-image")
274
-
275
- for tag in soup.find_all(style=re.compile(r"background-image")):
276
- for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
277
- _add_image(m, source_tag="background-style")
278
-
279
- og_image = soup.find("meta", property="og:image")
280
- if og_image and og_image.get("content"):
281
- _add_image(og_image["content"], source_tag="og:image")
282
-
283
- tw_image = soup.find("meta", attrs={"name": "twitter:image"})
284
- if tw_image and tw_image.get("content"):
285
- _add_image(tw_image["content"], source_tag="twitter:image")
286
-
287
- for figure in soup.find_all("figure"):
288
- img = figure.find("img")
289
- if img:
290
- src = img.get("data-src") or img.get("src")
291
- figcaption = figure.find("figcaption")
292
- alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
293
- _add_image(src, alt=alt, source_tag="figure")
294
-
295
- for a in soup.find_all("a", href=True):
296
- href = a["href"]
297
- if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]):
298
- _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
299
-
300
- return images
301
-
302
-
303
- # ===== JINA READER =====
304
- def _reader_url(target_url: str) -> str:
305
- safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
306
- return "https://r.jina.ai/http://" + safe
307
-
308
- def jina_reader_markdown(url: str) -> str:
309
- jr = _reader_url(url)
310
- r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
311
- r.raise_for_status()
312
- return r.text or ""
313
-
314
- def _parse_jina_markdown(md: str, url: str):
315
- lines = [x.rstrip() for x in (md or "").splitlines()]
316
- title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
317
- for ln in lines:
318
- if ln.startswith("Title:") and not title:
319
- title = _clean_text(ln.replace("Title:", "", 1)); continue
320
- if ln.startswith("URL Source:"):
321
- continue
322
- if ln.startswith("Markdown Content:"):
323
- in_content = True; continue
324
- for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
325
- img_url = mimg.group(1)
326
- if img_url not in all_images:
327
- all_images.append(img_url)
328
- if not first_image:
329
- first_image = img_url
330
- if in_content or (title and not ln.startswith("Title:")):
331
- if ln.strip():
332
- content_lines.append(ln)
333
- text = "\n".join(content_lines)
334
- text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
335
- paras = []
336
- for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
337
- t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
338
- if len(t) >= 40:
339
- paras.append(t)
340
- if len(paras) >= 35:
341
- break
342
- if not title and paras:
343
- title = paras[0][:90]
344
- return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
345
- "text": "\n".join(paras), "image": first_image,
346
- "images": all_images, "via": "jina"}
347
-
348
-
349
- # ===== WEB SCRAPE (with full image extraction) =====
350
- def _best_content_block(soup):
351
- best, best_score = None, 0
352
- for el in soup.find_all(["article", "main", "section", "div"]):
353
- ps = el.find_all("p")
354
- txt = " ".join(p.get_text(" ", strip=True) for p in ps)
355
- score = len(ps) * 100 + len(txt)
356
- cls = " ".join(el.get("class", []))
357
- if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
358
- score += 800
359
- if score > best_score:
360
- best, best_score = el, score
361
- return best
362
-
363
- def scrape_any_url_direct(url: str):
364
- r = requests.get(url, headers=HEADERS, timeout=18)
365
- if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
366
- raise RuntimeError(f"blocked status {r.status_code}")
367
- r.encoding = "utf-8"
368
- soup = BeautifulSoup(r.text, "lxml")
369
- for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
370
- tag.decompose()
371
-
372
- title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
373
- if not title:
374
- ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
375
- title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
376
-
377
- desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
378
- summary = desc_tag.get("content", "") if desc_tag else ""
379
-
380
- img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
381
- image = img_tag.get("content", "") if img_tag else ""
382
- if image and image.startswith("//"):
383
- image = "https:" + image
384
-
385
- all_images = _extract_all_images(soup, url)
386
- image_urls = [img["url"] for img in all_images]
387
-
388
- if image and image not in image_urls:
389
- image_urls.insert(0, image)
390
- elif image in image_urls:
391
- image_urls.remove(image)
392
- image_urls.insert(0, image)
393
-
394
- block = _best_content_block(soup) or soup
395
- paras, seen_p = [], set()
396
- for p in block.find_all("p"):
397
- t = _clean_text(p.get_text(" ", strip=True))
398
- if len(t) >= 40 and t not in seen_p:
399
- seen_p.add(t)
400
- paras.append(t)
401
- if len(paras) >= 35:
402
- break
403
-
404
- if not title and paras:
405
- title = paras[0][:90]
406
-
407
- return {
408
- "url": url, "title": title or url, "summary": paras[0] if paras else "",
409
- "text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
410
- "images": image_urls, "via": _domain(url)
411
- }
412
-
413
- def scrape_any_url(url: str):
414
- """Try direct scrape first, fall back to Jina Reader."""
415
- data = scrape_any_url_direct(url)
416
- raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
417
- if len(raw_text) >= 120:
418
- return data
419
- try:
420
- md = jina_reader_markdown(url)
421
- if md:
422
- jr = _parse_jina_markdown(md, url)
423
- if jr.get("text"):
424
- if data.get("title") and data["title"] != url:
425
- jr["title"] = data["title"]
426
- if data.get("image"):
427
- jr["image"] = data["image"]
428
- if data.get("images"):
429
- jr["images"] = data["images"]
430
- jr["via"] = data.get("via", _domain(url)) + " + jina"
431
- return jr
432
- except Exception:
433
- pass
434
- return data
435
-
436
-
437
- # ===== POLLINATIONS IMAGE =====
438
- def pollinations_image_url(topic: str) -> str:
439
- prompt = "editorial illustration, Vietnamese news, " + topic
440
- return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
441
-
442
-
443
- # ===== QWEN AI (strict, concise) =====
444
- async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, image_urls: Optional[List[str]] = None):
445
- global LAST_QWEN_ERROR, HF_TOKEN
446
- HF_TOKEN = _hf_token()
447
- if not HF_TOKEN:
448
- LAST_QWEN_ERROR = "Không tìm thấy token"
449
- return None
450
- if not AsyncInferenceClient:
451
- LAST_QWEN_ERROR = "Thiếu huggingface_hub"
452
- return None
453
- errors = []; models = []
454
- for m in [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]:
455
- if m and m not in models:
456
- models.append(m)
457
- for model in models:
458
- try:
459
- client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=90)
460
- content = []
461
- all_img_urls = []
462
- if image_urls:
463
- all_img_urls = image_urls[:6]
464
- elif image_url:
465
- all_img_urls = [image_url]
466
- for img_u in all_img_urls:
467
- if img_u and img_u.startswith("http"):
468
- content.append({"type": "image_url", "image_url": {"url": img_u}})
469
- content.append({"type": "text", "text": prompt})
470
- messages = [
471
- {"role": "system", "content": (
472
- "Bạn là biên tập viên báo điện tử tiếng Việt. "
473
- "NHIỆM VỤ: Chỉ TÓM TắT nội dung, KHÔNG viết lại bài đầy đủ. "
474
- "QUY TẮC CỨNG: "
475
- "(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
476
- "(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
477
- "(3) KHÔNG dùng Markdown (##, **, ---, *). "
478
- "(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
479
- "(5) KHÔNG bịa thông tin ngoài nguồn. "
480
- "(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
481
- "(7) Tối đa 200 từ. Ngắn gọn, súc tích."
482
- )},
483
- {"role": "user", "content": content}
484
- ]
485
- resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
486
- txt = (resp.choices[0].message.content or "").strip()
487
- if txt:
488
- LAST_QWEN_ERROR = ""
489
- return txt
490
- except Exception as e:
491
- errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
492
- LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
493
- print("[qwen errors]", LAST_QWEN_ERROR)
494
- return None
495
-
496
-
497
- # ===== TTS GENERATION =====
498
- async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str):
499
- if edge_tts is None:
500
- raise RuntimeError("edge-tts chưa cài đặt")
501
- voice = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])["id"]
502
- pct = int((speed - 1.0) * 100)
503
- rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
504
- communicate = edge_tts.Communicate(text, voice, rate=rate)
505
- await communicate.save(out_path)
506
-
507
- def _generate_tts_gtts(text: str, out_path: str):
508
- if gTTS is None:
509
- raise RuntimeError("gTTS chưa cài đặt")
510
- gTTS(text, lang="vi").save(out_path)
511
-
512
-
513
- # ===== SHORT VIDEO GENERATION =====
514
- def _download_image(url, fallback_topic, out_path):
515
- if url:
516
- try:
517
- r = requests.get(url, headers=HEADERS, timeout=15)
518
- if r.status_code == 200 and len(r.content) > 1000:
519
- with open(out_path, "wb") as f:
520
- f.write(r.content)
521
- return out_path
522
- except Exception:
523
- pass
524
- gen = pollinations_image_url(fallback_topic)
525
- try:
526
- r = requests.get(gen, headers=HEADERS, timeout=25)
527
- if r.status_code == 200 and len(r.content) > 1000:
528
- with open(out_path, "wb") as f:
529
- f.write(r.content)
530
- return out_path
531
- except Exception:
532
- pass
533
- if Image:
534
- Image.new("RGB", (1080, 860), (30, 55, 42)).save(out_path)
535
- return out_path
536
- raise RuntimeError("Không tạo được ảnh")
537
-
538
- def _make_short_frame(post, img_path, out_path):
539
- if Image is None:
540
- raise RuntimeError("Pillow chưa sẵn sàng")
541
- W, H = 1080, 1920
542
- bg = Image.new("RGB", (W, H), (14, 14, 14))
543
- try:
544
- im = Image.open(img_path).convert("RGB")
545
- target = (1080, 860)
546
- im_ratio = im.width / im.height
547
- target_ratio = target[0] / target[1]
548
- if im_ratio > target_ratio:
549
- new_h = target[1]; new_w = int(new_h * im_ratio)
550
- else:
551
- new_w = target[0]; new_h = int(new_w / im_ratio)
552
- im = im.resize((new_w, new_h))
553
- left = (new_w - target[0]) // 2; top = (new_h - target[1]) // 2
554
- im = im.crop((left, top, left + target[0], top + target[1]))
555
- bg.paste(im, (0, 0))
556
- except Exception:
557
- pass
558
- draw = ImageDraw.Draw(bg)
559
- try:
560
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
561
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
562
- font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
563
- except Exception:
564
- font_title = font_body = font_label = None
565
- draw.rectangle((0, 780, W, H), fill=(14, 14, 14))
566
- draw.text((54, 830), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
567
- title = post.get("title", "")
568
- words = title.split(); lines_t = []; cur = ""
569
- for w in words:
570
- if len(cur) + len(w) + 1 <= 24:
571
- cur = (cur + " " + w).strip()
572
- else:
573
- if cur: lines_t.append(cur)
574
- cur = w
575
- if cur: lines_t.append(cur)
576
- draw.multiline_text((54, 900), "\n".join(lines_t[:3]), fill=(255, 255, 255), font=font_title, spacing=10)
577
- body_text = post.get("text", "")
578
- words_b = body_text.split(); lines_b = []; cur_b = ""
579
- for w in words_b:
580
- if len(cur_b) + len(w) + 1 <= 34:
581
- cur_b = (cur_b + " " + w).strip()
582
- else:
583
- if cur_b: lines_b.append(cur_b)
584
- cur_b = w
585
- if len(lines_b) >= 10:
586
- break
587
- if cur_b and len(lines_b) < 10: lines_b.append(cur_b)
588
- draw.multiline_text((54, 1120), "\n".join(lines_b), fill=(220, 220, 220), font=font_body, spacing=12)
589
- bg.save(out_path, quality=92)
590
-
591
- def _short_script(post, max_chars=700):
592
- txt = _clean_text(post.get("text", ""))
593
- if len(txt) > max_chars:
594
- txt = txt[:max_chars].rsplit(" ", 1)[0] + "."
595
- title = _clean_text(post.get("title", ""))
596
- return f"{title}. {txt}"
597
-
598
- async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str:
599
- try:
600
- os.makedirs(SHORTS_DIR, exist_ok=True)
601
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
602
- if os.path.exists(out_mp4):
603
- return "/api/ai/short-file/" + post_id
604
-
605
- work = os.path.join(SHORTS_DIR, _safe_name(post_id))
606
- os.makedirs(work, exist_ok=True)
607
- img_path = os.path.join(work, "image.jpg")
608
- frame_path = os.path.join(work, "frame.jpg")
609
- audio_path = os.path.join(work, "voice.mp3")
610
-
611
- _download_image(post.get("img"), post.get("title", "AI news"), img_path)
612
- _make_short_frame(post, img_path, frame_path)
613
- script = _short_script(post)
614
-
615
- if voice_id is None:
616
- voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", ""))
617
- if speed is None:
618
- speed = TTS_DEFAULT_SPEED
619
-
620
- try:
621
- await _generate_tts_edge(script, voice_id, speed, audio_path)
622
- except Exception as e:
623
- print(f"[TTS edge-tts error] {e}, falling back to gTTS")
624
- if gTTS:
625
- _generate_tts_gtts(script, audio_path)
626
- else:
627
- return ""
628
-
629
- cmd = ["ffmpeg", "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
630
- "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
631
- "-c:a", "aac", "-b:a", "128k", "-vf", "scale=1080:1920", out_mp4]
632
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
633
- return "/api/ai/short-file/" + post_id
634
- except Exception as e:
635
- print(f"[short video error] {e}")
636
- return ""
637
-
638
-
639
- # ===== MAKE POST =====
640
- def make_post(title, text, image, source_url, kind, sources=None, images=None):
641
- return {
642
- "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
643
- "title": title, "text": text, "img": image, "url": source_url,
644
- "kind": kind, "sources": sources or [], "video": "",
645
- "images": images or [], "ts": int(time.time())
646
- }
647
-
648
-
649
- # ===== SHARED PROMPT BUILDER =====
650
- def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
651
- image_info = ""
652
- if images:
653
- num = len(images)
654
- if num == 1:
655
- image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
656
- else:
657
- image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
658
-
659
- return f"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI.
660
-
661
- QUY TẮC BẮT BUỘC:
662
- 1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc.
663
- 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
664
- 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
665
- 4. KHÔNG dùng Markdown (##, **, ---, *).
666
- 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt".
667
- 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
668
- 7. Giữ sự thật, KHÔNG bịa thông tin.
669
- 8. Tối đa 200 từ. Ngắn gọn, đủ ý.{image_info}
670
-
671
- Tiêu đề gốc: {title}
672
-
673
- Nội dung gốc:
674
- {raw[:14000]}"""
675
-
676
-
677
- def _build_topic_prompt(topic: str, ctx: str) -> str:
678
- return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
679
-
680
- QUY TẮC BẮT BUỘC:
681
- 1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên văn.
682
- 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
683
- 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
684
- 4. KHÔNG dùng Markdown (##, **, ---, *).
685
- 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
686
- 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points.
687
- 7. Giữ sự thật, KHÔNG bịa.
688
- 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
689
-
690
- Nguồn thực tế:
691
- {ctx[:12000]}"""
692
-
693
-
694
- # ===== WRITE ENDPOINTS =====
695
- @app.post("/api/rewrite_share")
696
- async def api_rewrite_share(request: Request):
697
- body = await request.json()
698
- url = _clean_text(body.get("url", ""))
699
- if not url.startswith("http"):
700
- return JSONResponse({"error": "missing url"}, status_code=400)
701
- try:
702
- data = scrape_any_url(url)
703
- except Exception as e:
704
- return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
705
- raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
706
- if len(raw) < 60:
707
- return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
708
-
709
- images = data.get("images", [])
710
- prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
711
- text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=500)
712
- if not text:
713
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
714
- text = _clean_ai_output(text)
715
- post = make_post(data.get("title") or "Bài viết", text,
716
- images[0] if images else data.get("image", ""),
717
- url, "rewrite", images=images)
718
-
719
- video_url = await _generate_short_video(post, post["id"])
720
- if video_url:
721
- post["video"] = video_url
722
-
723
- posts = _load_wall()
724
- posts.insert(0, post)
725
- _save_wall(posts)
726
- return JSONResponse({"post": post})
727
-
728
-
729
- @app.post("/api/url_wall")
730
- async def api_url_wall(request: Request):
731
- body = await request.json()
732
- url = _clean_text(body.get("url", ""))
733
- if not url.startswith("http"):
734
- return JSONResponse({"error": "missing url"}, status_code=400)
735
- try:
736
- data = scrape_any_url(url)
737
- except Exception as e:
738
- return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
739
- raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
740
- if len(raw) < 60:
741
- return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
742
-
743
- images = data.get("images", [])
744
- prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
745
- text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=500)
746
- if not text:
747
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
748
- text = _clean_ai_output(text)
749
- post = make_post(data.get("title") or "Bài viết", text,
750
- images[0] if images else data.get("image", ""),
751
- url, "url", images=images)
752
-
753
- video_url = await _generate_short_video(post, post["id"])
754
- if video_url:
755
- post["video"] = video_url
756
-
757
- posts = _load_wall()
758
- posts.insert(0, post)
759
- _save_wall(posts)
760
- return JSONResponse({"post": post})
761
-
762
-
763
- @app.post("/api/topic_post")
764
- async def api_topic_post(request: Request):
765
- body = await request.json()
766
- topic = _clean_text(body.get("topic", ""))
767
- if not topic:
768
- return JSONResponse({"error": "missing topic"}, status_code=400)
769
-
770
- ctx = _web_context(topic)
771
- if not ctx:
772
- return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
773
-
774
- image = pollinations_image_url(topic)
775
- prompt = _build_topic_prompt(topic, ctx)
776
- text = await qwen_generate(prompt, image_url=image, max_tokens=500)
777
- if not text:
778
- return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
779
- text = _clean_ai_output(text)
780
- post = make_post(topic, text, image, "", "topic")
781
-
782
- video_url = await _generate_short_video(post, post["id"])
783
- if video_url:
784
- post["video"] = video_url
785
-
786
- posts = _load_wall()
787
- posts.insert(0, post)
788
- _save_wall(posts)
789
- return JSONResponse({"post": post})
790
-
791
-
792
- # ===== WALL ENDPOINTS =====
793
- @app.get("/api/ai_wall")
794
- def api_ai_wall():
795
- return JSONResponse({"posts": _load_wall()[:80]})
796
-
797
- @app.get("/api/wall")
798
- def api_wall():
799
- return JSONResponse({"posts": _load_wall()[:80]})
800
-
801
-
802
- # ===== SHORT VIDEO ENDPOINT (with voice + speed params) =====
803
- @app.post("/api/ai/short/{post_id}")
804
- async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None)):
805
- posts = _load_wall()
806
- post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
807
- if not post:
808
- return JSONResponse({"error": "post not found"}, status_code=404)
809
-
810
- os.makedirs(SHORTS_DIR, exist_ok=True)
811
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
812
-
813
- if os.path.exists(out_mp4) and voice is None and speed is None:
814
- video_url = "/api/ai/short-file/" + post_id
815
- for i, p in enumerate(posts):
816
- if str(p.get("id")) == str(post_id):
817
- posts[i]["video"] = video_url
818
- break
819
- _save_wall(posts)
820
- return JSONResponse({"video": video_url})
821
-
822
- if voice is not None and voice not in TTS_VOICES:
823
- return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
824
-
825
- video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed)
826
- if video_url:
827
- for i, p in enumerate(posts):
828
- if str(p.get("id")) == str(post_id):
829
- posts[i]["video"] = video_url
830
- break
831
- _save_wall(posts)
832
- return JSONResponse({"video": video_url})
833
- return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
834
-
835
-
836
- @app.get("/api/ai/short-file/{post_id}")
837
- def api_ai_short_file(post_id: str):
838
- path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
839
- if not os.path.exists(path):
840
- return JSONResponse({"error": "not found"}, status_code=404)
841
- return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
842
-
843
-
844
- @app.get("/api/ai/status")
845
- def api_ai_status():
846
- return JSONResponse({
847
- "has_token": bool(_hf_token()),
848
- "client_imported": AsyncInferenceClient is not None,
849
- "model": QWEN_VL_MODEL,
850
- "last_error": LAST_QWEN_ERROR,
851
- "tts_ready": gTTS is not None or edge_tts is not None,
852
- "tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
853
- "tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
854
- "tts_voice_count": len(TTS_VOICES),
855
- "tts_default_speed": TTS_DEFAULT_SPEED,
856
- })
857
-
858
-
859
- @app.get("/api/ai/voices")
860
- def api_ai_voices():
861
- voices_out = {}
862
- for k, v in TTS_VOICES.items():
863
- voices_out[k] = {
864
- "name": v["name"],
865
- "gender": v["gender"],
866
- "country": v["country"],
867
- "lang": v["lang"],
868
- "flag": v["flag"],
869
- "label": f"{v['flag']} {v['name']} ({v['gender']})",
870
- }
871
- return JSONResponse({
872
- "voices": voices_out,
873
- "default_voice": TTS_DEFAULT_VOICE,
874
- "default_speed": TTS_DEFAULT_SPEED,
875
- "topic_voice_map": TOPIC_VOICE_MAP,
876
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_fix2.py DELETED
@@ -1,360 +0,0 @@
1
- import os, re, subprocess, html as html_lib, json
2
- from urllib.parse import quote_plus, urlparse, parse_qs, unquote
3
- import requests
4
- import ai_patch as prev
5
- from ai_patch import app
6
- from fastapi import Request
7
- from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
8
-
9
- base = prev.base
10
-
11
-
12
- def clean(s):
13
- return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
14
-
15
-
16
- def _is_real_article_text(raw):
17
- raw = clean(raw)
18
- if len(raw) < 500:
19
- return False
20
- sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
21
- long_sentences = [s for s in sentences if len(s) > 45]
22
- return len(long_sentences) >= 5
23
-
24
-
25
- def _extract_ddg_url(href):
26
- if not href:
27
- return ""
28
- if href.startswith("//"):
29
- href = "https:" + href
30
- if "duckduckgo.com/l/" in href:
31
- try:
32
- qs = parse_qs(urlparse(href).query)
33
- if qs.get("uddg"):
34
- return unquote(qs["uddg"][0])
35
- except Exception:
36
- pass
37
- return href
38
-
39
-
40
- def _ddg_article_urls(topic, limit=12):
41
- urls = []
42
- try:
43
- q = quote_plus(topic + " tin tức bài viết phân tích")
44
- r = requests.get("https://html.duckduckgo.com/html/?q=" + q, headers=base.HEADERS, timeout=18)
45
- r.encoding = "utf-8"
46
- from bs4 import BeautifulSoup
47
- soup = BeautifulSoup(r.text, "lxml")
48
- for a in soup.select("a.result__a"):
49
- u = _extract_ddg_url(a.get("href", ""))
50
- if not u.startswith("http"):
51
- continue
52
- if any(bad in u for bad in ["google.com", "youtube.com", "facebook.com", "x.com", "twitter.com"]):
53
- continue
54
- if u not in urls:
55
- urls.append(u)
56
- if len(urls) >= limit:
57
- break
58
- except Exception:
59
- pass
60
- return urls
61
-
62
-
63
- def _rss_article_urls(topic, limit=10):
64
- out = []
65
- try:
66
- url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
67
- r = requests.get(url, headers=base.HEADERS, timeout=15)
68
- r.encoding = "utf-8"
69
- from bs4 import BeautifulSoup
70
- soup = BeautifulSoup(r.text, "xml")
71
- for it in soup.find_all("item")[:limit]:
72
- title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
73
- link = it.find("link").get_text(strip=True) if it.find("link") else ""
74
- src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
75
- if title and link:
76
- out.append({"title": title, "url": link, "via": src, "excerpt": title})
77
- except Exception:
78
- pass
79
- return out
80
-
81
-
82
- def _topic_source_articles(topic, limit=5):
83
- """Scrape actual article bodies. Do not accept title-only sources."""
84
- candidates = []
85
- seen = set()
86
-
87
- for u in _ddg_article_urls(topic, limit=14):
88
- if u not in seen:
89
- seen.add(u)
90
- candidates.append({"url": u, "title": "", "via": base._domain(u)})
91
-
92
- try:
93
- _ctx, srcs = base.web_context(topic, limit=8)
94
- for s in srcs or []:
95
- u = s.get("url") or ""
96
- if u.startswith("http") and u not in seen:
97
- seen.add(u)
98
- candidates.append(s)
99
- except Exception:
100
- pass
101
-
102
- for s in _rss_article_urls(topic, limit=10):
103
- u = s.get("url") or ""
104
- if u.startswith("http") and u not in seen:
105
- seen.add(u)
106
- candidates.append(s)
107
-
108
- out = []
109
- for s in candidates[:24]:
110
- url = s.get("url") or ""
111
- try:
112
- page = base.scrape_any_url(url)
113
- raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
114
- if not _is_real_article_text(raw):
115
- continue
116
- title = page.get("title") or s.get("title") or url
117
- via = page.get("via") or s.get("via") or base._domain(url)
118
- out.append({
119
- "title": title,
120
- "url": url,
121
- "raw": raw,
122
- "image": page.get("image") or "",
123
- "via": via,
124
- "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
125
- })
126
- if len(out) >= limit:
127
- break
128
- except Exception:
129
- continue
130
- return out[:limit]
131
-
132
-
133
- def sentence_split(text):
134
- text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
135
- text = re.sub(r"\n+", ". ", text)
136
- parts = []
137
- for s in re.split(r"(?<=[\.\!\?])\s+", text):
138
- s = clean(s)
139
- if len(s) >= 8:
140
- parts.append(s)
141
- return parts
142
-
143
-
144
- def srt_time(sec):
145
- ms = int((sec - int(sec)) * 1000)
146
- sec = int(sec)
147
- return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
148
-
149
-
150
- def parse_timecode(t):
151
- t = t.replace(',', '.')
152
- parts = t.split(':')
153
- if len(parts) == 3:
154
- return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
155
- if len(parts) == 2:
156
- return int(parts[0])*60 + float(parts[1])
157
- return float(parts[0])
158
-
159
-
160
- def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
161
- try:
162
- txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
163
- cues = []
164
- i = 0
165
- while i < len(txt):
166
- line = txt[i].strip()
167
- if '-->' in line:
168
- a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
169
- start = parse_timecode(a) / speed
170
- end = parse_timecode(b) / speed
171
- i += 1
172
- texts = []
173
- while i < len(txt) and txt[i].strip():
174
- texts.append(txt[i].strip())
175
- i += 1
176
- s = clean(' '.join(texts))
177
- if s:
178
- cues.append((start, end, s))
179
- i += 1
180
- if not cues:
181
- return False
182
- with open(srt_path, 'w', encoding='utf-8') as f:
183
- for idx, (st, en, s) in enumerate(cues, 1):
184
- if en <= st:
185
- en = st + 1.2
186
- f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
187
- return True
188
- except Exception:
189
- return False
190
-
191
-
192
- def write_weighted_srt(script, path, total_duration):
193
- subs = sentence_split(script)
194
- if not subs:
195
- subs = [clean(script)[:140] or "VNEWS"]
196
- total_chars = max(1, sum(len(x) for x in subs))
197
- usable = max(2.0, float(total_duration) - 1.0)
198
- cur = 0.5
199
- with open(path, "w", encoding="utf-8") as f:
200
- for i, s in enumerate(subs, 1):
201
- dur = max(1.8, min(7.0, usable * len(s) / total_chars))
202
- start = cur
203
- end = min(total_duration - 0.15, cur + dur)
204
- cur = end + 0.18
205
- f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
206
- if cur >= total_duration - 0.2:
207
- break
208
-
209
-
210
- def tts_script_full(post, emotion):
211
- title = clean(post.get("title", ""))
212
- text = clean(post.get("text", ""))
213
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
214
- prefix = {
215
- "urgent": "Tin nhanh.",
216
- "warm": "Câu chuyện đáng chú ý.",
217
- "serious": "Bản tin nghiêm túc.",
218
- "energetic": "Cập nhật nổi bật.",
219
- }.get(emotion, "")
220
- script = f"{prefix} {title}. {text}".strip()
221
- if len(script) > 3600:
222
- tmp = script[:3600]
223
- cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
224
- script = tmp[:cut + 1] if cut > 1600 else tmp
225
- script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
226
- script = re.sub(r"\n{2,}", "\n", script).strip()
227
- return script
228
-
229
-
230
- _PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
231
- app.router.routes = [r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
232
-
233
-
234
- @app.post('/api/topic_post')
235
- async def topic_post_aggregate(request: Request):
236
- body = await request.json()
237
- topic = base._clean_text(body.get('topic',''))
238
- if not topic:
239
- return JSONResponse({'error':'missing topic'}, status_code=400)
240
- articles = _topic_source_articles(topic, limit=5)
241
- if not articles:
242
- return JSONResponse({'error':'Không scrape được nội dung bài viết thật cho chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dán URL trực tiếp.'}, status_code=422)
243
- source_blocks = []
244
- sources = []
245
- image = ""
246
- for i, art in enumerate(articles, 1):
247
- raw = art.get('raw','')
248
- source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
249
- sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
250
- if not image and art.get('image'):
251
- image = art.get('image')
252
- ctx = "\n\n".join(source_blocks)
253
- prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
254
-
255
- Chủ đề: {topic}
256
-
257
- NHIỆM VỤ:
258
- - Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
259
- - Tổng hợp thành 1 bản tóm tắt chung duy nhất, giống cách tóm tắt qua URL.
260
- - Không tạo mỗi tiêu đề thành một bài riêng.
261
- - Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
262
- - Không lặp ý giữa các nguồn.
263
- - Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
264
- - Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
265
- - Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
266
-
267
- Nội dung nguồn:
268
- {ctx[:16000]}"""
269
- text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
270
- text = prev._postprocess_ai_text(text, max_units=7)
271
- if 'Nguồn tham khảo:' not in text:
272
- text += '\n\n' + prev._source_line(sources)
273
- post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
274
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
275
- return JSONResponse({'post': post, 'count_sources': len(sources)})
276
-
277
-
278
- @app.post('/api/ai/short/{post_id}')
279
- async def ai_short_full(post_id: str, request: Request):
280
- try:
281
- body = await request.json()
282
- except Exception:
283
- body = {}
284
- voice = str(body.get('voice','nu')).lower().strip()
285
- emotion = str(body.get('emotion','neutral')).lower().strip()
286
- speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
287
- posts = base._load_ai_wall()
288
- post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
289
- if not post:
290
- return JSONResponse({'error':'post not found'}, status_code=404)
291
- os.makedirs(base.SHORTS_DIR, exist_ok=True)
292
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
293
- out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
294
- if os.path.exists(out_mp4):
295
- post['video'] = '/api/ai/short-file/' + post_id + suffix
296
- base._save_ai_wall(posts)
297
- return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
298
- work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
299
- img = os.path.join(work,'image.jpg'); frame = os.path.join(work,'frame.jpg'); audio = os.path.join(work,'voice.mp3'); audio_fast=os.path.join(work,'voice_fast.mp3'); srt=os.path.join(work,'subtitles.srt'); vtt=os.path.join(work,'subtitles.vtt')
300
- try:
301
- base._download_image(post.get('img'), post.get('title','AI news'), img)
302
- prev._make_short_frame_full(post, img, frame)
303
- script = tts_script_full(post, emotion)
304
- edge_voice = {'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
305
- used_edge = False
306
- try:
307
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',script,'--write-media',audio,'--write-subtitles',vtt], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=260)
308
- used_edge = True
309
- except Exception:
310
- tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
311
- try:
312
- base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
313
- except TypeError:
314
- base.gTTS(script, lang='vi', slow=False).save(audio)
315
- subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
316
- duration = 45.0
317
- try:
318
- pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
319
- duration = float((pr.stdout or b'45').decode().strip() or 45)
320
- except Exception:
321
- pass
322
- if used_edge and os.path.exists(vtt):
323
- ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
324
- if not ok:
325
- write_weighted_srt(script, srt, duration)
326
- else:
327
- write_weighted_srt(script, srt, duration)
328
- vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=42'".format(srt.replace("'", "\\'"))
329
- cmd = ['ffmpeg','-y','-loop','1','-i',frame,'-i',audio_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf',vf,out_mp4]
330
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
331
- post['video'] = '/api/ai/short-file/' + post_id + suffix
332
- post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
333
- base._save_ai_wall(posts)
334
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
335
- except Exception as e:
336
- return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
337
-
338
-
339
- @app.get('/api/ai/short-file/{file_id}')
340
- def ai_short_file_full(file_id: str):
341
- path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
342
- if not os.path.exists(path):
343
- return JSONResponse({'error':'not found'}, status_code=404)
344
- return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
345
-
346
-
347
- app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
348
-
349
- @app.get('/')
350
- async def index_fix2():
351
- with open('/app/static/index.html','r',encoding='utf-8') as f:
352
- html = f.read()
353
- inject = prev.PATCH_INJECT + r'''
354
- <script>
355
- (function(){
356
- window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){window.location.reload();alert('Đã tổng hợp NỘI DUNG các bài nguồn thành 1 bản tóm tắt trên Tường AI');}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
357
- })();
358
- </script>
359
- '''
360
- return HTMLResponse(html.replace('</body>', inject+'\n</body>'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_patch.py DELETED
@@ -1,739 +0,0 @@
1
- import os
2
- import re
3
- import time
4
- import random
5
- import json
6
- import html as html_lib
7
- import subprocess
8
- import requests
9
- import ai_ext as base
10
- from ai_ext import app
11
- from fastapi import Request
12
- from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
13
- from bs4 import BeautifulSoup
14
- from urllib.parse import quote_plus
15
-
16
- try:
17
- from PIL import Image, ImageDraw, ImageFont
18
- except Exception:
19
- Image = ImageDraw = ImageFont = None
20
-
21
-
22
- def _clean(s):
23
- s = html_lib.unescape(s or "")
24
- s = re.sub(r"[ \t]+", " ", s)
25
- s = re.sub(r"\n{3,}", "\n\n", s)
26
- return s.strip()
27
-
28
-
29
- def _norm(s):
30
- s = s.lower()
31
- s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
32
- s = re.sub(r"\s+", " ", s).strip()
33
- return s
34
-
35
-
36
- def _similar(a, b):
37
- ta = set(_norm(a).split())
38
- tb = set(_norm(b).split())
39
- if not ta or not tb:
40
- return False
41
- return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
42
-
43
-
44
- def _dedupe_units(units, max_units=7):
45
- out, seen = [], set()
46
- for u in units:
47
- u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
48
- if len(u) < 18:
49
- continue
50
- nu = _norm(u)
51
- if nu in seen:
52
- continue
53
- if any(_similar(u, old) for old in out):
54
- continue
55
- seen.add(nu)
56
- out.append(u)
57
- if len(out) >= max_units:
58
- break
59
- return out
60
-
61
-
62
- def _postprocess_ai_text(text, max_units=7):
63
- text = _clean(text)
64
- if not text:
65
- return text
66
- drop_prefixes = (
67
- "dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
68
- "tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
69
- )
70
- raw_lines = []
71
- for line in re.split(r"\n+", text):
72
- line = _clean(line)
73
- if not line:
74
- continue
75
- low = line.lower().strip()
76
- if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
77
- continue
78
- raw_lines.append(line)
79
- units = []
80
- for line in raw_lines:
81
- if len(line) > 260:
82
- units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
83
- else:
84
- units.append(line)
85
- units = _dedupe_units(units, max_units=max_units)
86
- if not units:
87
- return text[:900]
88
- title = ""
89
- if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
90
- title = raw_lines[0]
91
- units = [u for u in units if not _similar(u, title)]
92
- body = "\n".join("• " + u for u in units[:max_units])
93
- return (title + "\n\n" + body).strip() if title else body
94
-
95
-
96
- def _fallback_summary_from_prompt(prompt, max_units=6):
97
- text = prompt or ""
98
- for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
99
- if marker in text:
100
- text = text.split(marker, 1)[1]
101
- break
102
- text = re.sub(r"https?://\S+", "", text)
103
- text = re.sub(r"\s+", " ", text).strip()
104
- sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
105
- candidates = []
106
- for s in sentences:
107
- s = _clean(s)
108
- if 45 <= len(s) <= 260:
109
- candidates.append(s)
110
- units = _dedupe_units(candidates, max_units=max_units)
111
- if units:
112
- return "\n".join("• " + u for u in units)
113
- if text:
114
- return "• " + text[:700].rsplit(" ", 1)[0]
115
- return "• Không có đủ nội dung nguồn để tóm tắt."
116
-
117
-
118
- def _source_line(sources):
119
- names = []
120
- for s in (sources or [])[:5]:
121
- via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
122
- if via and via not in names:
123
- names.append(via)
124
- return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
125
-
126
-
127
- def _make_summary_prompt(title, raw, source_hint=""):
128
- return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
129
-
130
- NHIỆM VỤ BẮT BUỘC:
131
- - Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
132
- - Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
133
- - Không thêm thông tin ngoài nguồn.
134
- - Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
135
- - Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
136
- - Không viết phần mở bài dài, không viết văn kể lại.
137
-
138
- Tiêu đề nguồn: {title}
139
- Nguồn: {source_hint}
140
-
141
- Nội dung nguồn:
142
- {raw[:14000]}
143
- """
144
-
145
-
146
- def _direct_news_rss(topic, limit=10):
147
- out = []
148
- try:
149
- url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
150
- r = requests.get(url, headers=base.HEADERS, timeout=15)
151
- r.encoding = "utf-8"
152
- soup = BeautifulSoup(r.text, "xml")
153
- for it in soup.find_all("item")[:limit]:
154
- title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
155
- link = it.find("link").get_text(strip=True) if it.find("link") else ""
156
- src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
157
- if title and link:
158
- out.append({"title": title, "url": link, "via": src, "excerpt": title})
159
- except Exception:
160
- pass
161
- return out
162
-
163
-
164
- def _topic_source_articles(topic, limit=5):
165
- """Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
166
- try:
167
- _ctx, sources = base.web_context(topic, limit=limit)
168
- except Exception:
169
- sources = []
170
- if not sources:
171
- sources = _direct_news_rss(topic, limit=10)
172
- out, seen = [], set()
173
- for s in (sources or [])[:limit * 3]:
174
- url = s.get("url") or ""
175
- if not url.startswith("http") or url in seen:
176
- continue
177
- seen.add(url)
178
- try:
179
- page = base.scrape_any_url(url)
180
- raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
181
- if len(raw) < 180:
182
- continue
183
- title = page.get("title") or s.get("title") or url
184
- via = page.get("via") or s.get("via") or base._domain(url)
185
- out.append({
186
- "title": title,
187
- "url": url,
188
- "raw": raw,
189
- "image": page.get("image") or "",
190
- "via": via,
191
- "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
192
- })
193
- if len(out) >= limit:
194
- break
195
- except Exception:
196
- continue
197
- # Fallback to headlines/snippets only if no full body works.
198
- if not out:
199
- for s in (sources or _direct_news_rss(topic, 6))[:limit]:
200
- title = s.get("title") or topic
201
- excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
202
- url = s.get("url", "")
203
- via = s.get("via") or base._domain(url)
204
- out.append({
205
- "title": title,
206
- "url": url,
207
- "raw": excerpt,
208
- "image": base.pollinations_image_url(title),
209
- "via": via,
210
- "source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
211
- })
212
- return out[:limit]
213
-
214
-
215
- async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
216
- errors = []
217
- token = base._hf_token()
218
- try:
219
- original = getattr(base, "_original_qwen_generate", None)
220
- if original:
221
- txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
222
- if txt:
223
- base.LAST_QWEN_ERROR = ""
224
- return txt
225
- if getattr(base, "LAST_QWEN_ERROR", ""):
226
- errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
227
- except Exception as e:
228
- errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
229
- if token:
230
- models = []
231
- for m in [
232
- os.getenv("QWEN_VL_MODEL", ""),
233
- "Qwen/Qwen2.5-VL-7B-Instruct",
234
- "Qwen/Qwen2.5-VL-3B-Instruct",
235
- "Qwen/Qwen2.5-7B-Instruct",
236
- "Qwen/Qwen2.5-3B-Instruct",
237
- "Qwen/Qwen2.5-1.5B-Instruct",
238
- ]:
239
- if m and m not in models:
240
- models.append(m)
241
- headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
242
- for model in models:
243
- try:
244
- is_vl = "VL" in model and bool(image_url)
245
- user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
246
- payload = {
247
- "model": model,
248
- "messages": [
249
- {"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
250
- {"role": "user", "content": user_content},
251
- ],
252
- "max_tokens": min(int(max_tokens or 900), 1400),
253
- "temperature": 0.35,
254
- "top_p": 0.85,
255
- }
256
- r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
257
- if r.status_code >= 300:
258
- errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
259
- continue
260
- j = r.json()
261
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
262
- if txt:
263
- base.LAST_QWEN_ERROR = ""
264
- return txt
265
- errors.append(f"{model}: empty response")
266
- except Exception as e:
267
- errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
268
- else:
269
- errors.append("missing HF_TOKEN")
270
- base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
271
- print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
272
- return _fallback_summary_from_prompt(prompt, max_units=6)
273
-
274
-
275
- if not hasattr(base, "_original_qwen_generate"):
276
- base._original_qwen_generate = base.qwen_generate
277
- base.qwen_generate = qwen_generate_resilient
278
-
279
-
280
- @app.get('/api/wall')
281
- def compat_wall():
282
- return JSONResponse({'posts': base._load_ai_wall()[:80]})
283
-
284
-
285
- _PATCHED_PATHS = {
286
- ('/api/topic_post', 'POST'),
287
- ('/api/url_wall', 'POST'),
288
- ('/api/rewrite_share', 'POST'),
289
- ('/api/ai/short/{post_id}', 'POST'),
290
- }
291
- app.router.routes = [
292
- r for r in app.router.routes
293
- if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
294
- ]
295
-
296
-
297
- @app.post('/api/topic_post')
298
- async def compat_topic_post(request: Request):
299
- body = await request.json()
300
- topic = base._clean_text(body.get('topic', ''))
301
- if not topic:
302
- return JSONResponse({'error': 'missing topic'}, status_code=400)
303
- articles = _topic_source_articles(topic, limit=4)
304
- if not articles:
305
- return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
306
- new_posts = []
307
- posts = base._load_ai_wall()
308
- for art in articles:
309
- prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
310
-
311
- Chủ đề lọc: {topic}
312
- Tiêu đề bài nguồn: {art['title']}
313
- Nguồn: {art['via']}
314
-
315
- Yêu cầu bắt buộc:
316
- - Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
317
- - Không trộn với bài khác.
318
- - Không viết lại toàn bộ bài.
319
- - Không lặp ý.
320
- - 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
321
- - Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
322
-
323
- Nội dung bài:
324
- {art['raw'][:14000]}"""
325
- text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
326
- text = _postprocess_ai_text(text, max_units=6)
327
- src = [art['source']]
328
- if 'Nguồn tham khảo:' not in text:
329
- text += "\n\n" + _source_line(src)
330
- post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
331
- new_posts.append(post)
332
- posts = new_posts + posts
333
- base._save_ai_wall(posts)
334
- return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
335
-
336
-
337
- @app.post('/api/url_wall')
338
- async def compat_url_wall(request: Request):
339
- body = await request.json()
340
- url = base._clean_text(body.get('url', ''))
341
- if not url.startswith('http'):
342
- return JSONResponse({'error': 'missing url'}, status_code=400)
343
- try:
344
- data = base.scrape_any_url(url)
345
- except Exception as e:
346
- return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
347
- raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
348
- if len(raw) < 120:
349
- return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
350
- prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
351
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
352
- text = _postprocess_ai_text(text, max_units=6)
353
- src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
354
- if 'Nguồn tham khảo:' not in text:
355
- text += "\n\n" + _source_line(src)
356
- post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
357
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
358
- return JSONResponse({'post': post})
359
-
360
-
361
- @app.post('/api/rewrite_share')
362
- async def compat_rewrite_share(request: Request):
363
- body = await request.json()
364
- url = base._clean_text(body.get('url', ''))
365
- if not url.startswith('http'):
366
- return JSONResponse({'error': 'missing url'}, status_code=400)
367
- try:
368
- data = base.scrape_any_url(url)
369
- except Exception as e:
370
- return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
371
- raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
372
- if len(raw) < 120:
373
- return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
374
- prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
375
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
376
- text = _postprocess_ai_text(text, max_units=6)
377
- src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
378
- if 'Nguồn tham khảo:' not in text:
379
- text += "\n\n" + _source_line(src)
380
- post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
381
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
382
- return JSONResponse({'post': post})
383
-
384
-
385
- def _emotion_script(text, emotion):
386
- text = _clean(text)
387
- if emotion == 'urgent':
388
- return 'Tin nhanh. ' + text
389
- if emotion == 'warm':
390
- return 'Câu chuyện đáng chú ý. ' + text
391
- if emotion == 'serious':
392
- return 'Bản tin nghiêm túc. ' + text
393
- if emotion == 'energetic':
394
- return 'Cập nhật nổi bật. ' + text
395
- return text
396
-
397
-
398
- def _tts_script_smart(post, emotion):
399
- raw = base._short_script(post)
400
- raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
401
- raw = re.sub(r"\s*\n\s*", ". ", raw)
402
- raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
403
- raw = re.sub(r"\n{2,}", "\n", raw).strip()
404
- raw = _emotion_script(raw, emotion)
405
- if len(raw) > 1000:
406
- raw = raw[:1000]
407
- cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
408
- if cut > 350:
409
- raw = raw[:cut + 1]
410
- return raw
411
-
412
-
413
- def _split_subtitle_sentences(script):
414
- parts = []
415
- for line in script.splitlines():
416
- line = _clean(line)
417
- if not line:
418
- continue
419
- for s in re.split(r"(?<=[\.\!\?])\s+", line):
420
- s = _clean(s)
421
- if 8 <= len(s) <= 140:
422
- parts.append(s)
423
- return parts[:12]
424
-
425
-
426
- def _srt_time(sec):
427
- ms = int((sec - int(sec)) * 1000)
428
- sec = int(sec)
429
- h = sec // 3600
430
- m = (sec % 3600) // 60
431
- s = sec % 60
432
- return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
433
-
434
-
435
- def _write_srt(script, path, total_duration=30):
436
- subs = _split_subtitle_sentences(script)
437
- if not subs:
438
- subs = [script[:120]]
439
- dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
440
- cur = 0.3
441
- with open(path, 'w', encoding='utf-8') as f:
442
- for i, s in enumerate(subs, 1):
443
- start = cur
444
- end = cur + dur
445
- cur = end + 0.15
446
- f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
447
-
448
-
449
- def _wrap_text_px(draw, text, font, max_width, max_lines):
450
- words = _clean(text).split()
451
- lines, cur = [], ""
452
- for w in words:
453
- test = (cur + " " + w).strip()
454
- try:
455
- width = draw.textbbox((0, 0), test, font=font)[2]
456
- except Exception:
457
- width = len(test) * 20
458
- if width <= max_width:
459
- cur = test
460
- else:
461
- if cur:
462
- lines.append(cur)
463
- cur = w
464
- if len(lines) >= max_lines:
465
- break
466
- if cur and len(lines) < max_lines:
467
- lines.append(cur)
468
- return lines
469
-
470
-
471
- def _make_short_frame_full(post, img_path, out_path):
472
- if Image is None:
473
- return base._make_short_frame(post, img_path, out_path)
474
- W, H = 1080, 1920
475
- bg = Image.new("RGB", (W, H), (14, 14, 14))
476
- try:
477
- im = Image.open(img_path).convert("RGB")
478
- target = (1080, 760)
479
- im_ratio = im.width / im.height
480
- target_ratio = target[0] / target[1]
481
- if im_ratio > target_ratio:
482
- new_h = target[1]
483
- new_w = int(new_h * im_ratio)
484
- else:
485
- new_w = target[0]
486
- new_h = int(new_w / im_ratio)
487
- im = im.resize((new_w, new_h))
488
- left = (new_w - target[0]) // 2
489
- top = (new_h - target[1]) // 2
490
- im = im.crop((left, top, left + target[0], top + target[1]))
491
- bg.paste(im, (0, 0))
492
- except Exception:
493
- pass
494
- draw = ImageDraw.Draw(bg)
495
- try:
496
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
497
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
498
- font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
499
- except Exception:
500
- font_title = font_body = font_label = None
501
- draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
502
- margin = 48
503
- maxw = W - margin * 2
504
- draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
505
- y = 830
506
- for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
507
- draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
508
- y += 66
509
- y += 18
510
- text = post.get("text", "")
511
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
512
- body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
513
- for ln in body_lines:
514
- draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
515
- y += 50
516
- if y > 1640:
517
- break
518
- bg.save(out_path, quality=92)
519
-
520
-
521
-
522
-
523
- def _summary_segments_from_post(post, max_segments=7):
524
- """Only use current post summary. Each bullet/paragraph becomes one short scene."""
525
- raw = _clean(post.get('text') or post.get('title') or '')
526
- raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
527
- raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
528
- lines=[]
529
- for ln in raw.splitlines():
530
- ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
531
- if not ln: continue
532
- low=ln.lower()
533
- if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
534
- if len(ln)>=18: lines.append(ln)
535
- if len(lines)<2:
536
- lines=[]
537
- for s in re.split(r'(?<=[\.\!\?])\s+', raw):
538
- s=_clean(s)
539
- if len(s)>=25: lines.append(s)
540
- segs=_dedupe_units(lines, max_units=max_segments)
541
- return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
542
-
543
-
544
- def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
545
- """Create one 9:16 frame for one summary segment. No subtitle layer."""
546
- if Image is None:
547
- return _make_short_frame_full(post, img_path, out_path)
548
- W,H=1080,1920
549
- bg=Image.new('RGB',(W,H),(10,10,10))
550
- try:
551
- im=Image.open(img_path).convert('RGB')
552
- # background cover
553
- ratio=im.width/max(1,im.height); target=W/H
554
- if ratio>target:
555
- nh=H; nw=int(nh*ratio)
556
- else:
557
- nw=W; nh=int(nw/ratio)
558
- cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
559
- cover=cover.crop((left,top,left+W,top+H))
560
- bg.paste(cover,(0,0))
561
- bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
562
- # top hero image
563
- hero_h=720; target=W/hero_h
564
- if ratio>target:
565
- nh=hero_h; nw=int(nh*ratio)
566
- else:
567
- nw=W; nh=int(nw/ratio)
568
- hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
569
- hero=hero.crop((left,top,left+W,top+hero_h))
570
- bg.paste(hero,(0,0))
571
- except Exception:
572
- pass
573
- draw=ImageDraw.Draw(bg)
574
- try:
575
- font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
576
- font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
577
- font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
578
- font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
579
- except Exception:
580
- font_brand=font_small=font_seg=font_title=None
581
- draw.rectangle((0,680,W,H), fill=(12,12,12))
582
- # progress bars
583
- dot_x=48; dot_y=742
584
- for i in range(total):
585
- fill=(92,184,122) if i==idx else (70,70,70)
586
- draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
587
- draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
588
- draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
589
- draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
590
- y=940; maxw=W-96
591
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
592
- draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
593
- y+=74
594
- if y>1500: break
595
- y2=1640
596
- draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
597
- for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
598
- draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
599
- y2+=46
600
- bg.save(out_path, quality=92)
601
-
602
-
603
- def _estimate_audio_duration(path, fallback=4.0):
604
- try:
605
- pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
606
- return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
607
- except Exception:
608
- return fallback
609
-
610
-
611
- @app.post('/api/ai/short/{post_id}')
612
- async def patched_ai_short(post_id: str, request: Request):
613
- """Create 9:16 short from separate timed summary segments; no subtitles."""
614
- try:
615
- body = await request.json()
616
- except Exception:
617
- body = {}
618
- voice = str(body.get('voice', 'nu')).strip().lower()
619
- emotion = str(body.get('emotion', 'neutral')).strip().lower()
620
- speed = float(body.get('speed', 1.2) or 1.2)
621
- speed = max(0.85, min(1.35, speed))
622
-
623
- posts = base._load_ai_wall()
624
- post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
625
- if not post:
626
- return JSONResponse({'error': 'post not found'}, status_code=404)
627
-
628
- segments = _summary_segments_from_post(post, max_segments=7)
629
- seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
630
- os.makedirs(base.SHORTS_DIR, exist_ok=True)
631
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
632
- out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
633
- if os.path.exists(out_mp4):
634
- post['video'] = '/api/ai/short-file/' + post_id + suffix
635
- post['short_voice'] = voice
636
- post['short_emotion'] = emotion
637
- post['short_speed'] = speed
638
- post['short_segments'] = segments
639
- post['short_subtitles'] = False
640
- base._save_ai_wall(posts)
641
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
642
- if base.gTTS is None:
643
- return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
644
-
645
- work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
646
- os.makedirs(work, exist_ok=True)
647
- img = os.path.join(work, 'image.jpg')
648
- try:
649
- base._download_image(post.get('img'), post.get('title', 'AI news'), img)
650
- edge_voice = {
651
- 'nam': 'vi-VN-NamMinhNeural',
652
- 'male': 'vi-VN-NamMinhNeural',
653
- 'nu': 'vi-VN-HoaiMyNeural',
654
- 'female': 'vi-VN-HoaiMyNeural',
655
- 'mien-nam': 'vi-VN-HoaiMyNeural',
656
- }.get(voice, 'vi-VN-HoaiMyNeural')
657
- part_files=[]
658
- for idx, seg in enumerate(segments):
659
- frame=os.path.join(work,f'frame_{idx:02d}.jpg')
660
- aud=os.path.join(work,f'voice_{idx:02d}.mp3')
661
- aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
662
- part=os.path.join(work,f'part_{idx:02d}.mp4')
663
- _make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
664
- spoken=_emotion_script(seg, emotion)
665
- try:
666
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
667
- except Exception:
668
- tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
669
- try:
670
- base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
671
- except TypeError:
672
- base.gTTS(spoken, lang='vi', slow=False).save(aud)
673
- subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
674
- dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
675
- # No subtitles: scene text is part of frame only.
676
- subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
677
- part_files.append(part)
678
- concat=os.path.join(work,'concat.txt')
679
- with open(concat,'w',encoding='utf-8') as f:
680
- for p in part_files:
681
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
682
- subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
683
- post['video'] = '/api/ai/short-file/' + post_id + suffix
684
- post['short_voice'] = voice
685
- post['short_emotion'] = emotion
686
- post['short_speed'] = speed
687
- post['short_segments'] = segments
688
- post['short_subtitles'] = False
689
- base._save_ai_wall(posts)
690
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
691
- except Exception as e:
692
- return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
693
-
694
-
695
- @app.get('/api/ai/short-file/{file_id}')
696
- def patched_ai_short_file(file_id: str):
697
- path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
698
- if not os.path.exists(path):
699
- return JSONResponse({'error': 'not found'}, status_code=404)
700
- return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
701
-
702
-
703
- @app.get('/api/ai_shorts')
704
- def api_ai_shorts():
705
- posts = [p for p in base._load_ai_wall() if p.get('video')]
706
- return JSONResponse({'posts': posts[:80]})
707
-
708
-
709
- app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
710
-
711
- PATCH_INJECT = r'''
712
- <style>.ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-actions{display:flex;gap:6px;margin-top:8px}.ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.ai-short-card{flex:0 0 145px}.ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}.ai-short-video video{width:100%;height:100%;object-fit:cover}.ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}.ai-short-progress.active{display:flex}.ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}.ai-short-box h3{color:#5cb87a;margin-bottom:10px}.ai-short-step{font-size:13px;line-height:1.55;color:#ccc}.ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}</style>
713
- <div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
714
- <script>
715
- (function(){
716
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
717
- let patchedWall=[];let aiShorts=[];
718
- function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
719
- function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
720
- function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}
721
- async function loadPatchedWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();patchedWall=j.posts||[];renderPatchedWall();updateAiLabels();}catch(e){}try{const r2=await fetch('/api/ai_shorts');const j2=await r2.json();aiShorts=j2.posts||[];renderAiShorts();}catch(e){}}
722
- function renderAiShorts(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-shorts-patched')?.remove();if(!aiShorts.length)return;let wrap=document.createElement('div');wrap.id='ai-shorts-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';aiShorts.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card" onclick="aiReadShortPatched(${i})"><div class="ai-short-video"><video src="${p.video}" muted playsinline preload="metadata"></video></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let wall=document.getElementById('ai-wall-patched');if(wall)wall.after(wrap);else home.prepend(wrap);}
723
- function renderPatchedWall(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-wall-patched')?.remove();if(!patchedWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Mỗi nguồn = một bài tóm tắt</span></div><div class="slider-track">';patchedWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWallPatched(${i})">Xem</button><button class="primary" onclick="aiMakeShortPatched(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
724
- window.aiReadShortPatched=function(i){const p=aiShorts[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><p class="article-p" style="white-space:pre-wrap">${esc(p.text||'')}</p><div class="article-actions"><button onclick="window.open('${p.video}','_blank')">⬇ Mở video</button>${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
725
- window.aiReadWallPatched=function(i){const p=patchedWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice"><option value="nu">Giọng nữ Việt</option><option value="nam">Giọng nam Việt</option><option value="mien-nam">Giọng miền Nam</option></select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="aiMakeShortPatched(${i})">🎬 Tạo video shorts</button></div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
726
- window.aiMakeShortPatched=async function(i){const p=patchedWall[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let voiceName={nu:'Giọng nữ Việt',nam:'Giọng nam Việt','mien-nam':'Giọng miền Nam'}[voice]||voice;let emotionName={neutral:'Trung tính',urgent:'Tin nhanh',warm:'Ấm áp',serious:'Nghiêm túc',energetic:'Sôi nổi'}[emotion]||emotion;let ok=confirm(`Quy trình tạo short AI:\n\n1) Dùng ảnh đại diện của bài hoặc tạo ảnh minh họa nếu thiếu.\n2) Rút gọn nội dung tóm tắt thành kịch bản đọc ngắn.\n3) Tự ngắt câu theo dấu câu và xuống dòng hợp lý.\n4) Tạo giọng đọc tiếng Việt: ${voiceName}.\n5) Áp dụng cảm xúc/kịch bản: ${emotionName}.\n6) Tăng tốc giọng đọc 1.2 lần.\n7) Mỗi đoạn tóm tắt sẽ là một cảnh riêng theo thời lượng đọc.\n8) Không thêm phụ đề; video chỉ có chữ cảnh và giọng đọc.\n9) Sau khi xong, video xuất hiện ở slide “Short AI”.\n\nQuá trình có thể mất 1-3 phút. Bạn muốn bắt đầu?`);if(!ok)return;try{showProgress(`Bước 1/5: Chuẩn bị ảnh và căn chữ full width...<br>Bước 2/5: Tạo kịch bản, tự ngắt câu/xuống dòng...<br>Bước 3/5: Tạo giọng đọc ${voiceName}, cảm xúc ${emotionName}.<br>Bước 4/5: Tăng tốc 1.2x và ghép từng cảnh riêng, không phụ đề.<br>Bước 5/5: Lưu vào slide “Short AI”.`);const r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;hideProgress();alert('Hoàn tất: video shorts đã được tạo và thêm vào slide “Short AI”.');aiReadWallPatched(i);loadPatchedWall();}catch(e){hideProgress();alert('Không tạo được shorts: '+e.message)}};
727
- window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&(j.posts||j.post)){let arr=j.posts||[j.post];patchedWall=arr.concat(patchedWall.filter(x=>!arr.find(y=>y.id===x.id)));renderPatchedWall();if(inp)inp.value='';alert(`Đã lọc và tóm tắt ${arr.length} bài viết theo chủ đề lên Tường AI`);}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
728
- window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();if(inp)inp.value='';alert('Đã tóm tắt URL và đăng lên Tường AI');}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
729
- window.rewriteCurrentArticle=function(){if(!window._currentArticle&&typeof _currentArticle!=='undefined')window._currentArticle=_currentArticle;let cur=window._currentArticle||_currentArticle;if(!cur)return;let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang tóm tắt...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:cur.url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã tóm tắt và đăng Tường AI</div><div class="rewrite-text">${esc(j.post.text||'')}</div></div>`;patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();alert('Đã tóm tắt lên Tường AI');}else alert(j.error||'Không tóm tắt được')}).catch(e=>alert(e.message||'Lỗi tóm tắt')).finally(()=>{if(btn){btn.textContent='🤖 Tóm tắt AI & đăng tường';btn.disabled=false}})};
730
- setTimeout(loadPatchedWall,1500);setInterval(updateAiLabels,2000);
731
- })();
732
- </script>
733
- '''
734
-
735
- @app.get('/')
736
- async def index_patched():
737
- with open('/app/static/index.html','r',encoding='utf-8') as f:
738
- html=f.read()
739
- return HTMLResponse(html.replace('</body>', PATCH_INJECT+'\n</body>'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_clean.py DELETED
@@ -1,69 +0,0 @@
1
- """
2
- VNEWS Clean Backend - serves static/index_v2.html directly.
3
- No injection layers. All APIs from existing modules preserved.
4
- Comments feature REMOVED per user request.
5
- """
6
- import sys, os
7
-
8
- # Import the full chain which registers all API endpoints on the FastAPI app
9
- from app_main import app, _search_all, _clean
10
-
11
- # Now override the root '/' to serve our clean frontend
12
- from fastapi import Query, Request
13
- from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
14
- from fastapi.staticfiles import StaticFiles
15
- import os
16
-
17
- # Remove old '/' route
18
- app.router.routes = [r for r in app.router.routes if not (
19
- getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())
20
- )]
21
-
22
- # Remove comment endpoints (user requested removal)
23
- app.router.routes = [r for r in app.router.routes if not (
24
- getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')
25
- )]
26
-
27
- # Mount static files
28
- STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
29
- app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
30
-
31
- @app.get('/')
32
- async def serve_index():
33
- """Serve the clean v2 frontend - single HTML file, no injection."""
34
- index_path = os.path.join(STATIC_DIR, 'index_v2.html')
35
- if os.path.exists(index_path):
36
- return FileResponse(index_path, media_type='text/html')
37
- return HTMLResponse('<h1>VNEWS</h1><p>index_v2.html not found</p>', status_code=500)
38
-
39
- # Keep /api/hashtag/sources using direct search (not Google News)
40
- # This was already overridden in app_main.py with _search_all
41
- # Just make sure it's accessible
42
-
43
- # Storage status endpoint
44
- @app.get('/api/storage_status')
45
- def storage_status():
46
- """Check if persistent storage is enabled."""
47
- data_dir = '/data'
48
- persistent = os.path.isdir(data_dir) and os.access(data_dir, os.W_OK)
49
- return JSONResponse({'persistent': persistent, 'path': data_dir})
50
-
51
- # Categories for the tab bar
52
- @app.get('/api/categories')
53
- def get_categories():
54
- """Return category list for frontend tab bar."""
55
- return JSONResponse([]) # Categories moved into News tab, homepage shows media content
56
-
57
- # Share page
58
- @app.get('/s')
59
- async def share_page(url: str = '', title: str = '', img: str = ''):
60
- """OG share page for social media."""
61
- html = f'''<!DOCTYPE html><html><head>
62
- <meta property="og:title" content="{_clean(title)}">
63
- <meta property="og:url" content="{_clean(url)}">
64
- <meta property="og:image" content="{_clean(img)}">
65
- <meta property="og:type" content="article">
66
- <meta property="og:site_name" content="VNEWS">
67
- <meta http-equiv="refresh" content="0;url={_clean(url) or '/'}">
68
- </head><body>Redirecting...</body></html>'''
69
- return HTMLResponse(html)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_entry.py DELETED
@@ -1,17 +0,0 @@
1
- """Wrapper: load main patch then inject extra fixes for tiktok-right position, kill duplicate slides, progress toast."""
2
- from ai_runtime_patch_fast import *
3
- from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT
4
- from patch_extra import EXTRA_FIX
5
- from fastapi.responses import HTMLResponse
6
-
7
- # Remove old root and re-register with EXTRA_FIX appended.
8
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
9
-
10
- @app.get('/')
11
- async def _index_final():
12
- html=f5.f4.f3.f2.f1._load_index_html()
13
- body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
14
- body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
15
- body+=PATCH_INJECT
16
- body+=EXTRA_FIX
17
- return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_v2_entry.py DELETED
@@ -1,286 +0,0 @@
1
- """VNEWS v2 Entry Point - with AI endpoints + friendly highlights"""
2
- import sys, os
3
- from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
-
5
- # Import AI extension to get /api/rewrite_share, /api/topic_post, /api/ai_wall endpoints
6
- try:
7
- import ai_ext
8
- except Exception as e:
9
- print(f"[WARN] ai_ext import failed: {e}")
10
-
11
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
12
- from fastapi.staticfiles import StaticFiles
13
- from starlette.routing import Mount
14
- from fastapi import Query, Request
15
- import requests as req
16
- from urllib.parse import quote
17
- from bs4 import BeautifulSoup
18
- import re, html as html_lib, json, threading, time
19
- from concurrent.futures import ThreadPoolExecutor, as_completed
20
-
21
- HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
22
-
23
- STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
24
- # Only remove '/' route, keep all other routes (including AI endpoints)
25
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
26
- # Remove only Mount objects (old static mounts), NOT API routes
27
- app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
28
- # Clean mounts from router too
29
- app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
30
-
31
- def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
32
- _STOP=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
33
- def _has_kw(topic,title):
34
- tl=topic.lower();tt=(title or'').lower()
35
- if tl in tt:return True
36
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
37
- if not words:return True
38
- return any(w in tt for w in words)
39
- def _s_vnexpress(topic,limit=8):
40
- items=[]
41
- try:
42
- r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
43
- for art in soup.select('article.item-news')[:limit]:
44
- a=art.select_one('h2 a, h3 a')
45
- if a and a.get('href'):
46
- t=_clean(a.get('title','') or a.get_text(strip=True))
47
- if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
48
- except:pass
49
- return items
50
- def _s_dantri(topic,limit=8):
51
- items=[]
52
- try:
53
- r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
54
- for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
55
- t=_clean(a.get_text(strip=True));href=a.get('href','')
56
- if t and len(t)>15 and _has_kw(topic,t):
57
- if not href.startswith('http'):href='https://dantri.com.vn'+href
58
- items.append({'title':t,'url':href,'via':'Dân Trí'})
59
- if len(items)>=limit:break
60
- except:pass
61
- return items
62
- def _s_vietnamnet(topic,limit=6):
63
- items=[]
64
- try:
65
- r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
66
- for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
67
- t=_clean(a.get_text(strip=True));href=a.get('href','')
68
- if t and len(t)>15 and _has_kw(topic,t):
69
- if not href.startswith('http'):href='https://vietnamnet.vn'+href
70
- items.append({'title':t,'url':href,'via':'VietNamNet'})
71
- if len(items)>=limit:break
72
- except:pass
73
- return items
74
- def _s_bongda(topic,limit=5):
75
- items=[]
76
- try:
77
- r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
78
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
79
- t=_clean(a.get_text(strip=True));href=a.get('href','')
80
- if t and len(t)>15 and _has_kw(topic,t):
81
- if not href.startswith('http'):href='https://bongda.com.vn'+href
82
- items.append({'title':t,'url':href,'via':'Bóng Đá'})
83
- if len(items)>=limit:break
84
- except:pass
85
- return items
86
- def _s_genk(topic,limit=5):
87
- items=[]
88
- try:
89
- r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
90
- for a in soup.select('a[href$=".chn"]')[:limit*3]:
91
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
92
- if t and len(t)>15 and _has_kw(topic,t):
93
- if href.startswith('/'):href='https://genk.vn'+href
94
- items.append({'title':t,'url':href,'via':'GenK'})
95
- if len(items)>=limit:break
96
- except:pass
97
- return items
98
- def _s_thanhnien(topic,limit=6):
99
- items=[]
100
- try:
101
- r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
102
- for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
103
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
104
- if t and len(t)>15 and _has_kw(topic,t):
105
- if not href.startswith('http'):href='https://thanhnien.vn'+href
106
- items.append({'title':t,'url':href,'via':'Thanh Niên'})
107
- if len(items)>=limit:break
108
- except:pass
109
- return items
110
- def _s_tuoitre(topic,limit=6):
111
- items=[]
112
- try:
113
- r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
114
- for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
115
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
116
- if t and len(t)>15 and _has_kw(topic,t):
117
- if not href.startswith('http'):href='https://tuoitre.vn'+href
118
- items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
119
- if len(items)>=limit:break
120
- except:pass
121
- return items
122
- def _s_thethaovanhoa(topic,limit=5):
123
- items=[]
124
- try:
125
- r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
126
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
127
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
128
- if t and len(t)>15 and _has_kw(topic,t):
129
- if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
130
- items.append({'title':t,'url':href,'via':'TT&VH'})
131
- if len(items)>=limit:break
132
- except:pass
133
- return items
134
- def _search_all(topic,limit=36):
135
- results={}
136
- with ThreadPoolExecutor(8) as ex:
137
- futs={ex.submit(_s_vnexpress,topic,8):'vne',ex.submit(_s_dantri,topic,8):'dt',ex.submit(_s_vietnamnet,topic,6):'vnn',ex.submit(_s_bongda,topic,5):'bd',ex.submit(_s_genk,topic,5):'gk',ex.submit(_s_thanhnien,topic,6):'tn',ex.submit(_s_tuoitre,topic,6):'tt',ex.submit(_s_thethaovanhoa,topic,5):'tvh'}
138
- for f in as_completed(futs,timeout=14):
139
- try:results[futs[f]]=f.result()
140
- except:results[futs[f]]=[]
141
- srcs=list(results.values());out=[];seen=set();mx=max((len(s) for s in srcs),default=0)
142
- for i in range(mx):
143
- for s in srcs:
144
- if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
145
- return out[:limit]
146
- # Override article endpoint
147
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set()))]
148
- def _scrape_generic(url):
149
- try:
150
- r=req.get(url,headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True);r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
151
- for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
152
- h1=soup.find('h1');ogt=soup.find('meta',property='og:title');title=(h1.get_text(strip=True) if h1 else '')or(ogt.get('content','') if ogt else '')
153
- ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
154
- ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
155
- if og_img and og_img.startswith('//'):og_img='https:'+og_img
156
- block=None
157
- for sel in['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body']:
158
- el=soup.select_one(sel)
159
- if el and len(el.find_all('p'))>=2:block=el;break
160
- if not block:block=soup.body or soup
161
- body=[]
162
- for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
163
- if el.name=='p':t=el.get_text(strip=True);(body.append({'type':'p','text':t}) if t and len(t)>30 else None)
164
- elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None)
165
- elif el.name in('figure','img'):
166
- im=el if el.name=='img' else el.find('img')
167
- if im:src=im.get('data-src') or im.get('src') or'';(body.append({'type':'img','src':'https:'+src if src.startswith('//') else src}) if src and'base64' not in src else None)
168
- if not body and summary:body=[{'type':'p','text':summary}]
169
- return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
170
- except:return None
171
- @app.get('/api/article')
172
- def api_article_v2(url:str=Query(...)):
173
- from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
174
- if 'vnexpress.net' in url:data=scrape_vne_article(url)
175
- elif 'bbc.com' in url:data=scrape_bbc_article(url)
176
- elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
177
- elif 'genk.vn' in url:data=scrape_genk_article(url)
178
- elif 'thethaovanhoa.vn' in url:data=scrape_ttvh_article(url)
179
- else:data=_scrape_generic(url)
180
- if data and data.get('body'):return JSONResponse(data)
181
- data=_scrape_generic(url);return JSONResponse(data if data else{'error':'Không đọc được','url':url})
182
- _hot_cache={'t':0,'d':[]}
183
- def _get_hot_topics():
184
- now=time.time()
185
- if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
186
- freq={};display={}
187
- feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
188
- for feed_url in feeds:
189
- try:
190
- r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
191
- for item in soup.find_all('item')[:12]:
192
- title=_clean(item.find('title').get_text() if item.find('title') else '')
193
- if not title:continue
194
- title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
195
- if len(words)<2:continue
196
- for n in(3,4,2):
197
- for i in range(max(0,len(words)-n+1)):
198
- phrase=' '.join(words[i:i+n])
199
- if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
200
- except:continue
201
- ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
202
- for key,count in ranked:
203
- is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
204
- if is_dup:continue
205
- seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
206
- if len(topics)>=20:break
207
- for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','Giá vàng','Thời tiết']:
208
- if len(topics)>=24:break
209
- if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
210
- _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
211
- @app.get('/api/hot_topics')
212
- def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()})
213
- @app.get('/')
214
- async def serve_index():
215
- p=os.path.join(STATIC_DIR,'index_v2.html')
216
- if os.path.exists(p):return FileResponse(p,media_type='text/html')
217
- return HTMLResponse('<h1>VNEWS</h1>')
218
- @app.get('/api/hashtag/sources')
219
- def _ht(topic:str=Query(...),page:int=Query(default=0)):
220
- items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
221
- return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
222
- @app.get('/api/categories')
223
- def _cat():return JSONResponse([])
224
- @app.get('/api/storage_status')
225
- def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
226
- @app.get('/s')
227
- async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
228
- DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
229
- os.makedirs(DATA_DIR,exist_ok=True);IF=os.path.join(DATA_DIR,'interactions_v2.json');CF=os.path.join(DATA_DIR,'comments_v2.json')
230
- _il=threading.Lock();_cl=threading.Lock()
231
- def _lj(p):
232
- try:
233
- if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
234
- except:pass
235
- return{}
236
- def _sj(p,d):
237
- try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
238
- except:pass
239
- @app.post('/api/v2/interact')
240
- async def _int(request:Request):
241
- b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
242
- if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
243
- with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
244
- @app.get('/api/v2/interactions')
245
- def _gi(id:str=Query(...)):
246
- with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
247
- @app.get('/api/v2/comments')
248
- def _gc(id:str=Query(...)):
249
- with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
250
- @app.post('/api/v2/comment')
251
- async def _pc(request:Request):
252
- b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
253
- if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
254
- c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
255
- with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
256
- with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
257
- return JSONResponse({'comments':cms})
258
- from wc2026_scraper import(scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail)
259
- @app.get('/api/wc2026')
260
- def _w():return JSONResponse(get_wc2026_all())
261
- @app.get('/api/wc2026/fixtures')
262
- def _wf():return JSONResponse(scrape_fixtures())
263
- @app.get('/api/wc2026/standings')
264
- def _ws():return JSONResponse(scrape_standings())
265
- @app.get('/api/wc2026/stats')
266
- def _wst():return JSONResponse(scrape_stats())
267
- @app.get('/api/wc2026/history')
268
- def _whi():return JSONResponse(scrape_history())
269
- @app.get('/api/wc2026/news')
270
- def _wn():return JSONResponse(scrape_wc_news())
271
- @app.get('/api/wc2026/road')
272
- def _wr():return JSONResponse(scrape_road_to_wc())
273
- @app.get('/api/wc2026/h2h/{eid}')
274
- def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
275
- @app.get('/api/wc2026/lineups/{eid}')
276
- def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
277
- @app.get('/api/wc2026/match/{eid}')
278
- def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
279
- def _bg():
280
- time.sleep(15)
281
- while True:
282
- try:get_wc2026_all()
283
- except:pass
284
- time.sleep(90)
285
- threading.Thread(target=_bg,daemon=True).start()
286
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_extra.py DELETED
@@ -1,50 +0,0 @@
1
- """Extra CSS/JS fixes injected AFTER main PATCH_INJECT."""
2
- EXTRA_FIX = r'''
3
- <style>
4
- /* Force correct position for Short AI interaction buttons */
5
- .tiktok-slide{position:relative!important}
6
- .tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
7
- .tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;font-size:10px!important;cursor:pointer!important}
8
- .tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
9
- .tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
10
- #short-progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}
11
- /* Kill ALL duplicate short AI slides from old layers */
12
- #ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
13
- </style>
14
- <div id="short-progress-toast"></div>
15
- <script>
16
- (function(){
17
- // Kill old renderers that create duplicate Short AI slides
18
- window.renderAIShortHome=function(){};
19
- window.renderAIShorts7=function(){};
20
- window.renderTopicWallE=function(){};
21
- window.renderAiShorts=function(){};
22
- // Also remove any already-rendered duplicate slides
23
- setInterval(function(){
24
- document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
25
- },2000);
26
- // Progress toast for short creation
27
- window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
28
- window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
29
- // Override makeShortFromPost to use progress toast
30
- var _origMakeShort=window.makeShortFromPost;
31
- window.makeShortFromPost=async function(pid,btn){
32
- showShortProgress('⏳ Đang tạo Short AI...');
33
- if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
34
- try{
35
- var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
36
- var j=await r.json();
37
- if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
38
- showShortProgress('✅ Đã tạo Short AI!');
39
- setTimeout(hideShortProgress,3000);
40
- if(typeof renderShortAISlide==='function')renderShortAISlide();
41
- }catch(e){
42
- showShortProgress('❌ Lỗi: '+e.message);
43
- setTimeout(hideShortProgress,4000);
44
- }finally{
45
- if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}
46
- }
47
- };
48
- })();
49
- </script>
50
- '''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_runtime.py DELETED
@@ -1,274 +0,0 @@
1
- """Runtime patch layer for VNEWS.
2
- Keeps the current large app intact, but replaces fragile AI wall endpoints with
3
- stable JSON endpoints and injects frontend safeJson wrappers.
4
- """
5
- import hashlib
6
- import time
7
- import os
8
- from urllib.parse import quote
9
-
10
- import requests
11
- from bs4 import BeautifulSoup
12
- from fastapi import Request
13
- from fastapi.responses import JSONResponse, HTMLResponse
14
-
15
- import main as _main
16
-
17
- app = _main.app
18
- DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
19
-
20
-
21
- def _remove_routes(paths):
22
- app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) not in set(paths)]
23
-
24
-
25
- def _safe_text(v):
26
- return (v or "").strip()
27
-
28
-
29
- def _ensure_article(url: str):
30
- data = None
31
- try:
32
- if hasattr(_main, "_article_by_url"):
33
- data = _main._article_by_url(url)
34
- except Exception:
35
- data = None
36
- if not data:
37
- try:
38
- data = _main._scrape_generic_article(url) if hasattr(_main, "_scrape_generic_article") else None
39
- except Exception:
40
- data = None
41
- if not data:
42
- data = {"title": "", "summary": "", "og_image": "", "body": [], "url": url, "source": "generic"}
43
- title = _safe_text(data.get("title"))
44
- summary = _safe_text(data.get("summary"))
45
- img = _safe_text(data.get("og_image"))
46
- body = data.get("body") or []
47
- if not title or not summary or not img or not body:
48
- try:
49
- r = requests.get(url, headers=getattr(_main, "HEADERS", {}), timeout=15)
50
- r.encoding = "utf-8"
51
- soup = BeautifulSoup(r.text, "lxml")
52
- if not title:
53
- tag = soup.find("meta", property="og:title") or soup.find("title")
54
- title = tag.get("content", "").strip() if tag and tag.name == "meta" else (tag.get_text(strip=True) if tag else "")
55
- if not summary:
56
- tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
57
- summary = tag.get("content", "").strip() if tag else ""
58
- if not img:
59
- tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
60
- img = tag.get("content", "").strip() if tag else ""
61
- if not body:
62
- ps = []
63
- for p in soup.find_all("p"):
64
- t = p.get_text(" ", strip=True)
65
- if len(t) > 40:
66
- ps.append({"type": "p", "text": t})
67
- if len(ps) >= 30:
68
- break
69
- body = ps
70
- except Exception:
71
- pass
72
- if not summary and body:
73
- first = next((b.get("text", "") for b in body if b.get("type") == "p" and b.get("text")), "")
74
- summary = first[:360]
75
- if not title:
76
- title = url
77
- if not img:
78
- img = DEFAULT_IMG
79
- if not body and summary:
80
- body = [{"type": "p", "text": summary}]
81
- data.update({"title": title, "summary": summary, "og_image": img, "body": body, "url": url})
82
- return data
83
-
84
-
85
- def _rewrite(data, tone="tu-nhien"):
86
- try:
87
- if hasattr(_main, "_ai_rewrite_article"):
88
- text = _main._ai_rewrite_article(data, tone=tone)
89
- if text and len(text.strip()) > 50:
90
- return text.strip()
91
- except Exception:
92
- pass
93
- title = data.get("title", "")
94
- summary = data.get("summary", "")
95
- ps = [b.get("text", "") for b in data.get("body", []) if b.get("type") == "p" and b.get("text")]
96
- lead = summary or (ps[0] if ps else "")
97
- points = "\n".join(["• " + p[:220] + ("..." if len(p) > 220 else "") for p in ps[:5]])
98
- body = "\n\n".join(ps[:10])
99
- return (f"Bản tin AI viết lại: {title}\n\n{lead}\n\n{body}\n\nĐiểm chính:\n{points}").strip()
100
-
101
-
102
- def _topic_image(topic):
103
- try:
104
- if hasattr(_main, "_image_for_topic"):
105
- return _main._image_for_topic(topic)
106
- except Exception:
107
- pass
108
- return "https://image.pollinations.ai/prompt/" + quote("editorial illustration Vietnamese news " + topic, safe="") + "?width=1024&height=576&nologo=true"
109
-
110
-
111
- def _save_post(post):
112
- try:
113
- posts = _main._load_wall() if hasattr(_main, "_load_wall") else []
114
- except Exception:
115
- posts = []
116
- posts.insert(0, post)
117
- try:
118
- if hasattr(_main, "_save_wall"):
119
- _main._save_wall(posts)
120
- except Exception:
121
- pass
122
- return post
123
-
124
-
125
- _remove_routes(["/api/url_wall", "/api/topic_post", "/api/rewrite_share", "/"])
126
-
127
-
128
- @app.post("/api/url_wall")
129
- async def patched_url_wall(request: Request):
130
- try:
131
- body = await request.json()
132
- except Exception:
133
- body = {}
134
- url = _safe_text(body.get("url"))
135
- tone = _safe_text(body.get("tone")) or "tu-nhien"
136
- if not url:
137
- return JSONResponse({"error": "missing url"}, status_code=400)
138
- try:
139
- data = _ensure_article(url)
140
- text = _rewrite(data, tone=tone)
141
- post = {
142
- "id": hashlib.md5((url + str(time.time())).encode()).hexdigest()[:12],
143
- "url": url,
144
- "title": data.get("title") or url,
145
- "summary": data.get("summary") or "",
146
- "img": data.get("og_image") or DEFAULT_IMG,
147
- "text": text or (data.get("summary") or data.get("title") or url),
148
- "source": data.get("source", "url"),
149
- "ts": int(time.time()),
150
- }
151
- _save_post(post)
152
- return JSONResponse({"post": post})
153
- except Exception as e:
154
- return JSONResponse({"error": "Không tạo được tóm tắt URL", "detail": str(e)[:300]}, status_code=500)
155
-
156
-
157
- @app.post("/api/rewrite_share")
158
- async def patched_rewrite_share(request: Request):
159
- return await patched_url_wall(request)
160
-
161
-
162
- @app.post("/api/topic_post")
163
- async def patched_topic_post(request: Request):
164
- try:
165
- body = await request.json()
166
- except Exception:
167
- body = {}
168
- topic = _safe_text(body.get("topic"))
169
- tone = _safe_text(body.get("tone")) or "tu-nhien"
170
- if not topic:
171
- return JSONResponse({"error": "missing topic"}, status_code=400)
172
- try:
173
- context = ""
174
- try:
175
- if hasattr(_main, "_topic_article_context"):
176
- context = _main._topic_article_context(topic)
177
- if not context and hasattr(_main, "_web_context"):
178
- context = _main._web_context(topic)
179
- except Exception:
180
- context = ""
181
- if not context:
182
- context = f"Chủ đề: {topic}"
183
- data = {"title": topic, "summary": context[:420], "og_image": _topic_image(topic), "body": [{"type": "p", "text": context}], "source": "topic", "url": ""}
184
- text = _rewrite(data, tone=tone)
185
- post = {
186
- "id": hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:12],
187
- "url": "",
188
- "title": topic,
189
- "summary": data["summary"],
190
- "img": data["og_image"] or DEFAULT_IMG,
191
- "text": text or context,
192
- "source": "topic",
193
- "ts": int(time.time()),
194
- }
195
- _save_post(post)
196
- return JSONResponse({"post": post})
197
- except Exception as e:
198
- return JSONResponse({"error": "Không tạo được bài theo chủ đề", "detail": str(e)[:300]}, status_code=500)
199
-
200
-
201
- _FRONTEND_PATCH = r'''
202
- <script>
203
- (function(){
204
- async function safeJson(res){
205
- const text = await res.text();
206
- try { return JSON.parse(text); }
207
- catch(e){ return { error: (text || 'Server không trả JSON').slice(0,500) }; }
208
- }
209
- window.safeJson = safeJson;
210
- window.createUrlPost = function(){
211
- let inp=document.getElementById('ai-url-input');
212
- let url=(inp&&inp.value||'').trim();
213
- if(!url){ alert('Dán URL trước'); return; }
214
- fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})})
215
- .then(safeJson).then(j=>{
216
- if(j&&j.post){
217
- if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
218
- if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung tóm tắt.';
219
- if(typeof prependWallPost==='function') prependWallPost(j.post);
220
- alert('Đã tóm tắt URL và đăng lên tường');
221
- if(inp) inp.value='';
222
- } else alert((j&&j.error)||'Lỗi URL');
223
- }).catch(e=>alert('Lỗi URL: '+e.message));
224
- };
225
- window.createTopicPost = function(){
226
- let inp=document.getElementById('ai-topic-input');
227
- let topic=(inp&&inp.value||'').trim();
228
- if(!topic){ alert('Nhập chủ đề trước'); return; }
229
- fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})
230
- .then(safeJson).then(j=>{
231
- if(j&&j.post){
232
- if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
233
- if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
234
- if(typeof prependWallPost==='function') prependWallPost(j.post);
235
- alert('Đã tạo bài và đăng lên tường');
236
- if(inp) inp.value='';
237
- } else alert((j&&j.error)||'Lỗi tạo bài');
238
- }).catch(e=>alert('Lỗi tạo bài: '+e.message));
239
- };
240
- window.rewriteCurrentArticle = function(){
241
- if(!window._currentArticle && typeof _currentArticle!=='undefined') window._currentArticle=_currentArticle;
242
- let ca = (typeof _currentArticle!=='undefined') ? _currentArticle : window._currentArticle;
243
- if(!ca || !ca.url){ alert('Chưa có bài viết để rewrite'); return; }
244
- let tone=document.getElementById('rewrite-tone')?.value||'nghiem-tuc';
245
- let btn=document.querySelector('.article-actions button.primary');
246
- if(btn){btn.textContent='Đang rewrite...';btn.disabled=true;}
247
- fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:ca.url,tone})})
248
- .then(safeJson).then(j=>{
249
- if(j&&j.post){
250
- if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
251
- if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
252
- let box=document.getElementById('rewrite-result');
253
- if(box) box.innerHTML='<div class="rewrite-box"><div class="rewrite-title">Đã rewrite và đăng lên Tường AI</div><div class="rewrite-text">'+(j.post.text||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))+'</div></div>';
254
- if(typeof prependWallPost==='function') prependWallPost(j.post);
255
- alert('Đã đăng lên Tường AI');
256
- } else alert((j&&j.error)||'Không tạo được bài AI');
257
- }).catch(e=>alert('Lỗi tạo bài AI: '+e.message))
258
- .finally(()=>{if(btn){btn.textContent='🤖 AI viết lại & đăng tường';btn.disabled=false;}});
259
- };
260
- })();
261
- </script>
262
- '''
263
-
264
-
265
- @app.get("/")
266
- async def patched_index():
267
- try:
268
- with open("/app/static/index.html", "r", encoding="utf-8") as f:
269
- html = f.read()
270
- if "window.safeJson" not in html:
271
- html = html.replace("</body>", _FRONTEND_PATCH + "</body>")
272
- return HTMLResponse(content=html)
273
- except Exception as e:
274
- return HTMLResponse(content=f"<pre>Index error: {str(e)}</pre>", status_code=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
restore_runner.py DELETED
@@ -1,31 +0,0 @@
1
- import os
2
- import sys
3
- import subprocess
4
- from huggingface_hub import snapshot_download
5
-
6
- REVISION = os.environ.get("VNEWS_RESTORE_REVISION", "bcaa2dc")
7
- REPO_ID = os.environ.get("VNEWS_REPO_ID", "bep40/vnews")
8
-
9
- # Download exact Space snapshot from Hugging Face Hub.
10
- # This avoids manually copying huge files from an old commit.
11
- snapshot_dir = snapshot_download(
12
- repo_id=REPO_ID,
13
- repo_type="space",
14
- revision=REVISION,
15
- local_dir="/tmp/vnews_restore",
16
- local_dir_use_symlinks=False,
17
- )
18
-
19
- os.chdir(snapshot_dir)
20
- sys.path.insert(0, snapshot_dir)
21
-
22
- # Commit bcaa2dc Dockerfile ran ai_patch:app.
23
- cmd = [
24
- "uvicorn",
25
- "ai_patch:app",
26
- "--host",
27
- "0.0.0.0",
28
- "--port",
29
- "7860",
30
- ]
31
- os.execvp(cmd[0], cmd)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
wc2026_scraper.py DELETED
@@ -1,174 +0,0 @@
1
- """
2
- World Cup 2026 - NEWS uses same 8-source search as hashtag (NOT Google News)
3
- """
4
- import requests, re, time, threading
5
- from bs4 import BeautifulSoup
6
- from urllib.parse import quote
7
- from datetime import datetime, timedelta, timezone
8
- from concurrent.futures import ThreadPoolExecutor, as_completed
9
-
10
- BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept":"application/json, text/javascript, */*; q=0.01","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/giai-dau/24254/standings/world-cup","X-Requested-With":"XMLHttpRequest"}
11
- UA = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36','Accept-Language':'vi-VN,vi;q=0.9'}
12
- WC_ID=24254;VN_TZ=timezone(timedelta(hours=7));CACHE={};LOCK=threading.Lock()
13
- def _clean(s):return re.sub(r'\s+',' ',str(s or'')).strip()
14
- def _cached(key,ttl=120):
15
- with LOCK:
16
- if key in CACHE and time.time()-CACHE[key]['t']<ttl:return CACHE[key]['d']
17
- return None
18
- def _set(key,data):
19
- with LOCK:CACHE[key]={'t':time.time(),'d':data}
20
- def _bongda(endpoint):
21
- try:
22
- r=requests.get(f"https://bongda.com.vn{endpoint}",headers=BONGDA_HEADERS,timeout=10)
23
- if r.status_code==200:
24
- data=r.json()
25
- if data.get("status")=="success":return data.get("html","")
26
- except:pass
27
- return""
28
- def _fetch(url,timeout=12):
29
- try:r=requests.get(url,headers=UA,timeout=timeout,allow_redirects=True);r.encoding='utf-8';return r.text if r.status_code==200 else''
30
- except:return''
31
- def _get_og_image(url):
32
- try:
33
- r=requests.get(url,headers=UA,timeout=8,allow_redirects=True);r.encoding='utf-8'
34
- if r.status_code!=200:return''
35
- m=re.search(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']',r.text[:5000])
36
- if not m:m=re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']',r.text[:5000])
37
- if m:img=m.group(1);return('https:'+img if img.startswith('//') else img)
38
- except:pass
39
- return''
40
- def _utc_to_vn(date_str):
41
- try:
42
- dt_str=date_str.replace('Z','+00:00')
43
- if '+' not in dt_str and 'T' in dt_str:dt_str+='+00:00'
44
- return datetime.fromisoformat(dt_str).astimezone(VN_TZ).strftime('%H:%M %d/%m/%Y')
45
- except:return date_str
46
-
47
- def scrape_fixtures():
48
- c=_cached('wc_fix',600)
49
- if c is not None:return c
50
- matches=[]
51
- try:
52
- r=requests.get('https://fixturedownload.com/feed/json/fifa-world-cup-2026',headers=UA,timeout=15)
53
- if r.status_code==200:
54
- for m in r.json():
55
- match={'match_number':m.get('MatchNumber',''),'round':m.get('RoundNumber',''),'group':m.get('Group',''),'date_utc':m.get('DateUtc',''),'date_vn':_utc_to_vn(m.get('DateUtc','')),'location':m.get('Location',''),'home':m.get('HomeTeam',''),'away':m.get('AwayTeam',''),'home_score':m.get('HomeTeamScore'),'away_score':m.get('AwayTeamScore')}
56
- if match['home_score'] is not None and match['away_score'] is not None:match['score']=f"{match['home_score']} - {match['away_score']}";match['status']='finished'
57
- else:
58
- match['score']='vs'
59
- try:
60
- mdt=datetime.fromisoformat(m.get('DateUtc','').replace('Z','+00:00'));diff=(mdt-datetime.now(timezone.utc)).total_seconds()
61
- match['status']='live' if -7200<diff<0 else 'today' if 0<=diff<86400 else 'upcoming'
62
- except:match['status']='upcoming'
63
- matches.append(match)
64
- except:pass
65
- r={'matches':matches,'total':len(matches)};_set('wc_fix',r);return r
66
-
67
- def scrape_standings():
68
- c=_cached('wc_bxh',180)
69
- if c is not None:return c
70
- html=_bongda(f"/api/league-table/home?tournament_id={WC_ID}&is_detail=True")
71
- if not html:html=_bongda(f"/api/league-table/home?tournament_id={WC_ID}")
72
- r={'html':html};_set('wc_bxh',r);return r
73
-
74
- def scrape_stats():
75
- c=_cached('wc_stats',600)
76
- if c is not None:return c
77
- html=_bongda(f"/api/event-standing/player-performance?tournament_id={WC_ID}")
78
- r={'html':html};_set('wc_stats',r);return r
79
-
80
- def scrape_history():return scrape_standings()
81
- def scrape_h2h(event_id):return{'html':_bongda(f"/api/fixtures/head-to-head?event_id={event_id}")}
82
- def scrape_lineups(event_id):return{'html':_bongda(f"/api/fixtures/lineups?event_id={event_id}")}
83
- def scrape_match_detail(event_id):return{'html':_bongda(f"/api/fixtures/commentaries?event_id={event_id}")}
84
- def scrape_summary():return scrape_standings()
85
-
86
- # === NEWS: uses SAME 8-source search as hashtag (NOT Google News) ===
87
- def _search_source(url_tpl, topic, selector, base_url='', limit=6):
88
- """Generic search on a VN news site."""
89
- items=[]
90
- try:
91
- url=url_tpl.format(q=quote(topic))
92
- r=requests.get(url,headers=UA,timeout=10,allow_redirects=True);r.encoding='utf-8'
93
- soup=BeautifulSoup(r.text,'lxml')
94
- for a in soup.select(selector)[:limit*2]:
95
- t=_clean(a.get('title','') or a.get_text(strip=True))
96
- href=a.get('href','')
97
- if not t or len(t)<15:continue
98
- if not href.startswith('http'):href=base_url+href
99
- items.append({'title':t,'link':href})
100
- if len(items)>=limit:break
101
- except:pass
102
- return items
103
-
104
- def scrape_wc_news():
105
- """WC news from 8 sources (same as hashtag search) - NOT Google News."""
106
- c=_cached('wc_news',300)
107
- if c is not None:return c
108
-
109
- topic="World Cup 2026"
110
- sources_cfg=[
111
- ('https://timkiem.vnexpress.net/?q={q}','article.item-news h2 a, article.item-news h3 a','','VnExpress'),
112
- ('https://dantri.com.vn/tim-kiem/{q}.htm','h3 a[href], .article-title a[href]','https://dantri.com.vn','Dân Trí'),
113
- ('https://vietnamnet.vn/tim-kiem?q={q}','h3 a[href], .vnn-title a','https://vietnamnet.vn','VietNamNet'),
114
- ('https://thanhnien.vn/tim-kiem?q={q}','h3 a[href], .box-title a','https://thanhnien.vn','Thanh Niên'),
115
- ('https://tuoitre.vn/tim-kiem.htm?keywords={q}','h3 a[href], .box-title-text a','https://tuoitre.vn','Tuổi Trẻ'),
116
- ('https://thethaovanhoa.vn/tim-kiem.htm?keyword={q}','h3 a[href], .title a[href]','https://thethaovanhoa.vn','TT&VH'),
117
- ('https://bongda.com.vn/tim-kiem.html?q={q}','h3 a[href], .title a[href]','https://bongda.com.vn','Bóng Đá'),
118
- ('https://genk.vn/tim-kiem?q={q}','a[href$=".chn"]','https://genk.vn','GenK'),
119
- ]
120
-
121
- all_news=[]
122
- def _fetch_source(cfg):
123
- url_tpl,selector,base,source=cfg
124
- items=_search_source(url_tpl,topic,selector,base,8)
125
- return [(item,source) for item in items]
126
-
127
- with ThreadPoolExecutor(8) as ex:
128
- futs=[ex.submit(_fetch_source,cfg) for cfg in sources_cfg]
129
- for f in as_completed(futs,timeout=14):
130
- try:
131
- for item,source in f.result():
132
- all_news.append({'title':item['title'],'link':item['link'],'img':'','source':source})
133
- except:pass
134
-
135
- # Deduplicate
136
- seen=set();unique=[]
137
- for n in all_news:
138
- if n['link'] not in seen:seen.add(n['link']);unique.append(n)
139
-
140
- # Fetch og:image for first 12
141
- def _fill(item):
142
- if not item.get('img'):item['img']=_get_og_image(item['link'])
143
- with ThreadPoolExecutor(6) as ex:
144
- futs=[ex.submit(_fill,n) for n in unique[:12]]
145
- for f in as_completed(futs,timeout=12):
146
- try:f.result()
147
- except:pass
148
-
149
- _set('wc_news',unique[:30]);return unique[:30]
150
-
151
- def scrape_road_to_wc():
152
- """Road to WC also uses direct search (not Google News)."""
153
- c=_cached('wc_road',600)
154
- if c is not None:return c
155
- articles=[]
156
- for topic in['đường tới World Cup 2026','tuyển Việt Nam World Cup 2026']:
157
- items=_search_source('https://timkiem.vnexpress.net/?q={q}',topic,'article.item-news h2 a, article.item-news h3 a','',5)
158
- for item in items:
159
- if item['link'] not in[x['link'] for x in articles]:
160
- img=_get_og_image(item['link'])
161
- articles.append({'title':item['title'],'link':item['link'],'img':img,'source':'VnExpress','type':'road'})
162
- _set('wc_road',articles[:20]);return articles[:20]
163
-
164
- def get_wc2026_all():
165
- c=_cached('wc_all',90)
166
- if c is not None:return c
167
- data={}
168
- with ThreadPoolExecutor(5) as ex:
169
- futs={ex.submit(scrape_fixtures):'fixtures',ex.submit(scrape_standings):'standings',ex.submit(scrape_stats):'stats',ex.submit(scrape_wc_news):'news',ex.submit(scrape_road_to_wc):'road'}
170
- for f in as_completed(futs,timeout=35):
171
- key=futs[f]
172
- try:data[key]=f.result()
173
- except:data[key]={} if key in('fixtures','standings','stats') else[]
174
- data['summary']=data.get('standings',{});_set('wc_all',data);return data