공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
267 lines
11 KiB
Python
267 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""부여군 N(저작물 유형) + O/P/Q(공공누리) 전수 재판정.
|
|
사용자 검수(3~37행)를 정답으로 검증 후 38행~ 적용.
|
|
|
|
핵심(부여 buyeo 템플릿 특성):
|
|
- KOGL 마크는 게시판 '상세글'의 `div.gnuri_layer > .codeView01 > img[src=/_module/gnuri/images/img_opentypeNN.png]`
|
|
+ kogl.or.kr/info/licenseTypeN.do 링크. **본문(#txt) 바깥**이라 기존 phase234가 놓침.
|
|
- 상세 URL = `?mode=V&no=...`.
|
|
- 게시판은 글마다 유형 다를 수 있음 → O = **첫 마크 글의 유형**(사용자 컨벤션: 01030502 글1=1유형 → O=1유형).
|
|
- N 이미지: 장식/공통UI(file_icon·see_btn·btn_page·/skin/·/common/·ico/btn/bg) + KOGL 마크 제외.
|
|
지도/PDF iframe = 이미지. 게시판 M=0 = 없음. 실제 업로드 사진만 이미지.
|
|
|
|
사용: python -X utf8 _buyeo_rejudge_NO.py --validate # 3~37 사용자값과 비교
|
|
python -X utf8 _buyeo_rejudge_NO.py --write # 38행~ 기입(백업)
|
|
"""
|
|
import sys, warnings, re, time
|
|
from urllib.parse import urljoin
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import requests, openpyxl
|
|
from bs4 import BeautifulSoup
|
|
|
|
warnings.filterwarnings('ignore')
|
|
S = requests.Session(); S.headers.update({'User-Agent': 'Mozilla/5.0'})
|
|
XLSX = r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\7.부여군\충청남도_부여군.xlsx'
|
|
DOMAIN = 'buyeo.go.kr'
|
|
REF_MAX = 37 # 3~37 = 사용자 검수 정답
|
|
|
|
KOGL_IMG = re.compile(r'(?:new_)?img_open(?:type|code)(\d{1,2})\.(?:png|jpe?g|gif)', re.I)
|
|
KOGL_LINK = re.compile(r'licenseType(\d)', re.I)
|
|
DECO = re.compile(
|
|
r'/site/common/img/|move\.png|no[-_]?img|blank\.|spacer\.|'
|
|
r'/img/(?:icon|ico|bul|bullet|arrow|btn|bg|tit|h\d)|'
|
|
r'ico_|btn_|bul_|bg_|_bg\.|icon_|'
|
|
r'file_icon|_icon\.|icon\.gif|_btn\.|see_btn|flag\.|webaccess|'
|
|
r'/skin/|/images/(?:kr/)?common(?:_new)?/|/common/img/|/_module/gnuri/|'
|
|
r'btn_page|page_(?:next|prev|first|last)', re.I)
|
|
MAPPDF = re.compile(r'pdf|viewer\.html|/map|kakao|daum|=map|naver.*map|google.*map', re.I)
|
|
VIDEO = re.compile(r'youtube\.com|youtu\.be|/embed/|\.mp4|\.webm|vimeo', re.I)
|
|
DETAIL = re.compile(r'mode=V&no=', re.I)
|
|
|
|
|
|
def get(url):
|
|
last = None
|
|
for _ in range(3):
|
|
try:
|
|
r = S.get(url, verify=False, timeout=20)
|
|
return BeautifulSoup(r.content, 'html.parser')
|
|
except Exception as e:
|
|
last = e
|
|
time.sleep(1.2)
|
|
raise last
|
|
|
|
|
|
def body(soup):
|
|
return soup.select_one('#txt') or soup.select_one('#contents') or soup.select_one('main') or soup
|
|
|
|
|
|
def real_img(node):
|
|
for im in node.find_all('img'):
|
|
src = im.get('src') or ''
|
|
if not src or KOGL_IMG.search(src) or DECO.search(src):
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def media_flags(node):
|
|
img = real_img(node)
|
|
vid = bool(node.find('video'))
|
|
for ifr in node.find_all('iframe'):
|
|
s = ifr.get('src') or ''
|
|
if VIDEO.search(s):
|
|
vid = True
|
|
elif MAPPDF.search(s):
|
|
img = True
|
|
for a in node.find_all('a'):
|
|
if VIDEO.search(a.get('href') or ''):
|
|
vid = True
|
|
return img, vid
|
|
|
|
|
|
def kogl_from(html):
|
|
"""전체 HTML에서 KOGL 이미지유형·링크유형 추출."""
|
|
imgs = [int(x) for x in KOGL_IMG.findall(html) if 1 <= int(x) <= 4]
|
|
links = [int(x) for x in KOGL_LINK.findall(html) if 1 <= int(x) <= 4]
|
|
return imgs, links
|
|
|
|
|
|
def _menu_cd(u):
|
|
m = re.search(r'menu_dvs_cd=([^&]+)', u or '')
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def detail_links(soup, base):
|
|
"""같은 게시판(_prog/_board + 동일 menu_dvs_cd) 상세글만. 타 게시판 stray 링크 배제."""
|
|
if '/_prog/_board/' not in base:
|
|
return [] # 게시판 템플릿이 아니면 상세 없음(scate 등)
|
|
base_menu = _menu_cd(base)
|
|
out = []
|
|
for a in body(soup).find_all('a', href=True):
|
|
h = a['href']
|
|
if not DETAIL.search(h):
|
|
continue
|
|
full = urljoin(base, h)
|
|
if base_menu and _menu_cd(full) and _menu_cd(full) != base_menu:
|
|
continue # 다른 게시판 글 → 제외
|
|
out.append(full)
|
|
seen = set(); res = []
|
|
for u in out:
|
|
if u not in seen:
|
|
seen.add(u); res.append(u)
|
|
return res[:5]
|
|
|
|
|
|
def decide_O(img_types, link_types):
|
|
"""KOGL 규칙: 이미지유형 우선. 링크만이면 licenseType, 1·2·3·4 전부=범례→미부착."""
|
|
if img_types:
|
|
t = img_types[0]
|
|
mism = bool(link_types) and (link_types[0] != t)
|
|
return f'{t}유형', mism
|
|
if link_types:
|
|
if {1, 2, 3, 4}.issubset(set(link_types)):
|
|
return '미부착', False
|
|
return f'{link_types[0]}유형', False
|
|
return '미부착', False
|
|
|
|
|
|
def judge(k, L, M):
|
|
"""반환 dict: N, O, P, Q, S(mismatch)."""
|
|
try:
|
|
soup = get(k)
|
|
except Exception as e:
|
|
return {'err': f'fetch:{str(e)[:25]}'}
|
|
bd = body(soup); full = str(soup)
|
|
txt = len(bd.get_text(strip=True)) >= 1
|
|
is_board_url = '/_prog/_board/' in k # 실제 게시판 URL만(.html·서브사이트는 게시판 아님)
|
|
|
|
has_img, has_vid = media_flags(bd)
|
|
# O: 페이지/리스트 자체 마크 우선
|
|
img_t, link_t = kogl_from(full)
|
|
o_loc = '게시판' if L == '게시판' else '페이지'
|
|
o_imgs, o_links = list(img_t), list(link_t)
|
|
|
|
# '빈 게시판→없음'은 진짜 게시판(_prog/_board)에 글이 0개일 때만.
|
|
# L=게시판 M=0 이라도 .html 페이지·서브사이트는 본문내용으로 판정(원본 L/M 오기 보정).
|
|
dls = detail_links(soup, k) if (L == '게시판') else []
|
|
board0 = (L == '게시판' and is_board_url and (M in (0, '0', None)) and not dls)
|
|
|
|
if L == '게시판' and not board0:
|
|
# N: 상위 5글 본문에서 실제 이미지/영상 누적(이미지 콘텐츠 회수율↑)
|
|
for idx, du in enumerate(dls):
|
|
try:
|
|
ds = get(du); db = body(ds); df = str(ds)
|
|
except Exception:
|
|
continue
|
|
di, dv = media_flags(db)
|
|
if di: has_img = True
|
|
if dv: has_vid = True
|
|
# O: 리스트 마크 없으면 '첫(대표) 글'만으로 판정
|
|
# (사용자 컨벤션: 01030502 글1=1유형→1유형 / 업무추진비 글1무·글5만3유형→미부착)
|
|
if idx == 0 and not o_imgs and not o_links:
|
|
dimg, dlink = kogl_from(df)
|
|
if dimg or dlink:
|
|
o_imgs, o_links, o_loc = dimg, dlink, '게시물'
|
|
if has_img and has_vid:
|
|
break
|
|
|
|
# N
|
|
if board0:
|
|
N = '없음'
|
|
else:
|
|
parts = []
|
|
if txt: parts.append('어문')
|
|
if has_img: parts.append('이미지')
|
|
if has_vid: parts.append('영상')
|
|
N = ','.join(parts) if parts else '없음'
|
|
|
|
O, mism = decide_O(o_imgs, o_links)
|
|
P = (o_loc if O != '미부착' else None)
|
|
Q = ('Y' if (O != '미부착' and o_links) else None)
|
|
return {'N': N, 'O': O, 'P': P, 'Q': Q, 'mismatch': mism}
|
|
|
|
|
|
def main():
|
|
mode = '--validate' if '--validate' in sys.argv else ('--write' if '--write' in sys.argv else '--dry')
|
|
wb = openpyxl.load_workbook(XLSX); ws = wb.active
|
|
|
|
rows = []
|
|
for r in range(3, ws.max_row + 1):
|
|
k = ws.cell(r, 11).value; L = ws.cell(r, 12).value; M = ws.cell(r, 13).value
|
|
if not (isinstance(k, str) and DOMAIN in k):
|
|
continue
|
|
if L == '사이트':
|
|
continue
|
|
if mode == '--validate' and r > REF_MAX:
|
|
continue
|
|
if mode == '--write' and r <= REF_MAX:
|
|
continue
|
|
rows.append((r, k, L, M))
|
|
print(f'mode={mode} | 대상 {len(rows)}행')
|
|
|
|
res = {}
|
|
t0 = time.time()
|
|
with ThreadPoolExecutor(max_workers=3) as ex:
|
|
futs = {ex.submit(judge, k, L, M): (r, k, L, M) for r, k, L, M in rows}
|
|
done = 0
|
|
for fut in as_completed(futs):
|
|
r, k, L, M = futs[fut]
|
|
res[r] = fut.result()
|
|
done += 1
|
|
if done % 40 == 0:
|
|
print(f' {done}/{len(rows)} ({time.time()-t0:.0f}s)')
|
|
print(f'크롤링 완료 ({time.time()-t0:.0f}s)')
|
|
|
|
if mode == '--validate':
|
|
miss = 0
|
|
for r, k, L, M in rows:
|
|
d = res[r]
|
|
if d.get('err'):
|
|
print(f' 행{r} ERR {d["err"]}'); continue
|
|
curN = ws.cell(r, 14).value; curO = ws.cell(r, 15).value
|
|
curP = ws.cell(r, 16).value; curQ = ws.cell(r, 17).value
|
|
dn = (d['N'] or '') != (curN or '')
|
|
do = (d['O'] or '') != (curO or '')
|
|
dp = (d['P'] or '') != (curP or '')
|
|
dq = (d['Q'] or '') != (curQ or '')
|
|
if dn or do or dp or dq:
|
|
miss += 1
|
|
g = ws.cell(r, 7).value or ws.cell(r, 6).value
|
|
print(f' ✗행{r} {str(g)[:16]:16}| N:{curN}→{d["N"]} {"≠" if dn else "="} | '
|
|
f'O:{curO}→{d["O"]}{"≠" if do else "="} P:{curP}→{d["P"]}{"≠" if dp else ""} Q:{curQ}→{d["Q"]}{"≠" if dq else ""}')
|
|
print(f'\n불일치 {miss}/{len(rows)}행 (0이면 로직이 사용자 검수와 100% 일치)')
|
|
return
|
|
|
|
# write (38행~)
|
|
if mode == '--write':
|
|
import shutil
|
|
bk = XLSX.replace('.xlsx', '_backup_NO재판정전.xlsx'); shutil.copy(XLSX, bk)
|
|
print('백업:', bk)
|
|
chg = 0
|
|
for r, k, L, M in rows:
|
|
d = res[r]
|
|
if d.get('err'):
|
|
print(f' 행{r} ERR {d["err"]}'); continue
|
|
old = (ws.cell(r,14).value, ws.cell(r,15).value, ws.cell(r,16).value, ws.cell(r,17).value)
|
|
ws.cell(r, 14).value = d['N']
|
|
ws.cell(r, 15).value = d['O']
|
|
ws.cell(r, 16).value = d['P']
|
|
ws.cell(r, 17).value = d['Q']
|
|
if d['mismatch']:
|
|
cur = (ws.cell(r,19).value or '').strip()
|
|
ws.cell(r,19).value = '링크주소 오기' if not cur else cur+' / 링크주소 오기'
|
|
new = (d['N'], d['O'], d['P'], d['Q'])
|
|
if old != new:
|
|
chg += 1
|
|
g = ws.cell(r,7).value or ws.cell(r,6).value
|
|
print(f' 행{r} {str(g)[:16]:16}| N:{old[0]}→{new[0]} O:{old[1]}→{new[1]} P:{old[2]}→{new[2]} Q:{old[3]}→{new[3]}')
|
|
wb.save(XLSX)
|
|
print(f'\n저장: {XLSX} ({chg}행 변경)')
|
|
return
|
|
|
|
print('(--validate 또는 --write 지정)')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|