공공기관2 작업 중. _temp 몽타주(재생성가능)는 제외. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
6.2 KiB
Python
172 lines
6.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""공주시 전용: type1 탭 메뉴를 재귀 크롤링해 각 시트 행의 '진짜 수량'(잎 페이지 수)을
|
|
끝까지 세서 재계산. 잎 weight = 인페이지 #nav 개수(>=2) 또는 1.
|
|
하위 subtree에 게시판이 하나라도 있으면 그 행은 '합치지 않음'으로 플래그(M 미변경).
|
|
|
|
판별:
|
|
· type1 메뉴 = ul.tab-ul(단 type3/#nav 제외) 안 같은도메인 .do 링크 집합 중 self 포함하는 것
|
|
· #nav 개수 = ul.tab-ul 안 href^='#' 탭 수
|
|
· 게시판 = 목록table/페이징/총N건/상세링크(view.do·mode=V·nttId·BBSMSTR)
|
|
|
|
사용: python -X utf8 _gongju_recurse.py (DRY 리포트)
|
|
python -X utf8 _gongju_recurse.py --write (M 갱신)
|
|
"""
|
|
import re, sys, shutil, warnings
|
|
from urllib.parse import urljoin, urlsplit
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
import openpyxl, requests
|
|
from bs4 import BeautifulSoup
|
|
warnings.filterwarnings('ignore')
|
|
|
|
XLSX = r'D:\01.프로젝트\DB수집\작업파일\광역_사이트맵\충청남도\2.공주시\충청남도_공주시.xlsx'
|
|
DOMAIN = 'gongju.go.kr'
|
|
H = {'User-Agent': 'Mozilla/5.0 Chrome/120 Safari/537.36'}
|
|
DETAIL = re.compile(r'(view\.do|mode=V|nttId|BBSMSTR|selectBoard|selectBbs)', re.I)
|
|
TOTAL = re.compile(r'총\s*[\d,]+\s*건')
|
|
|
|
S = requests.Session(); S.headers.update(H)
|
|
CACHE = {} # url -> (menu_set, menu_list, nav, is_board)
|
|
|
|
|
|
def norm(u):
|
|
s = urlsplit(u); return urljoin('http://x/', s.path).split('//', 1)[-1].rstrip('/').lower()
|
|
|
|
|
|
def analyze(url):
|
|
try:
|
|
html = S.get(url, timeout=15, verify=False).content
|
|
except Exception:
|
|
return (frozenset(), [], 0, False)
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
self_n = norm(url)
|
|
nav = 0
|
|
type1_groups = []
|
|
for ul in soup.find_all('ul'):
|
|
cls = ' '.join(ul.get('class') or []).lower()
|
|
if 'tab-ul' not in cls:
|
|
continue
|
|
do_links = []
|
|
navc = 0
|
|
for a in ul.find_all('a'):
|
|
h = (a.get('href') or '').strip()
|
|
t = a.get_text(strip=True)
|
|
if not t:
|
|
continue
|
|
if h.startswith('#'):
|
|
navc += 1
|
|
elif h and not h.startswith('javascript:'):
|
|
au = urljoin(url, h)
|
|
if DOMAIN in au and au.lower().endswith('.do'):
|
|
do_links.append((t, au))
|
|
if navc >= 2:
|
|
nav = max(nav, navc)
|
|
if len(do_links) >= 2:
|
|
type1_groups.append(do_links)
|
|
# self 포함하는 type1 그룹 우선, 없으면 최대
|
|
menu = []
|
|
for g in type1_groups:
|
|
if any(norm(u) == self_n for _, u in g):
|
|
menu = g; break
|
|
if not menu and type1_groups:
|
|
menu = max(type1_groups, key=len)
|
|
menu_set = frozenset(norm(u) for _, u in menu)
|
|
# 게시판 판정
|
|
body = soup.select_one('#txt') or soup
|
|
txt = body.get_text(' ', strip=True)
|
|
is_board = bool(TOTAL.search(txt)) or bool(body.select('.paging,.pagination,.board_paging')) \
|
|
or any(DETAIL.search(a['href']) for a in body.find_all('a', href=True))
|
|
return (menu_set, menu, nav, is_board)
|
|
|
|
|
|
def get(url):
|
|
n = norm(url)
|
|
if n not in CACHE:
|
|
CACHE[n] = analyze(url)
|
|
return CACHE[n]
|
|
|
|
|
|
def crawl(seed_urls):
|
|
seen = set()
|
|
queue = list(seed_urls)
|
|
while queue:
|
|
batch = [u for u in queue if norm(u) not in seen]
|
|
for u in batch:
|
|
seen.add(norm(u))
|
|
queue = []
|
|
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
futs = {ex.submit(get, u): u for u in batch}
|
|
for f in as_completed(futs):
|
|
_ms, menu, _nav, _b = f.result()
|
|
for _, cu in menu:
|
|
if norm(cu) not in seen:
|
|
queue.append(cu)
|
|
|
|
|
|
def weight(url):
|
|
_ms, _menu, nav, _b = get(url)
|
|
return nav if nav >= 2 else 1
|
|
|
|
|
|
def expand(url, parent_set, path):
|
|
n = norm(url)
|
|
if n in path:
|
|
return 1, False
|
|
ms, menu, nav, is_board = get(url)
|
|
if not menu or ms == parent_set:
|
|
return (nav if nav >= 2 else 1), is_board
|
|
tot = 0; board = is_board
|
|
for _, cu in menu:
|
|
w, b = expand(cu, ms, path | {n})
|
|
tot += w; board = board or b
|
|
return tot, board
|
|
|
|
|
|
def main():
|
|
write = '--write' in sys.argv
|
|
wb = openpyxl.load_workbook(XLSX); ws = wb.active
|
|
rows = []
|
|
seeds = []
|
|
for r in range(3, ws.max_row + 1):
|
|
u = ws.cell(r, 11).value
|
|
if isinstance(u, str) and DOMAIN in u and u.lower().endswith('.do'):
|
|
rows.append((r, u, ws.cell(r, 12).value, ws.cell(r, 13).value,
|
|
ws.cell(r, 6).value or ws.cell(r, 5).value or ws.cell(r, 4).value or ''))
|
|
seeds.append(u)
|
|
print(f'시트 .do 페이지행: {len(rows)} — 재귀 크롤 시작...')
|
|
crawl(seeds)
|
|
print(f'크롤한 고유 페이지: {len(CACHE)}\n')
|
|
|
|
changes = []; boards = []
|
|
for r, u, L, M, cat in rows:
|
|
if L != '페이지':
|
|
continue
|
|
newM, hasboard = expand(u, frozenset({norm(u)}), set())
|
|
if hasboard:
|
|
boards.append((r, cat, u, M, newM))
|
|
continue
|
|
if str(newM) != str(M):
|
|
changes.append((r, cat, M, newM, u))
|
|
|
|
changes.sort(key=lambda x: -(x[3] - (x[2] or 0)))
|
|
print(f'=== 수량 변경(증가/감소) 대상: {len(changes)}행 ===')
|
|
for r, cat, oldM, newM, u in changes:
|
|
print(f' 행{r} {cat[:24]:<24} M {oldM} → {newM} {u}')
|
|
if boards:
|
|
print(f'\n=== ⚠ 하위에 게시판 있어 합치지 않음(M 보류): {len(boards)}행 ===')
|
|
for r, cat, u, M, nm in boards:
|
|
print(f' 행{r} {cat[:24]:<24} (현 M={M}, 재귀={nm}) {u}')
|
|
|
|
if write and changes:
|
|
bak = XLSX.replace('.xlsx', '_backup_recurse전.xlsx')
|
|
shutil.copy(XLSX, bak)
|
|
for r, cat, oldM, newM, u in changes:
|
|
ws.cell(r, 13).value = newM
|
|
wb.save(XLSX)
|
|
print(f'\n저장 완료({len(changes)}행 M갱신). 백업: {bak}')
|
|
elif not write:
|
|
print('\n(DRY — 적용하려면 --write)')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|