공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
8.2 KiB
Python
221 lines
8.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""탭 확장으로 새로 추가된 행만 골라 Phase 2~4(L/M/N/O/P) 수집.
|
|
|
|
- 대상: L(게시판형태)이 비어있고 K가 같은 도메인 http URL 인 행 = 신규 탭 행.
|
|
- L/M/N: 해당 기관 _phase234.py 의 get_body/detect_form/detect_media/extract_detail_urls 재사용.
|
|
- O/P : 확정 KOGL 규칙(_recheck_kogl_all 의 BROAD_IMG_PAT + detect_split + decide_O).
|
|
이미지명 우선, 게시판은 같은도메인 상세 5건 추적, 이미지≠링크면 S열 '링크주소 오기'.
|
|
- 인코딩: 바이트로 받아 BeautifulSoup 자동판별(읍면동 등 오판 방지).
|
|
- 기존 행(L 이미 채워짐)은 절대 건드리지 않음.
|
|
|
|
사용: python -X utf8 _tab_phase234.py <엑셀경로> <Phase234 모듈경로> <도메인키워드> [body_sel(콤마)]
|
|
- 개별형(공주시 _phase234.py: get_body(soup)) → body_sel 생략
|
|
- 일괄형(_chungnam_phase234_all.py: get_body(soup, sel)) → body_sel 지정(미지정 시 #txt,#contents,main)
|
|
예) python -X utf8 _tab_phase234.py 충청남도/2.공주시/공주시_탭확장.xlsx 충청남도/2.공주시/_phase234.py gongju.go.kr
|
|
python -X utf8 _tab_phase234.py 충청남도/4.논산시/충청남도_논산시.xlsx _chungnam_phase234_all.py nonsan.go.kr "#txt,#contents,main"
|
|
"""
|
|
import re
|
|
import sys
|
|
import time
|
|
import inspect
|
|
import importlib.util
|
|
import warnings
|
|
from urllib.parse import urlparse
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
import ssl
|
|
import openpyxl
|
|
import requests
|
|
from requests.adapters import HTTPAdapter
|
|
from urllib3.util.ssl_ import create_urllib3_context
|
|
from bs4 import BeautifulSoup
|
|
|
|
warnings.filterwarnings('ignore')
|
|
|
|
UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
H = {'User-Agent': UA}
|
|
|
|
|
|
class WeakSSLAdapter(HTTPAdapter):
|
|
def init_poolmanager(self, *args, **kwargs):
|
|
ctx = create_urllib3_context()
|
|
ctx.set_ciphers('DEFAULT@SECLEVEL=0')
|
|
ctx.options |= 0x4
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
kwargs['ssl_context'] = ctx
|
|
return super().init_poolmanager(*args, **kwargs)
|
|
|
|
|
|
SESSION = requests.Session()
|
|
SESSION.headers.update(H)
|
|
|
|
BROAD_IMG_PAT = re.compile(r'(?:new_)?img_open(?:type|code)(\d{1,2})\.(?:png|jpe?g|gif)', re.I)
|
|
|
|
|
|
def load_module(path):
|
|
spec = importlib.util.spec_from_file_location('city_p234', path)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def fetch_soup(url, timeout=14):
|
|
try:
|
|
r = SESSION.get(url, timeout=timeout, verify=False)
|
|
if r.status_code == 200:
|
|
return BeautifulSoup(r.content, 'html.parser') # 바이트 → 자동 인코딩
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def valid(n):
|
|
return 1 <= n <= 4
|
|
|
|
|
|
def detect_split(body, LINK_PAT):
|
|
img_t, link_t = set(), set()
|
|
for a in body.find_all('a', href=True):
|
|
m = LINK_PAT.search(a['href'])
|
|
if m and valid(int(m.group(1))):
|
|
link_t.add(int(m.group(1)))
|
|
blob = ' '.join(filter(None, (img.get('src', '') for img in body.find_all('img'))))
|
|
blob += ' ' + ' '.join(el.get('style', '') for el in body.find_all(style=True))
|
|
blob += ' ' + str(body)
|
|
for m in BROAD_IMG_PAT.finditer(blob):
|
|
n = int(m.group(1))
|
|
if valid(n):
|
|
img_t.add(n)
|
|
return img_t, link_t
|
|
|
|
|
|
def decide_O(img_t, link_t):
|
|
if img_t:
|
|
return ','.join(f'{n}유형' for n in sorted(img_t)), (bool(link_t) and link_t != img_t)
|
|
if link_t:
|
|
if {1, 2, 3, 4}.issubset(link_t):
|
|
return '미부착', False
|
|
return ','.join(f'{n}유형' for n in sorted(link_t)), False
|
|
return '미부착', False
|
|
|
|
|
|
def domain3(host):
|
|
labels = (host or '').split('.')
|
|
return '.'.join(labels[-3:]) if len(labels) >= 3 else host
|
|
|
|
|
|
def same_site(a, b):
|
|
return domain3(urlparse(a).hostname) == domain3(urlparse(b).hostname)
|
|
|
|
|
|
def main():
|
|
xlsx, p234_path, domain = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
if '--weak-ssl' in sys.argv:
|
|
SESSION.mount('https://', WeakSSLAdapter())
|
|
body_sel = None
|
|
if len(sys.argv) > 4 and sys.argv[4].strip() and not sys.argv[4].startswith('--'):
|
|
body_sel = [s.strip() for s in sys.argv[4].split(',') if s.strip()]
|
|
mod = load_module(p234_path)
|
|
LINK_PAT = mod.KOGL_LINK_PAT
|
|
|
|
# get_body 시그니처 자동 대응: 일괄형은 (soup, selectors), 개별형은 (soup)
|
|
needs_sel = len(inspect.signature(mod.get_body).parameters) >= 2
|
|
sel = body_sel or ['#txt', '#contents', 'main']
|
|
get_body = (lambda s: mod.get_body(s, sel)) if needs_sel else mod.get_body
|
|
print(f'get_body 인자 {2 if needs_sel else 1}개 | body_sel={sel if needs_sel else "(미사용)"}')
|
|
|
|
wb = openpyxl.load_workbook(xlsx)
|
|
ws = wb.active
|
|
|
|
targets = []
|
|
for r in range(3, ws.max_row + 1):
|
|
L = ws.cell(r, 12).value
|
|
url = ws.cell(r, 11).value
|
|
if L not in (None, '') :
|
|
continue # 기존 행 보존
|
|
if not (isinstance(url, str) and domain in url):
|
|
continue
|
|
targets.append((r, url))
|
|
print(f'대상 신규행: {len(targets)}개')
|
|
|
|
def work(t):
|
|
r, url = t
|
|
soup = fetch_soup(url)
|
|
if soup is None:
|
|
return r, {'note': '접근 실패'}
|
|
body = get_body(soup)
|
|
form, count = mod.detect_form(body)
|
|
has_img, has_vid, has_txt = mod.detect_media(body)
|
|
img_t, link_t = detect_split(body, LINK_PAT)
|
|
P_loc = '게시판' if img_t or link_t else ''
|
|
if form == '게시판':
|
|
for du in mod.extract_detail_urls(body, url, limit=5):
|
|
if not same_site(url, du):
|
|
continue
|
|
ds = fetch_soup(du, 10)
|
|
if ds is None:
|
|
continue
|
|
db = get_body(ds)
|
|
di, dv, dt = mod.detect_media(db)
|
|
has_img = has_img or di; has_vid = has_vid or dv; has_txt = has_txt or dt
|
|
dimg, dlink = detect_split(db, LINK_PAT)
|
|
if (dimg or dlink) and not P_loc:
|
|
P_loc = '게시물'
|
|
img_t |= dimg; link_t |= dlink
|
|
N = mod.n_string(has_txt, has_img, has_vid)
|
|
O, mismatch = decide_O(img_t, link_t)
|
|
P = '' if O == '미부착' else (P_loc or '게시판')
|
|
return r, {'L': form, 'M': count if form == '게시판' else 1,
|
|
'N': N, 'O': O, 'P': P, 'mismatch': mismatch}
|
|
|
|
t0 = time.time()
|
|
results = {}
|
|
done = 0
|
|
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
futs = [ex.submit(work, t) for t in targets]
|
|
for fut in as_completed(futs):
|
|
r, res = fut.result()
|
|
results[r] = res
|
|
done += 1
|
|
if done % 40 == 0:
|
|
print(f' 진행 {done}/{len(targets)} ({time.time()-t0:.0f}s)')
|
|
print(f'크롤링 완료 ({time.time()-t0:.0f}s)')
|
|
|
|
fail = 0
|
|
for r, res in results.items():
|
|
if res.get('note'):
|
|
fail += 1
|
|
if not ws.cell(r, 19).value:
|
|
ws.cell(r, 19).value = res['note']
|
|
continue
|
|
ws.cell(r, 12).value = res['L']
|
|
ws.cell(r, 13).value = res['M']
|
|
ws.cell(r, 14).value = res['N']
|
|
ws.cell(r, 15).value = res['O']
|
|
if res['P']:
|
|
ws.cell(r, 16).value = res['P']
|
|
if res['mismatch']:
|
|
cur = (ws.cell(r, 19).value or '').strip()
|
|
ws.cell(r, 19).value = '링크주소 오기' if not cur else cur + ' / 링크주소 오기'
|
|
|
|
try:
|
|
wb.save(xlsx)
|
|
saved = xlsx
|
|
except PermissionError:
|
|
saved = xlsx.replace('.xlsx', '_LP.xlsx')
|
|
wb.save(saved)
|
|
print(f'!! 원본 잠김(Excel 열림). 대체 저장: {saved}')
|
|
|
|
from collections import Counter
|
|
Lc = Counter(res.get('L') for res in results.values())
|
|
Oc = Counter(res.get('O') for res in results.values())
|
|
print(f'저장: {xlsx}')
|
|
print('L 분포:', dict(Lc))
|
|
print('O 분포:', dict(Oc))
|
|
print(f'접근실패: {fail}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|