bep40 commited on
Commit
f8dbfb7
·
verified ·
1 Parent(s): e5a8467

Upload ai_fix2.py

Browse files
Files changed (1) hide show
  1. ai_fix2.py +356 -0
ai_fix2.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ candidates = []
84
+ seen = set()
85
+ for u in _ddg_article_urls(topic, limit=14):
86
+ if u not in seen:
87
+ seen.add(u)
88
+ candidates.append({"url": u, "title": "", "via": base._domain(u)})
89
+ try:
90
+ _ctx, srcs = base.web_context(topic, limit=8)
91
+ for s in srcs or []:
92
+ u = s.get("url") or ""
93
+ if u.startswith("http") and u not in seen:
94
+ seen.add(u)
95
+ candidates.append(s)
96
+ except Exception:
97
+ pass
98
+ for s in _rss_article_urls(topic, limit=10):
99
+ u = s.get("url") or ""
100
+ if u.startswith("http") and u not in seen:
101
+ seen.add(u)
102
+ candidates.append(s)
103
+
104
+ out = []
105
+ for s in candidates[:24]:
106
+ url = s.get("url") or ""
107
+ try:
108
+ page = base.scrape_any_url(url)
109
+ raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
110
+ if not _is_real_article_text(raw):
111
+ continue
112
+ title = page.get("title") or s.get("title") or url
113
+ via = page.get("via") or s.get("via") or base._domain(url)
114
+ out.append({
115
+ "title": title,
116
+ "url": url,
117
+ "raw": raw,
118
+ "image": page.get("image") or "",
119
+ "via": via,
120
+ "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
121
+ })
122
+ if len(out) >= limit:
123
+ break
124
+ except Exception:
125
+ continue
126
+ return out[:limit]
127
+
128
+
129
+ def sentence_split(text):
130
+ text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
131
+ text = re.sub(r"\n+", ". ", text)
132
+ parts = []
133
+ for s in re.split(r"(?<=[\.\!\?])\s+", text):
134
+ s = clean(s)
135
+ if len(s) >= 8:
136
+ parts.append(s)
137
+ return parts
138
+
139
+
140
+ def srt_time(sec):
141
+ ms = int((sec - int(sec)) * 1000)
142
+ sec = int(sec)
143
+ return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
144
+
145
+
146
+ def parse_timecode(t):
147
+ t = t.replace(',', '.')
148
+ parts = t.split(':')
149
+ if len(parts) == 3:
150
+ return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
151
+ if len(parts) == 2:
152
+ return int(parts[0])*60 + float(parts[1])
153
+ return float(parts[0])
154
+
155
+
156
+ def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
157
+ try:
158
+ txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
159
+ cues = []
160
+ i = 0
161
+ while i < len(txt):
162
+ line = txt[i].strip()
163
+ if '-->' in line:
164
+ a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
165
+ start = parse_timecode(a) / speed
166
+ end = parse_timecode(b) / speed
167
+ i += 1
168
+ texts = []
169
+ while i < len(txt) and txt[i].strip():
170
+ texts.append(txt[i].strip())
171
+ i += 1
172
+ s = clean(' '.join(texts))
173
+ if s:
174
+ cues.append((start, end, s))
175
+ i += 1
176
+ if not cues:
177
+ return False
178
+ with open(srt_path, 'w', encoding='utf-8') as f:
179
+ for idx, (st, en, s) in enumerate(cues, 1):
180
+ if en <= st:
181
+ en = st + 1.2
182
+ f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
183
+ return True
184
+ except Exception:
185
+ return False
186
+
187
+
188
+ def write_weighted_srt(script, path, total_duration):
189
+ subs = sentence_split(script)
190
+ if not subs:
191
+ subs = [clean(script)[:140] or "VNEWS"]
192
+ total_chars = max(1, sum(len(x) for x in subs))
193
+ usable = max(2.0, float(total_duration) - 1.0)
194
+ cur = 0.5
195
+ with open(path, "w", encoding="utf-8") as f:
196
+ for i, s in enumerate(subs, 1):
197
+ dur = max(1.8, min(7.0, usable * len(s) / total_chars))
198
+ start = cur
199
+ end = min(total_duration - 0.15, cur + dur)
200
+ cur = end + 0.18
201
+ f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
202
+ if cur >= total_duration - 0.2:
203
+ break
204
+
205
+
206
+ def tts_script_full(post, emotion):
207
+ title = clean(post.get("title", ""))
208
+ text = clean(post.get("text", ""))
209
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
210
+ prefix = {
211
+ "urgent": "Tin nhanh.",
212
+ "warm": "Câu chuyện đáng chú ý.",
213
+ "serious": "Bản tin nghiêm túc.",
214
+ "energetic": "Cập nhật nổi bật.",
215
+ }.get(emotion, "")
216
+ script = f"{prefix} {title}. {text}".strip()
217
+ if len(script) > 3600:
218
+ tmp = script[:3600]
219
+ cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
220
+ script = tmp[:cut + 1] if cut > 1600 else tmp
221
+ script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
222
+ script = re.sub(r"\n{2,}", "\n", script).strip()
223
+ return script
224
+
225
+
226
+ _PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
227
+ 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)]
228
+
229
+
230
+ @app.post('/api/topic_post')
231
+ async def topic_post_aggregate(request: Request):
232
+ body = await request.json()
233
+ topic = base._clean_text(body.get('topic',''))
234
+ if not topic:
235
+ return JSONResponse({'error':'missing topic'}, status_code=400)
236
+ articles = _topic_source_articles(topic, limit=5)
237
+ if not articles:
238
+ 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)
239
+ source_blocks = []
240
+ sources = []
241
+ image = ""
242
+ for i, art in enumerate(articles, 1):
243
+ raw = art.get('raw','')
244
+ source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
245
+ sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
246
+ if not image and art.get('image'):
247
+ image = art.get('image')
248
+ ctx = "\n\n".join(source_blocks)
249
+ prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
250
+
251
+ Chủ đề: {topic}
252
+
253
+ NHIỆM VỤ:
254
+ - Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
255
+ - 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.
256
+ - Không tạo mỗi tiêu đề thành một bài riêng.
257
+ - Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
258
+ - Không lặp ý giữa các nguồn.
259
+ - Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
260
+ - Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
261
+ - Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
262
+
263
+ Nội dung nguồn:
264
+ {ctx[:16000]}"""
265
+ text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
266
+ text = prev._postprocess_ai_text(text, max_units=7)
267
+ if 'Nguồn tham khảo:' not in text:
268
+ text += '\n\n' + prev._source_line(sources)
269
+ post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
270
+ posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
271
+ return JSONResponse({'post': post, 'count_sources': len(sources)})
272
+
273
+
274
+ @app.post('/api/ai/short/{post_id}')
275
+ async def ai_short_full(post_id: str, request: Request):
276
+ try:
277
+ body = await request.json()
278
+ except Exception:
279
+ body = {}
280
+ voice = str(body.get('voice','nu')).lower().strip()
281
+ emotion = str(body.get('emotion','neutral')).lower().strip()
282
+ speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
283
+ posts = base._load_ai_wall()
284
+ post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
285
+ if not post:
286
+ return JSONResponse({'error':'post not found'}, status_code=404)
287
+ os.makedirs(base.SHORTS_DIR, exist_ok=True)
288
+ suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
289
+ out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
290
+ if os.path.exists(out_mp4):
291
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
292
+ base._save_ai_wall(posts)
293
+ return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
294
+ work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
295
+ 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')
296
+ try:
297
+ base._download_image(post.get('img'), post.get('title','AI news'), img)
298
+ prev._make_short_frame_full(post, img, frame)
299
+ script = tts_script_full(post, emotion)
300
+ 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')
301
+ used_edge = False
302
+ try:
303
+ 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)
304
+ used_edge = True
305
+ except Exception:
306
+ tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
307
+ try:
308
+ base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
309
+ except TypeError:
310
+ base.gTTS(script, lang='vi', slow=False).save(audio)
311
+ subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
312
+ duration = 45.0
313
+ try:
314
+ pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
315
+ duration = float((pr.stdout or b'45').decode().strip() or 45)
316
+ except Exception:
317
+ pass
318
+ if used_edge and os.path.exists(vtt):
319
+ ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
320
+ if not ok:
321
+ write_weighted_srt(script, srt, duration)
322
+ else:
323
+ write_weighted_srt(script, srt, duration)
324
+ 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("'", "\\'"))
325
+ 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]
326
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
327
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
328
+ post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
329
+ base._save_ai_wall(posts)
330
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
331
+ except Exception as e:
332
+ return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
333
+
334
+
335
+ @app.get('/api/ai/short-file/{file_id}')
336
+ def ai_short_file_full(file_id: str):
337
+ path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
338
+ if not os.path.exists(path):
339
+ return JSONResponse({'error':'not found'}, status_code=404)
340
+ return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
341
+
342
+
343
+ app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
344
+
345
+ @app.get('/')
346
+ async def index_fix2():
347
+ with open('/app/static/index.html','r',encoding='utf-8') as f:
348
+ html = f.read()
349
+ inject = prev.PATCH_INJECT + r'''
350
+ <script>
351
+ (function(){
352
+ 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'));};
353
+ })();
354
+ </script>
355
+ '''
356
+ return HTMLResponse(html.replace('</body>', inject+'\n</body>'))