공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
8.3 KiB
Python
157 lines
8.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""사이트 직접 대조 검수 — N(저작물 유형)·L 이상치 탐지(읽기전용 audit).
|
|
각 데이터행 페이지/게시판을 실제 렌더해 본문 콘텐츠 이미지/영상을 '원본크기' 기준 탐지하고
|
|
시트 N과 비교해 과표시/누락/모순 이상치만 보고 + 의심행 실제이미지 몽타주.
|
|
|
|
⚠ 읽기전용: 엑셀을 수정하지 않는다. 사람이 몽타주 보고 최종 판정.
|
|
⚠ 콘텐츠 이미지 경로는 사이트마다 다름(kesco=/common/images/sub) → 경로(/common)로 거르지 말고
|
|
파일명 기준 데코필터(logo/icon/btn/mark_…) + naturalWidth 사용. (kesco 교훈)
|
|
|
|
사용:
|
|
python -X utf8 _검수_N대조.py <엑셀경로> [--rows 67-끝|3-끝] [--limit N]
|
|
출력: <기관>_naudit\ (의심행 실이미지 + montage_*.png) + naudit.json + 콘솔 이상치표
|
|
"""
|
|
import sys, os, re, json, io, warnings, argparse
|
|
import openpyxl, requests
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
warnings.filterwarnings('ignore'); requests.packages.urllib3.disable_warnings()
|
|
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
# 파일명 기준 데코(절대 콘텐츠 아님). 경로(/common 등)로 거르지 말 것.
|
|
NAME_NOISE = re.compile(r'(^ico[_\-]|icon|logo|btn[_\-]|^bul|bullet|^bg[_\-]|banner|^sns|blank|spacer|loading|arrow|^dot|^line[_\.]|^top_|footer|^foot|^header|symbol|copyright|^qr|no_img|noimage|share|facebook|insta|twitter|naver|kakao|mark_|webwatch|eprivacy|wa\.png)', re.I)
|
|
PATH_NOISE = re.compile(r'(/skin/|/template/|/editor/|/sample)', re.I)
|
|
CONT_SEL = '#contents,.contents,#content,.content,.sub_content,.sub_contents,#sub_content,#contents_area,.contents_area,#container,#contentBody,.board_view,.view_con'
|
|
JS = r"""(sel)=>{
|
|
const cont=document.querySelector(sel)||document.body;
|
|
const imgs=[];
|
|
for(const i of cont.querySelectorAll('img')){imgs.push({src:i.currentSrc||i.src||'',alt:(i.alt||''),nw:i.naturalWidth,nh:i.naturalHeight});}
|
|
let vid=0;
|
|
if(cont.querySelector('video,source[src*=".mp4"]'))vid++;
|
|
for(const f of cont.querySelectorAll('iframe')){const s=f.src||'';if(/youtube|youtu\.be|vimeo|player\./i.test(s))vid++;}
|
|
return {imgs,vid};
|
|
}"""
|
|
|
|
|
|
def real_imgs(items):
|
|
out, seen = [], set()
|
|
for it in items:
|
|
src = it.get('src', '')
|
|
if not src or src in seen:
|
|
continue
|
|
base = src.split('?')[0].split('/')[-1]
|
|
if NAME_NOISE.search(base) or PATH_NOISE.search(src):
|
|
continue
|
|
nw, nh = it.get('nw', 0), it.get('nh', 0)
|
|
if nw >= 200 and nh >= 150 and nw * nh >= 40000:
|
|
seen.add(src); out.append(it)
|
|
return out
|
|
|
|
|
|
def parse_rows(spec, lo, hi):
|
|
if not spec:
|
|
return lo, hi
|
|
a, b = spec.split('-')
|
|
a = int(a); b = hi if b in ('끝', 'end', '') else int(b)
|
|
return a, b
|
|
|
|
|
|
def run():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument('xlsx'); ap.add_argument('--rows', default='')
|
|
ap.add_argument('--limit', type=int, default=0)
|
|
a = ap.parse_args()
|
|
wb = openpyxl.load_workbook(a.xlsx); ws = wb.active
|
|
name = os.path.splitext(os.path.basename(a.xlsx))[0]
|
|
OUT = os.path.join(os.path.dirname(a.xlsx), f'{name}_naudit'); os.makedirs(OUT, exist_ok=True)
|
|
r0, r1 = parse_rows(a.rows, 3, ws.max_row)
|
|
targets = []
|
|
for r in range(r0, r1 + 1):
|
|
if ws.cell(r, 2).value is None and all(ws.cell(r, c).value in (None, '') for c in range(4, 12)):
|
|
continue
|
|
L = ws.cell(r, 12).value or ''
|
|
M = ws.cell(r, 13).value
|
|
N = ws.cell(r, 14).value or ''
|
|
url = ws.cell(r, 11).value
|
|
targets.append((r, url, L, M, N))
|
|
if a.limit:
|
|
targets = targets[:a.limit]
|
|
print(f'[{name}] 대조 대상 {len(targets)}행', flush=True)
|
|
from playwright.sync_api import sync_playwright
|
|
plan = []; dl = []
|
|
sess = requests.Session(); sess.headers['User-Agent'] = UA
|
|
with sync_playwright() as p:
|
|
b = p.chromium.launch()
|
|
pg = b.new_page(user_agent=UA, viewport={'width': 1280, 'height': 2000})
|
|
for idx, (r, url, L, M, N) in enumerate(targets):
|
|
rec = {'row': r, 'L': L, 'M': M, 'N': N, 'url': url}
|
|
# 사이트면 뒤컬럼 공백이어야(11항)
|
|
if L == '사이트':
|
|
if N or (M not in (None, '')):
|
|
rec['flag'] = 'SITE_TAIL'; rec['note'] = f'사이트인데 M={M} N={N}'
|
|
plan.append(rec); continue
|
|
if not (url and isinstance(url, str) and url.startswith('http')):
|
|
plan.append(rec); continue
|
|
try:
|
|
pg.goto(url, timeout=22000, wait_until='domcontentloaded'); pg.wait_for_timeout(1200)
|
|
try:
|
|
pg.evaluate("()=>window.scrollTo(0,document.body.scrollHeight)"); pg.wait_for_timeout(800)
|
|
pg.evaluate("()=>window.scrollTo(0,0)"); pg.wait_for_timeout(200)
|
|
except Exception:
|
|
pass
|
|
d = pg.evaluate(JS, CONT_SEL)
|
|
ci = real_imgs(d['imgs']); det_img = len(ci); det_vid = d['vid']
|
|
rec['det_img'] = det_img; rec['det_vid'] = det_vid
|
|
has_img = '이미지' in N; has_vid = '영상' in N
|
|
# 이상치 규칙
|
|
if det_img >= 1 and not has_img:
|
|
rec['flag'] = 'UNDER_IMG'; rec['note'] = f'본문이미지 {det_img}개 검출인데 N에 이미지 없음'
|
|
elif has_img and det_img == 0:
|
|
rec['flag'] = 'OVER_IMG'; rec['note'] = 'N=이미지인데 본문 실이미지 0(차트/도식/마크 의심)'
|
|
if det_vid and not has_vid:
|
|
rec['flag'] = (rec.get('flag', '') + '+VID').strip('+'); rec['note'] = (rec.get('note', '') + ' | 영상검출인데 N영상없음').strip()
|
|
if L == '게시판' and (M == 0) and N != '없음':
|
|
rec['flag'] = (rec.get('flag', '') + '+BBS0').strip('+'); rec['note'] = (rec.get('note', '') + ' | 빈게시판M0인데 N≠없음').strip()
|
|
# 의심행 대표이미지 다운로드(몽타주용)
|
|
if rec.get('flag', '').startswith(('UNDER', 'OVER')):
|
|
for it in ci[:3]:
|
|
try:
|
|
rr = sess.get(it['src'], timeout=10, verify=False)
|
|
im = Image.open(io.BytesIO(rr.content)).convert('RGB'); im.thumbnail((300, 300))
|
|
lp = os.path.join(OUT, f'r{r}_{len(dl)}.jpg'); im.save(lp, quality=80)
|
|
lab = ws.cell(r, 6).value or ws.cell(r, 5).value or ws.cell(r, 4).value or ''
|
|
dl.append((r, rec['flag'], str(lab)[:16], lp))
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
rec['err'] = str(e)[:40]
|
|
plan.append(rec)
|
|
if (idx + 1) % 25 == 0:
|
|
print(f' {idx+1}/{len(targets)}', flush=True)
|
|
b.close()
|
|
# 몽타주(의심행만)
|
|
try: font = ImageFont.truetype('malgun.ttf', 15)
|
|
except Exception: font = ImageFont.load_default()
|
|
cols, cw, ch = 5, 240, 270; per = cols * 6; mps = []
|
|
for gi in range(0, len(dl), per):
|
|
chunk = dl[gi:gi + per]; rn = (len(chunk) + cols - 1) // cols
|
|
cv = Image.new('RGB', (cols * cw, rn * ch), 'white'); dr = ImageDraw.Draw(cv)
|
|
for j, (r, flag, lab, lp) in enumerate(chunk):
|
|
try:
|
|
im = Image.open(lp).convert('RGB'); im.thumbnail((cw - 8, ch - 30))
|
|
except Exception: continue
|
|
cx, cy = (j % cols) * cw, (j // cols) * ch; cv.paste(im, (cx + 4, cy + 26))
|
|
dr.rectangle([cx, cy, cx + cw - 1, cy + ch - 1], outline='gray')
|
|
dr.text((cx + 4, cy + 4), f'r{r} {flag}', 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, 'naudit.json'), 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
|
flags = [x for x in plan if x.get('flag')]
|
|
print(f'\n=== 이상치 {len(flags)}건 ===', flush=True)
|
|
for x in flags:
|
|
print(f" r{x['row']} [{x['flag']}] L={x['L']} N={x['N']} :: {x.get('note','')}", flush=True)
|
|
print(f'\n몽타주 {len(mps)}장:', flush=True)
|
|
for mp in mps: print(' MONT:', mp, flush=True)
|
|
print(' JSON:', os.path.join(OUT, 'naudit.json'), flush=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run()
|