DB_JOB/_스크립트/_redo_N_all.py
hehihoho3 df16c98366 백업: DB수집 전체 스냅샷 (공공기관2 정리 전)
공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:15:40 +09:00

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()