Spaces:
Running
Running
Improve article image detection without over-filtering
Browse files- ai_runtime_final2.py +242 -0
ai_runtime_final2.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final2: improve article-image detection without over-filtering real article images."""
|
| 2 |
+
import re, requests
|
| 3 |
+
from urllib.parse import urlparse
|
| 4 |
+
import ai_runtime_final as f1
|
| 5 |
+
from ai_runtime_final import app, base, rt, HTMLResponse, JSONResponse, Request, Query
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _domain(url):
|
| 9 |
+
try:return urlparse(url or '').netloc.replace('www.','')
|
| 10 |
+
except Exception:return ''
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _abs_url(src, base_url):
|
| 14 |
+
if not src:return ''
|
| 15 |
+
src=src.strip()
|
| 16 |
+
if src.startswith('//'):return 'https:'+src
|
| 17 |
+
if src.startswith('/'):
|
| 18 |
+
try:
|
| 19 |
+
p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}'
|
| 20 |
+
except Exception:return src
|
| 21 |
+
return src
|
| 22 |
+
|
| 23 |
+
BAD_RE=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|most-view|banner|qc|quang-cao|sponsor|social|share|comment|author|newsletter)',re.I)
|
| 24 |
+
GOOD_RE=re.compile(r'(article|content|detail|body|post|entry|story|fck|cms|singular|main|news)',re.I)
|
| 25 |
+
IMG_EXT_RE=re.compile(r'\.(jpg|jpeg|png|webp|avif)(\?|$)',re.I)
|
| 26 |
+
ARTICLE_LINK_RE=re.compile(r'\.(html|htm|shtml|tpo|chn)(\?|$)|/\d{4}/|post\d+|article',re.I)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _clean_soup(soup):
|
| 30 |
+
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):
|
| 31 |
+
tag.decompose()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _find_article_block(soup):
|
| 35 |
+
"""Find the article body first; do not delete suspected related blocks before finding it."""
|
| 36 |
+
selectors=[
|
| 37 |
+
'article', 'main article',
|
| 38 |
+
'.article-content','.article__content','.article__body','.article-body','.article-detail','.article__detail',
|
| 39 |
+
'.detail-content','.content-detail','.singular-content','.news-content','.post-content','.entry-content',
|
| 40 |
+
'.knc-content','.fck_detail','.cms-body','.story-body','.maincontent','.main-content',
|
| 41 |
+
'[class*=article-content]','[class*=article__content]','[class*=detail-content]','[class*=singular-content]',
|
| 42 |
+
'[class*=cms-body]','[class*=story-body]'
|
| 43 |
+
]
|
| 44 |
+
for sel in selectors:
|
| 45 |
+
el=soup.select_one(sel)
|
| 46 |
+
if el and (len(el.find_all('p'))>=2 or len(el.find_all(['figure','picture','img']))>=1):
|
| 47 |
+
return el
|
| 48 |
+
best=None;best_score=0
|
| 49 |
+
for el in soup.find_all(['article','main','section','div']):
|
| 50 |
+
cls=' '.join(el.get('class',[]));eid=el.get('id','')
|
| 51 |
+
if BAD_RE.search(cls+' '+eid) and not GOOD_RE.search(cls+' '+eid):
|
| 52 |
+
continue
|
| 53 |
+
ps=el.find_all('p');imgs=el.find_all('img')
|
| 54 |
+
text=' '.join(p.get_text(' ',strip=True) for p in ps)
|
| 55 |
+
long_ps=sum(1 for p in ps if len(p.get_text(' ',strip=True))>40)
|
| 56 |
+
score=long_ps*180+len(ps)*40+min(len(text),5000)+len(imgs)*25
|
| 57 |
+
if GOOD_RE.search(cls+' '+eid):score+=800
|
| 58 |
+
if score>best_score:
|
| 59 |
+
best=el;best_score=score
|
| 60 |
+
return best or soup
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _ancestor_bad(im, block):
|
| 64 |
+
node=im
|
| 65 |
+
while node and node is not block:
|
| 66 |
+
if getattr(node,'name',None) in ['aside','nav','footer']:
|
| 67 |
+
return True
|
| 68 |
+
cls=' '.join(node.get('class',[])) if hasattr(node,'get') else ''
|
| 69 |
+
eid=node.get('id','') if hasattr(node,'get') else ''
|
| 70 |
+
if BAD_RE.search(cls+' '+eid):
|
| 71 |
+
return True
|
| 72 |
+
node=getattr(node,'parent',None)
|
| 73 |
+
return False
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _image_anchor_penalty(im, page_url):
|
| 77 |
+
a=im.find_parent('a')
|
| 78 |
+
if not a:return 0
|
| 79 |
+
href=_abs_url(a.get('href',''),page_url)
|
| 80 |
+
if not href:return 0
|
| 81 |
+
# If anchor opens the image itself, do not penalize.
|
| 82 |
+
if IMG_EXT_RE.search(href):return 0
|
| 83 |
+
# If anchor points to another article, it is probably related content.
|
| 84 |
+
try:
|
| 85 |
+
p1=urlparse(page_url);p2=urlparse(href)
|
| 86 |
+
if href!=page_url and ARTICLE_LINK_RE.search(href) and (p2.path!=p1.path):
|
| 87 |
+
return -100
|
| 88 |
+
except Exception:pass
|
| 89 |
+
return -10
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _near_article_text_score(im):
|
| 93 |
+
score=0
|
| 94 |
+
# caption/figcaption is strong sign of article image
|
| 95 |
+
fig=im.find_parent('figure')
|
| 96 |
+
if fig:
|
| 97 |
+
score+=5
|
| 98 |
+
cap=fig.find('figcaption')
|
| 99 |
+
if cap and len(cap.get_text(' ',strip=True))>10:score+=4
|
| 100 |
+
if im.find_parent('picture'):score+=2
|
| 101 |
+
# paragraph around image
|
| 102 |
+
parent=im.parent
|
| 103 |
+
for node in [parent, getattr(parent,'parent',None) if parent else None, fig]:
|
| 104 |
+
if not node:continue
|
| 105 |
+
ps=node.find_all('p') if hasattr(node,'find_all') else []
|
| 106 |
+
if any(len(p.get_text(' ',strip=True))>40 for p in ps):score+=3;break
|
| 107 |
+
# sibling paragraph near figure/image
|
| 108 |
+
holder=fig or parent
|
| 109 |
+
if holder:
|
| 110 |
+
for sib in [holder.find_previous_sibling(), holder.find_next_sibling()]:
|
| 111 |
+
if sib and len(sib.get_text(' ',strip=True))>40:
|
| 112 |
+
score+=2
|
| 113 |
+
break
|
| 114 |
+
return score
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _image_score(im, src, block, page_url):
|
| 118 |
+
low=(src or '').lower()
|
| 119 |
+
if not src or src.startswith('data:') or 'base64' in low:return -999
|
| 120 |
+
if any(x in low for x in ['logo','icon','avatar','sprite','tracking','pixel','social','share','author']):return -999
|
| 121 |
+
if _ancestor_bad(im,block):return -999
|
| 122 |
+
score=0
|
| 123 |
+
# Explicit dimensions: only reject truly tiny images; if missing dimensions, allow.
|
| 124 |
+
try:
|
| 125 |
+
w=int(re.sub(r'\D','',str(im.get('width') or '0')) or 0);h=int(re.sub(r'\D','',str(im.get('height') or '0')) or 0)
|
| 126 |
+
if (w and w<120) or (h and h<90):return -999
|
| 127 |
+
if w>=500 or h>=300:score+=3
|
| 128 |
+
except Exception:pass
|
| 129 |
+
alt=(im.get('alt') or im.get('title') or '').lower()
|
| 130 |
+
if any(x in alt for x in ['logo','avatar','quảng cáo','advertisement','banner']):return -999
|
| 131 |
+
cls=' '.join(im.get('class',[]));eid=im.get('id','')
|
| 132 |
+
if BAD_RE.search(cls+' '+eid):return -999
|
| 133 |
+
if GOOD_RE.search(cls+' '+eid):score+=2
|
| 134 |
+
score+=_near_article_text_score(im)
|
| 135 |
+
score+=_image_anchor_penalty(im,page_url)
|
| 136 |
+
if any(x in low for x in ['cdn','photo','image','media','upload','thumb','avatar']):score+=1
|
| 137 |
+
# Tienphong and many VN papers use lazy/data src without figure; still accept if inside article block.
|
| 138 |
+
if im.find_parent(['article','main']) or GOOD_RE.search(' '.join(block.get('class',[]))+' '+block.get('id','')):score+=3
|
| 139 |
+
return score
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _extract_img_src(im, page_url):
|
| 143 |
+
src=(im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('data-srcset') or im.get('srcset') or im.get('src') or '')
|
| 144 |
+
if ',' in src:src=src.split(',')[0].strip().split(' ')[0]
|
| 145 |
+
else:src=src.strip().split(' ')[0]
|
| 146 |
+
return _abs_url(src,page_url)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _article_only_images(url):
|
| 150 |
+
imgs=[]
|
| 151 |
+
try:
|
| 152 |
+
from bs4 import BeautifulSoup
|
| 153 |
+
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8'
|
| 154 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 155 |
+
_clean_soup(soup)
|
| 156 |
+
block=_find_article_block(soup)
|
| 157 |
+
candidates=[]
|
| 158 |
+
for el in block.find_all(['figure','picture'],recursive=True):
|
| 159 |
+
im=el.find('img')
|
| 160 |
+
if im and im not in candidates:candidates.append(im)
|
| 161 |
+
for im in block.find_all('img',recursive=True):
|
| 162 |
+
if im not in candidates:candidates.append(im)
|
| 163 |
+
scored=[];seen=set()
|
| 164 |
+
for im in candidates:
|
| 165 |
+
src=_extract_img_src(im,url)
|
| 166 |
+
if not src or src in seen:continue
|
| 167 |
+
seen.add(src)
|
| 168 |
+
sc=_image_score(im,src,block,url)
|
| 169 |
+
if sc>=2:
|
| 170 |
+
scored.append((sc,src))
|
| 171 |
+
# Keep original article order but only for scored images, filtering duplicate URLs.
|
| 172 |
+
good=set(src for sc,src in sorted(scored,reverse=True) if sc>=2)
|
| 173 |
+
for im in candidates:
|
| 174 |
+
src=_extract_img_src(im,url)
|
| 175 |
+
if src in good and src not in imgs:imgs.append(src)
|
| 176 |
+
if len(imgs)>=20:break
|
| 177 |
+
# Fallback: og:image is usually article main image, and better than no image.
|
| 178 |
+
if not imgs:
|
| 179 |
+
og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
|
| 180 |
+
if og:
|
| 181 |
+
src=_abs_url(og.get('content',''),url)
|
| 182 |
+
if src and not any(x in src.lower() for x in ['logo','icon','avatar','sprite']):imgs.append(src)
|
| 183 |
+
except Exception:pass
|
| 184 |
+
return imgs[:20]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _scrape_url_article_only(url):
|
| 188 |
+
data=base.scrape_any_url(url)
|
| 189 |
+
imgs=_article_only_images(url)
|
| 190 |
+
data['images']=imgs
|
| 191 |
+
data['image']=imgs[0] if imgs else ''
|
| 192 |
+
return data
|
| 193 |
+
|
| 194 |
+
# Override the functions used by inherited endpoints.
|
| 195 |
+
f1._article_only_images=_article_only_images
|
| 196 |
+
f1._scrape_url_article_only=_scrape_url_article_only
|
| 197 |
+
|
| 198 |
+
# Replace URL endpoints to use improved extraction.
|
| 199 |
+
_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/','GET')}
|
| 200 |
+
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)]
|
| 201 |
+
|
| 202 |
+
@app.post('/api/url_wall')
|
| 203 |
+
async def final2_url_wall(request:Request):
|
| 204 |
+
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 205 |
+
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 206 |
+
try:data=_scrape_url_article_only(url)
|
| 207 |
+
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 208 |
+
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 209 |
+
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 210 |
+
prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
|
| 211 |
+
|
| 212 |
+
Yêu cầu:
|
| 213 |
+
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 214 |
+
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 215 |
+
- Không lặp ý, không thêm chi tiết ngoài nguồn.
|
| 216 |
+
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 217 |
+
- Hạn chế dùng dấu đầu dòng.
|
| 218 |
+
|
| 219 |
+
Tiêu đề gốc: {data.get('title','')}
|
| 220 |
+
Nguồn: {_domain(url)}
|
| 221 |
+
Nội dung gốc:
|
| 222 |
+
{raw[:16000]}"""
|
| 223 |
+
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 224 |
+
if not text:text=rt.old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(rt.old,'_fallback_summary_from_prompt') else raw[:900]
|
| 225 |
+
text=rt.postprocess(text) if hasattr(rt,'postprocess') else text
|
| 226 |
+
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
|
| 227 |
+
if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
|
| 228 |
+
imgs=data.get('images') or []
|
| 229 |
+
post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
|
| 230 |
+
post['images']=imgs
|
| 231 |
+
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 232 |
+
return JSONResponse({'post':post})
|
| 233 |
+
|
| 234 |
+
@app.post('/api/rewrite_share')
|
| 235 |
+
async def final2_rewrite_share(request:Request):return await final2_url_wall(request)
|
| 236 |
+
@app.post('/api/ai/url')
|
| 237 |
+
async def final2_ai_url(request:Request):return await final2_url_wall(request)
|
| 238 |
+
|
| 239 |
+
@app.get('/')
|
| 240 |
+
async def index_final2():
|
| 241 |
+
html=f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + f1.FINAL_INJECT
|
| 242 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|