bep40 commited on
Commit
5ec348f
·
verified ·
1 Parent(s): c504a4c

Improve article image detection without over-filtering

Browse files
Files changed (1) hide show
  1. 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)