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