공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
7.9 KiB
Python
201 lines
7.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""수집완료 기관 N(저작물 유형) 일괄 재검토 (2026-06-01, 당진 규칙 일반화).
|
|
|
|
매뉴얼 3-1a/3-3/3-4b 반영:
|
|
- 이미지 = 별도 정보 주는 것만(사진·평면도·지도·QR·악보·소식지·상징물·도표).
|
|
- 어문 = 글 설명 인포그래픽(한눈에式)·로고·파트너로고·아이콘·배너·헤드라인텍스트·버튼·팝업·웹접근성마크.
|
|
- 오디오 = <audio>·mp3/wav/m4a 링크(다운로드 쿼리형 포함).
|
|
- 지도/PDF 임베드 = 이미지.
|
|
- 빈 게시판(M=0) = 없음. 사이트(외부) = N 미변경(빈칸 유지).
|
|
|
|
N만 재계산하고 L/M/O/P/Q 등 다른 컬럼은 보존. 지역 phase234 모듈의 fetch/get_body/
|
|
extract_detail_urls/정규식/SITES 재사용. 검수완료(계룡·공주·금산·논산·보령)+당진 제외.
|
|
|
|
사용:
|
|
python -X utf8 _redo_N_all.py dry [기관...] # 변경 로그만(읽기전용)
|
|
python -X utf8 _redo_N_all.py run [기관...] # 백업 후 N 기입
|
|
(기관 미지정 시 3개 지역 전체 ✅ 기관)
|
|
"""
|
|
import sys, os, io, re, shutil, importlib.util, warnings
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import openpyxl
|
|
warnings.filterwarnings('ignore')
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
MODULES = ['_chungnam_phase234_all.py', '_chungbuk_phase234_all.py', '_jeonbuk_phase234_all.py']
|
|
EXCLUDE_INST = {'계룡시', '공주시', '금산군', '논산시', '보령시', '당진시'} # 검수완료 + 당진
|
|
|
|
# ── 이미지 분류 (벤더 공통) ───────────────────────────────
|
|
DECO = re.compile(r'/common/|move\.png|no[-_]?img|blank|spacer|/ico|/btn|bullet|arrow|/bg|icon|see_btn|/sample|mimetype|/file_|filedown|btn_dir', re.I)
|
|
EXCLUDE = re.compile(
|
|
r'한눈에|흐름도|절차도|처리절차|이용절차'
|
|
r'|로고(?!송)|logo(?!song)|아이콘|배너|banner'
|
|
r'|신문고|relation_item|tracer|headline'
|
|
r'|카피라이트|copyright|copy_logo|popup|/pup/|wa_mk|웹접근성|품질인증', re.I)
|
|
AUDIO = re.compile(r'\.(?:mp3|wav|m4a|ogg|flac)\b', re.I)
|
|
MAPPDF = re.compile(r'pdf|viewer\.html|/map|kakao|daum.*map', re.I)
|
|
|
|
|
|
def load_module(fname):
|
|
spec = importlib.util.spec_from_file_location(fname[:-3], os.path.join(HERE, fname))
|
|
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
|
return m
|
|
|
|
|
|
def real_imgs(M, body):
|
|
out = []
|
|
for img in body.find_all('img'):
|
|
src = img.get('src') or ''
|
|
if not src or M.KOGL_IMG_PAT.search(src) or DECO.search(src):
|
|
continue
|
|
if EXCLUDE.search(src + ' ' + (img.get('alt') or '')):
|
|
continue
|
|
out.append(img)
|
|
return out
|
|
|
|
|
|
def media2(M, body):
|
|
has_text = len(body.get_text(strip=True)) > 30
|
|
img = len(real_imgs(M, body)) > 0
|
|
vid = False
|
|
for ifr in body.find_all('iframe'):
|
|
s = ifr.get('src') or ''
|
|
if M.YOUTUBE_PAT.search(s):
|
|
vid = True
|
|
elif MAPPDF.search(s):
|
|
img = True
|
|
if not vid and (body.find('video') or body.find('a', href=M.YOUTUBE_PAT) or M.VIDEO_EXT.search(str(body))):
|
|
vid = True
|
|
aud = bool(body.find('audio')) or bool(AUDIO.search(str(body)))
|
|
return img, vid, aud, has_text
|
|
|
|
|
|
def make_fetch(M, cfg):
|
|
"""모듈별 fetch 시그니처 차이 흡수: 충남=fetch(url), 충북/전북=fetch(session,url)."""
|
|
if hasattr(M, 'make_session'):
|
|
sess = M.make_session(weak_ssl=cfg.get('weak_ssl', False))
|
|
return lambda u: M.fetch(sess, u)
|
|
return lambda u: M.fetch(u)
|
|
|
|
|
|
def _fetch_body(M, url, body_sel, fetchfn, tries=3):
|
|
"""throttle 대비 재시도. 본문 텍스트>30 또는 미디어 잡히면 즉시 반환."""
|
|
import time
|
|
last = None
|
|
for k in range(tries):
|
|
soup = fetchfn(url)
|
|
if soup is not None:
|
|
body = M.get_body(soup, body_sel)
|
|
last = media2(M, body) + (body,)
|
|
if last[3] or last[0] or last[1] or last[2]: # txt/img/vid/aud 중 하나라도
|
|
return last
|
|
time.sleep(0.6 * (k + 1))
|
|
return last # 끝까지 비면 마지막(또는 None)
|
|
|
|
|
|
def n_of(M, url, L, body_sel, fetchfn):
|
|
res = _fetch_body(M, url, body_sel, fetchfn)
|
|
if res is None:
|
|
return None # 접근 실패 → 기존 N 유지
|
|
img, vid, aud, txt, body = res
|
|
if not (txt or img or vid or aud):
|
|
return None # 재시도해도 빈 본문 → 신뢰불가, 기존 N 유지(거짓 '없음' 차단)
|
|
if L == '게시판':
|
|
detail_urls = M.extract_detail_urls(body, url, limit=5)
|
|
data_rows = [tr for tr in body.select('table tbody tr, .board_list li, ul.bbs_list li') if tr.find('a')]
|
|
empty_msg = bool(re.search(r'게시물이?\s*없|등록된\s*(?:게시물|자료)\s*가?\s*없|자료가\s*없', body.get_text(' ', strip=True)))
|
|
if not detail_urls and not data_rows and (empty_msg or not txt):
|
|
return '없음' # 실제 글 0개 = 진짜 빈 게시판 (M값 무시, 내용기반)
|
|
for du in detail_urls:
|
|
ds = fetchfn(du)
|
|
if not ds:
|
|
continue
|
|
db = M.get_body(ds, body_sel)
|
|
i2, v2, a2, t2 = media2(M, db)
|
|
img = img or i2; vid = vid or v2; aud = aud or a2; txt = txt or t2
|
|
parts = []
|
|
if txt:
|
|
parts.append('어문')
|
|
if img:
|
|
parts.append('이미지')
|
|
if vid:
|
|
parts.append('영상')
|
|
if aud:
|
|
parts.append('오디오')
|
|
return ','.join(parts) if parts else '없음'
|
|
|
|
|
|
def do_inst(M, name, cfg, mode, log):
|
|
xlsx = cfg['xlsx']
|
|
if not os.path.exists(xlsx):
|
|
log.write(f'[{name}] 엑셀 없음\n'); return (name, 0, 0)
|
|
body_sel = cfg['body_sel']
|
|
fetchfn = make_fetch(M, cfg)
|
|
wb = openpyxl.load_workbook(xlsx); ws = wb.active
|
|
jobs = []
|
|
for r in range(3, ws.max_row + 1):
|
|
L = ws.cell(r, 12).value
|
|
K = ws.cell(r, 11).value
|
|
Mq = ws.cell(r, 13).value
|
|
if not (isinstance(K, str) and K.startswith('http')):
|
|
continue
|
|
if L == '사이트':
|
|
continue
|
|
if L not in ('페이지', '게시판'):
|
|
continue
|
|
jobs.append((r, K, L)) # M=0이어도 내용기반으로 n_of가 판정(거짓 M=0 대응)
|
|
results = {}
|
|
with ThreadPoolExecutor(max_workers=4) as ex:
|
|
futs = {}
|
|
for r, K, L in jobs:
|
|
if K == '__EMPTY__':
|
|
results[r] = '없음'
|
|
else:
|
|
futs[ex.submit(n_of, M, K, L, body_sel, fetchfn)] = r
|
|
for f in as_completed(futs):
|
|
r = futs[f]
|
|
try:
|
|
results[r] = f.result()
|
|
except Exception:
|
|
results[r] = None
|
|
changed = 0
|
|
for r, newN in sorted(results.items()):
|
|
if newN is None:
|
|
continue
|
|
oldN = ws.cell(r, 14).value
|
|
if newN != oldN:
|
|
changed += 1
|
|
log.write(f' [{name}] r{r} {oldN}→{newN}\n')
|
|
if mode == 'run':
|
|
ws.cell(r, 14).value = newN
|
|
total = len([j for j in jobs])
|
|
if mode == 'run' and changed:
|
|
bak = xlsx.replace('.xlsx', '_backup_N재검토전.xlsx')
|
|
shutil.copy(xlsx, bak)
|
|
wb.save(xlsx)
|
|
return (name, total, changed)
|
|
|
|
|
|
def main():
|
|
mode = sys.argv[1] if len(sys.argv) > 1 else 'dry'
|
|
only = set(a for a in sys.argv[2:] if not a.startswith('--'))
|
|
log = io.open(os.path.join(HERE, '_redo_N_all_log.txt'), 'w', encoding='utf-8')
|
|
grand = []
|
|
for fname in MODULES:
|
|
M = load_module(fname)
|
|
for name, cfg in M.SITES.items():
|
|
if name in EXCLUDE_INST:
|
|
continue
|
|
if only and name not in only:
|
|
continue
|
|
res = do_inst(M, name, cfg, mode, log)
|
|
grand.append(res)
|
|
print(f'{name:7} 처리 {res[1]:4}행 | N변경 {res[2]:4} ({mode})', flush=True)
|
|
log.close()
|
|
print('=' * 50)
|
|
print(f'총 {len(grand)}개 기관 | 변경합계 {sum(r[2] for r in grand)}행 ({mode}) | 로그 _redo_N_all_log.txt')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|