# -*- coding: utf-8 -*- """komipo Phase2~4 (throttled Playwright). L 이미 채워짐. 게시판 M·N·O/P/Q. 출력: L~Q + _nshots(이미지후보 몽타주). N은 후보만 표시→몽타주 후 시각판정 별도 적용.""" import re, json, io, os, time, warnings import openpyxl, requests from PIL import Image, ImageDraw, ImageFont from playwright.sync_api import sync_playwright warnings.filterwarnings('ignore'); requests.packages.urllib3.disable_warnings() FN='한국중부발전.xlsx'; OUT='_nshots'; os.makedirs(OUT,exist_ok=True) UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36' NAME_NOISE=re.compile(r'(^ico[_\-]|icon|logo|btn[_\-]|^bul|bullet|^bg[_\-]|banner|^sns|blank|spacer|loading|arrow|^dot|footer|^foot|^header|symbol|copyright|^qr|no_img|noimage|share|facebook|insta|twitter|naver|kakao|mark_|public|kogl|opentype|opencode)',re.I) KOGL=re.compile(r'open(?:type|code)0*(\d)\.(?:png|jpe?g|gif)',re.I) JS=r"""()=>{ const c=document.querySelector('#contents,.contents,#content,.content,.sub_content,#container,.cont_wrap,.contents_wrap')||document.body; const imgs=[]; for(const i of c.querySelectorAll('img')){imgs.push({src:i.currentSrc||i.src||'',alt:i.alt||'',nw:i.naturalWidth,nh:i.naturalHeight});} let vid=0; if(c.querySelector('video,source[src*=".mp4"]'))vid++; for(const f of c.querySelectorAll('iframe')){if(/youtube|youtu\.be|vimeo/i.test(f.src||''))vid++;} // 게시판 총건수/리스트 let total=null; const m=(document.body.innerText||'').match(/(?:총|전체)\s*[:\-]?\s*([\d,]+)\s*건/); if(m) total=parseInt(m[1].replace(/,/g,'')); let rows=document.querySelectorAll('table.board_list tbody tr, .board_list li, table tbody tr, .bbs_list tbody tr').length; // KOGL 마크 let kogl=[]; for(const i of document.querySelectorAll('img')){const mm=(i.src||'').match(/open(?:type|code)0*(\d)/i); if(mm)kogl.push(parseInt(mm[1]));} return {imgs,vid,total,rows,kogl:[...new Set(kogl)]}; }""" def realimg(items): out=[] for it in items: s=it['src']; if not s: continue base=s.split('?')[0].split('/')[-1] if NAME_NOISE.search(base): continue if it['nw']>=200 and it['nh']>=150 and it['nw']*it['nh']>=40000: out.append(it) return out wb=openpyxl.load_workbook(FN); ws=wb.active sess=requests.Session(); sess.headers['User-Agent']=UA targets=[] for r in range(3,ws.max_row+1): L=ws.cell(r,12).value; u=ws.cell(r,11).value if L=='사이트' or not(u and str(u).startswith('http')): continue targets.append((r,str(u),L)) print('대상',len(targets),flush=True) plan=[]; dl=[] with sync_playwright() as p: b=p.chromium.launch(); pg=b.new_page(user_agent=UA,viewport={'width':1280,'height':2000}) for idx,(r,u,L) in enumerate(targets): d=None for attempt in range(3): try: pg.goto(u,timeout=25000,wait_until='domcontentloaded'); pg.wait_for_timeout(900) d=pg.evaluate(JS); break except Exception: time.sleep(1.5) rec={'row':r,'L':L,'url':u} if d: ri=realimg(d['imgs']); rec['nimg']=len(ri); rec['vid']=d['vid']; rec['kogl']=d['kogl'] # M if L=='게시판': M=d['total'] if d['total'] is not None else (d['rows'] if d['rows'] else 0) ws.cell(r,13).value=M; rec['M']=M else: ws.cell(r,13).value=1 # O/P/Q if d['kogl']: ws.cell(r,15).value=','.join(str(x) for x in sorted(set(d['kogl']))) ws.cell(r,16).value='게시물' if L=='게시판' else '게시물' ws.cell(r,17).value='Y' else: ws.cell(r,15).value='미부착' # N 후보 다운로드 if ri: rec['auto']='candidate' for it in ri[:2]: try: rr=sess.get(it['src'],timeout=10,verify=False); im=Image.open(io.BytesIO(rr.content)).convert('RGB'); im.thumbnail((280,280)) lp=os.path.join(OUT,f'r{r}_{len(dl)}.jpg'); im.save(lp,quality=78) lab=ws.cell(r,7).value or ws.cell(r,6).value or ws.cell(r,5).value or '' dl.append((r,str(lab)[:14],lp)) except Exception: pass else: rec['auto']='어문' if (L!='게시판' or rec.get('M',1)) else '없음' # 임시 N: 후보면 어문,이미지 / 영상 / 빈게시판 없음 — 시각판정 전 자동 if d['vid'] and not ri: ws.cell(r,14).value='어문,영상' elif ri: ws.cell(r,14).value='어문,이미지' elif L=='게시판' and rec.get('M',0)==0: ws.cell(r,14).value='없음' else: ws.cell(r,14).value='어문' else: rec['err']=1; ws.cell(r,13).value=ws.cell(r,13).value or (1 if L=='페이지' else 0); ws.cell(r,14).value='어문'; ws.cell(r,15).value='미부착' plan.append(rec) if (idx+1)%20==0: wb.save(FN); print(f' {idx+1}/{len(targets)} (저장)',flush=True) time.sleep(0.4) b.close() wb.save(FN) # 몽타주 try: font=ImageFont.truetype('malgun.ttf',15) except: font=ImageFont.load_default() cols,cw,ch=6,200,230; per=cols*7; mps=[] for gi in range(0,len(dl),per): ch_=dl[gi:gi+per]; rn=(len(ch_)+cols-1)//cols cv=Image.new('RGB',(cols*cw,rn*ch),'white'); dr=ImageDraw.Draw(cv) for j,(r,lab,lp) in enumerate(ch_): try: im=Image.open(lp).convert('RGB'); im.thumbnail((cw-8,ch-28)) except: continue cx,cy=(j%cols)*cw,(j//cols)*ch; cv.paste(im,(cx+4,cy+24)); dr.rectangle([cx,cy,cx+cw-1,cy+ch-1],outline='gray'); dr.text((cx+4,cy+4),f'r{r}',fill='red',font=font) mp=os.path.join(OUT,f'montage_{gi//per+1}.png'); cv.save(mp); mps.append(mp) json.dump(plan,open(os.path.join(OUT,'plan.json'),'w',encoding='utf-8'),ensure_ascii=False,indent=1) print('완료. 후보',len(dl),'몽타주',len(mps),flush=True) for mp in mps: print(' MONT:',mp,flush=True)