DB_JOB/작업파일/완료/광역_사이트맵/충청남도/6.보령시/_recollect.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

425 lines
19 KiB
Python

# -*- coding: utf-8 -*-
"""보령시 전면 재수집(복사본 베이스).
- 본문 = #contents (기존 #txt는 전역검색 포함→게시판 오판)
- 인페이지탭 = ul.depth_tab → 매뉴얼 1-5b/1-5c로 leaf+1 컬럼 전개(논산식)
· 탭이 페이지/게시판 섞여도 각각 개별 행, 게시판은 총건수, 페이지는 #grap 인페이지탭수 or 1
- 검출 로직(L/M/N/O/P/Q)·미디어·공공누리 = 공주시 _phase234.py 이식
- 순번3 사전정보공표 = 사전 정의된 16 leaf 블록 삽입(brm1/dept 필터)
출력: 충청남도_보령시.xlsx 재작성(헤더·스타일·병합·순번·하이퍼링크).
"""
import re, time, warnings, json
from copy import copy
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests, openpyxl
from bs4 import BeautifulSoup
from openpyxl.worksheet.hyperlink import Hyperlink
warnings.filterwarnings('ignore')
DIR = r'작업파일/광역_사이트맵/충청남도/6.보령시/'
SRC = DIR + '보령시 복사본.xlsx'
OUT = DIR + '충청남도_보령시.xlsx'
DOMAIN = 'brcn.go.kr'
H = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'}
ADV_URL = 'https://www.brcn.go.kr/prog/openInformation/advanceInformation/kor/sub01_01_03/list.do'
# ---------------- fetch ----------------
_cache = {}
def fetch(url, timeout=15):
if url in _cache: return _cache[url]
s = None
try:
r = requests.get(url, headers=H, timeout=timeout, verify=False)
if r.status_code == 200:
s = BeautifulSoup(r.content, 'html.parser')
except Exception:
s = None
_cache[url] = s
return s
def body_of(soup):
return soup.select_one('#contents') or soup.select_one('#container') or soup
# ---------------- board / page detection ----------------
TOTAL_PATS = [re.compile(r'\s*게시물\s*[:\s]*([\d,]+)'),
re.compile(r'\s*([\d,]+)\s*건'),
re.compile(r'전체\s*([\d,]+)\s*건'),
re.compile(r'게시물\s*수\s*[:\s]*([\d,]+)')]
def get_total(body):
bt = body.select_one('.board_total, .total, .b_total')
if bt:
m = re.search(r'([\d,]+)', bt.get_text())
if m: return int(m.group(1).replace(',', ''))
txt = body.get_text(' ', strip=True)
for p in TOTAL_PATS:
m = p.search(txt)
if m: return int(m.group(1).replace(',', ''))
return None
def anchor_tab_count(body):
best = 0
for ul in body.select('ul.tab-ul, ul.type2, ul.type3, ul.tab_type, .tab_wrap ul, .tabBox ul'):
las = ul.find_all('a')
if len(las) >= 2 and all((a.get('href') or '').strip().startswith('#') for a in las):
best = max(best, len(las))
return best
def is_board_url(u):
return bool(u) and ('selectBoardList' in u or '/list.do' in u or 'BBSMSTR' in u or 'bbsId=' in u) and '/viewer/' not in u
def detect_form(body, url):
# 직원안내·전화번호안내·부서안내 등 deptOn 디렉토리 = 게시물 아님 → 페이지(논산 컨벤션 M=1)
if 'deptOn' in url:
return '페이지', 1
total = get_total(body)
has_paging = bool(body.select('.pagination, .paging'))
is_bbs = ('BBSMSTR' in url or 'selectBoardList' in url or 'bbsId=' in url)
# 진짜 게시판: 총건수/페이징이 있거나 BBSMSTR/selectBoardList 보드
if (total is not None) or has_paging or is_bbs:
return '게시판', (total if total is not None else 0)
# 그 외(디렉토리성 list.do 포함) = 페이지
return '페이지', (anchor_tab_count(body) or 1)
# ---------------- media / kogl (공주시 이식) ----------------
KOGL_IMG = re.compile(r'(?:new_)?img_opentype(\d{2})\.png', re.I)
KOGL_LINK = re.compile(r'kogl\.or\.kr/info/licenseType(\d)', re.I)
YT = re.compile(r'(?:youtube\.com|youtu\.be)', re.I)
VIDEXT = re.compile(r'\.(mp4|webm|mov|avi)(?:\?|$)', re.I)
def detect_media(body):
has_text = len(body.get_text(strip=True)) > 30
has_image = False
for img in body.find_all('img'):
src = img.get('src', '')
if not src or KOGL_IMG.search(src): continue
if re.search(r'(btn_|icon|ico_|bullet|blank|spacer|move\.|no[-_]?img|arrow)', src, re.I): continue
has_image = True; break
has_video = bool(body.find_all('video'))
if not has_video:
for f in body.find_all('iframe'):
if YT.search(f.get('src', '')): has_video = True; break
if not has_video and VIDEXT.search(str(body)): has_video = True
return has_image, has_video, has_text
def n_string(t, i, v):
p = []
if t: p.append('어문')
if i: p.append('이미지')
if v: p.append('영상')
return ','.join(p) if p else '없음'
def img_anchor(img):
p = img.parent
while p is not None:
if p.name == 'a':
h = p.get('href', '')
return bool(h) and not h.startswith('#') and not h.lower().startswith('javascript:')
p = p.parent
return False
def detect_kogl(body):
types = set(); qy = qn = False
for a in body.find_all('a', href=True):
m = KOGL_LINK.search(a['href'])
if m: types.add(int(m.group(1))); qy = True
for img in body.find_all('img'):
m = KOGL_IMG.search(img.get('src', ''))
if m:
types.add(int(m.group(1)))
if img_anchor(img): qy = True
else: qn = True
return types, ('Y' if qy else ('N' if qn else None))
DETAIL_FN = re.compile(r"fn[_a-zA-Z]*\(\s*['\"]?(\d+)")
def detail_urls(body, list_url, limit=4):
urls, seen = [], set()
tb = body.select_one('table tbody') or body.find('table')
if not tb: return urls
for tr in tb.find_all('tr')[:limit*3]:
a = tr.find('a', href=True)
if a:
h = a['href']
if 'view' in h.lower() and ('nttId' in h or 'Seq' in h or 'idx' in h.lower()):
full = urljoin(list_url, h)
if full not in seen:
seen.add(full); urls.append(full)
if len(urls) >= limit: break
return urls
def classify(url):
"""return dict L,M,N,O,P,Q (게시판은 상세 추적해 미디어/공공누리)."""
s = fetch(url)
if s is None:
# 별도 시스템(서브도메인/결제앱 등) — 자동수집 불가, 페이지로 처리
return {'L': '페이지', 'M': 1, 'N': '어문', 'O': '미부착', 'P': '', 'Q': '', 'note': '별도 시스템(자동수집 불가)'}
body = body_of(s)
form, count = detect_form(body, url)
has_i, has_v, has_t = detect_media(body)
types, q = detect_kogl(body)
P = '게시판' if types else ''
qflags = [q] if q else []
if form == '게시판' and count and count > 0:
for du in detail_urls(body, url, limit=2):
ds = fetch(du, 10)
if not ds: continue
db = body_of(ds)
di, dv, dt = detect_media(db)
has_i |= di; has_v |= dv; has_t |= dt
dtypes, dq = detect_kogl(db)
if dtypes and not types and not P: P = '게시물'
types |= dtypes
if dq: qflags.append(dq)
N = n_string(has_t, has_i, has_v)
if form == '게시판' and count == 0:
N = '없음' # 빈 게시판
if not types:
O = '미부착'; P = ''; Q = ''
else:
O = ','.join(f'{n}유형' for n in sorted(types))
P = P or '게시판'
Q = 'Y' if 'Y' in qflags else 'N'
return {'L': form, 'M': count if form == '게시판' else (count or 1),
'N': N, 'O': O, 'P': P, 'Q': Q, 'note': ''}
# ---------------- 사전정보공표 블록 (순번3) ----------------
def adv_block():
cat = [('일반공공행정','brm1=A',117),('공공질서및안전','brm1=B',26),('통신','brm1=C',7),
('산업중소기업','brm1=D',33),('보건','brm1=E',82),('사회복지','brm1=F',69),
('문화체육관광','brm1=G',35),('수송및교통','brm1=H',38),('농림해양수산','brm1=I',39),
('교육','brm1=J',11),('환경보호','brm1=K',45),('지역개발','brm1=L',28)]
dep = [('시청','dept=1',405),('직속기관 사업소','dept=2',109),('시의회','dept=3',16),('읍면동','dept=4',0)]
recs = []
for i,(h,qs,c) in enumerate(cat):
recs.append({'F':'사전정보공표' if i==0 else None,'G':'정보분류별' if i==0 else None,'H':h,
'url':f'{ADV_URL}?{qs}&siteCode=kor&mno=sub01_01_03','L':'게시판','M':c,'N':'어문','O':'미부착','P':'','Q':''})
for i,(h,qs,c) in enumerate(dep):
recs.append({'F':None,'G':'공표부서' if i==0 else None,'H':h,
'url':f'{ADV_URL}?{qs}&siteCode=kor&mno=sub01_01_03','L':'게시판','M':c,'N':'어문','O':'미부착','P':'','Q':''})
return recs
# ---------------- 메뉴 읽기 + 전개 ----------------
def norm(href, base):
if not href: return None
href = href.strip()
if href.startswith('#') or href.lower().startswith('javascript:') or href == '': return None
return urljoin(base, href)
def get_tabs(body, url):
dt = body.select_one('ul.depth_tab')
if not dt: return []
tabs = []
for li in dt.find_all('li'):
a = li.find('a')
if not a: continue
h = norm(a.get('href'), url)
if not h: continue
tabs.append({'label': li.get_text(strip=True), 'href': h})
return tabs
def main():
wb = openpyxl.load_workbook(SRC)
ws = wb.active
# 메뉴 행 수집 (복사본)
menus = []
for r in range(3, ws.max_row + 1):
D, E, F = ws.cell(r,4).value, ws.cell(r,5).value, ws.cell(r,6).value
url = ws.cell(r,11).value
menus.append({'r':r,'D':D,'E':E,'F':F,'url':url})
all_urls = set(m['url'] for m in menus if m['url'])
print('메뉴 행:', len(menus))
# --- 병렬 사전 fetch: 모든 메뉴 URL (탭 탐지용) ---
t0 = time.time()
menu_urls = [m['url'] for m in menus if m['url'] and isinstance(m['url'],str) and DOMAIN in m['url'] and m['F']!='사전정보공표']
print('메뉴 URL 병렬 fetch:', len(menu_urls))
with ThreadPoolExecutor(max_workers=16) as ex:
list(ex.map(lambda u: fetch(u), set(menu_urls)))
print(f' 메뉴 fetch 완료 ({time.time()-t0:.0f}s)')
# --- 스켈레톤 빌드(캐시 사용) + classify 대상 URL 수집 ---
skel = [] # (rec_partial, classify_url_or_None)
def leafcol(m):
if m['F'] is not None: return 'F'
if m['E'] is not None: return 'E'
return 'D'
for m in menus:
url = m['url']
# 빈 junk 행(URL·D·E·F 전부 없음) 스킵
if (not url or not isinstance(url,str)) and not any([m['D'],m['E'],m['F']]):
continue
base = {'D':m['D'],'E':m['E'],'F':m['F'],'G':None,'H':None}
if m['F'] == '사전정보공표':
for j,b in enumerate(adv_block()):
rec = {'D':None,'E':None,'F':None,'G':b['G'],'H':b['H'],'url':b['url'],
'L':b['L'],'M':b['M'],'N':b['N'],'O':b['O'],'P':b['P'],'Q':b['Q'],'newseq':(j==0)}
if j==0: rec.update({'D':m['D'],'E':m['E'],'F':'사전정보공표'})
skel.append((rec, None))
continue
if not url or not isinstance(url,str) or DOMAIN not in url:
rec = dict(base); rec.update({'url':url,'L':'사이트','M':'','N':'','O':'','P':'','Q':'','newseq':True})
skel.append((rec, None)); continue
s = _cache.get(url)
tabs = get_tabs(body_of(s), url) if s else []
realtabs = [t for t in tabs if DOMAIN in t['href']]
expand = len(realtabs) >= 2 and not all(t['href'] in all_urls for t in realtabs)
lc = leafcol(m); sub = {'F':'G','E':'F','D':'E'}[lc]
if expand:
for j,t in enumerate(realtabs):
if j==0:
rec = dict(base); rec['url']=t['href']; rec[sub]=t['label']; rec['newseq']=True
else:
rec = {'D':None,'E':None,'F':None,'G':None,'H':None,'url':t['href'],'newseq':False}
rec[sub]=t['label']
skel.append((rec, t['href']))
else:
rec = dict(base); rec['url']=url; rec['newseq']=True
skel.append((rec, url))
# --- 병렬 classify ---
todo = sorted(set(u for _,u in skel if u))
print('classify 대상 URL:', len(todo))
cls = {}
done=[0]
def cl(u):
r = classify(u); done[0]+=1
if done[0]%60==0: print(f' classify {done[0]}/{len(todo)} ({time.time()-t0:.0f}s)')
return u, r
with ThreadPoolExecutor(max_workers=16) as ex:
for u,r in ex.map(cl, todo):
cls[u] = r
print(f' classify 완료 ({time.time()-t0:.0f}s)')
records = []
for rec, u in skel:
if u and u in cls:
rec.update(cls[u])
records.append(rec)
print(f'총 레코드: {len(records)} ({time.time()-t0:.0f}s)')
json.dump([{k:v for k,v in r.items() if k!="newseq"} for r in records],
open(DIR+'_records.json','w',encoding='utf-8'), ensure_ascii=False)
write_excel(wb, ws, records)
return
# (이하 구버전 순차 경로 — 미사용)
for idx, m in enumerate(menus):
url = m['url']
base = {'D':m['D'],'E':m['E'],'F':m['F'],'G':None,'H':None}
if m['F'] == '사전정보공표':
for j,b in enumerate(adv_block()):
rec = dict(base)
if j==0:
rec['url']=b['url']
else:
rec={'D':None,'E':None,'F':None,'G':b['G'],'H':b['H'],'url':b['url']}
rec.update({'G':b['G'] if j==0 else rec.get('G'),'H':b['H'],
'L':b['L'],'M':b['M'],'N':b['N'],'O':b['O'],'P':b['P'],'Q':b['Q'],'newseq':(j==0)})
# 첫 행은 F 유지
if j==0: rec['F']='사전정보공표'
records.append(rec)
continue
# 외부
if not url or not isinstance(url,str) or DOMAIN not in url:
rec = dict(base); rec.update({'url':url,'L':'사이트','M':'','N':'','O':'','P':'','Q':'','newseq':True})
records.append(rec); continue
# fetch + depth_tab
s = fetch(url)
tabs = get_tabs(body_of(s), url) if s else []
realtabs = [t for t in tabs if DOMAIN in t['href']]
# 전개 조건: 2+ 탭, 모두 사이트맵에 이미 있으면 스킵
expand = len(realtabs) >= 2 and not all(t['href'] in all_urls for t in realtabs)
lc = leafcol(m)
sub = {'F':'G','E':'F','D':'E'}[lc] # leaf+1 컬럼
if expand:
for j,t in enumerate(realtabs):
if j==0:
rec = dict(base); rec['url']=t['href']; rec[sub]=t['label']; rec['newseq']=True
else:
rec = {'D':None,'E':None,'F':None,'G':None,'H':None,'url':t['href'],'newseq':False}
rec[sub]=t['label']
c = classify(t['href'])
rec.update(c); records.append(rec)
else:
rec = dict(base); rec['url']=url; rec['newseq']=True
rec.update(classify(url)); records.append(rec)
if (idx+1) % 40 == 0:
print(f' 메뉴 {idx+1}/{len(menus)} 레코드 {len(records)} ({time.time()-t0:.0f}s)')
print(f'총 레코드: {len(records)} ({time.time()-t0:.0f}s)')
json.dump([{k:v for k,v in r.items() if k!="newseq"} for r in records],
open(DIR+'_records.json','w',encoding='utf-8'), ensure_ascii=False)
write_excel(wb, ws, records)
def write_excel(wb, ws, records):
# 템플릿 스타일 (복사본 row3)
TPL = 3
tpl_style = {c: copy(ws.cell(TPL,c)._style) for c in range(1,28)}
tpl_h = ws.row_dimensions[TPL].height
# 기존 데이터행/병합 제거
for mr in list(ws.merged_cells.ranges):
if mr.min_row >= 3:
ws.unmerge_cells(str(mr))
if ws.max_row >= 3:
ws.delete_rows(3, ws.max_row-2)
# 기입
seq = 0
for i, rec in enumerate(records):
r = 3 + i
for c in range(1,28):
ws.cell(r,c)._style = copy(tpl_style[c])
if tpl_h: ws.row_dimensions[r].height = tpl_h
if rec.get('newseq'):
seq += 1
ws.cell(r,2).value = seq
ws.cell(r,3).value = '보령시'
for col,key in [(4,'D'),(5,'E'),(6,'F'),(7,'G'),(8,'H')]:
v = rec.get(key)
if v is not None: ws.cell(r,col).value = v
url = rec.get('url')
if url:
kc = ws.cell(r,11); kc.value = url
kc.hyperlink = Hyperlink(ref=kc.coordinate, target=str(url))
for col,key in [(12,'L'),(13,'M'),(14,'N'),(15,'O'),(16,'P'),(17,'Q')]:
v = rec.get(key)
if v not in (None,''): ws.cell(r,col).value = v
note = rec.get('note')
if note: ws.cell(r,19).value = note
last = 3 + len(records) - 1
# 병합 F→E→D
def merge_col(col):
run_start = 3; prev = ws.cell(3,col).value
# 그룹키: D는 자체, E는 D+E, F는 D+E+F (상위 동일구간 안에서만)
for r in range(4, last+2):
cur = ws.cell(r,col).value if r<=last else '\x00END'
# 상위 컬럼 경계에서도 끊기
boundary = False
if col>=5 and r<=last and ws.cell(r,4).value is not None: boundary=True # 새 D 시작
if col>=6 and r<=last and ws.cell(r,5).value is not None: boundary=True # 새 E 시작
if cur is not None or r>last or boundary:
if r-1 > run_start and prev is not None:
ws.merge_cells(start_row=run_start,start_column=col,end_row=r-1,end_column=col)
run_start = r; prev = cur
# F 먼저, E, D 순
for col in (6,5,4):
# 연속 동일값 구간 병합(빈칸은 위와 동일그룹으로 흡수)
r = 3
while r <= last:
v = ws.cell(r,col).value
if v is None: r+=1; continue
r2 = r+1
while r2 <= last and ws.cell(r2,col).value is None:
# 상위 컬럼이 새로 시작하면 끊기
if col>=5 and ws.cell(r2,4).value is not None: break
if col>=6 and ws.cell(r2,5).value is not None: break
r2 += 1
if r2-1 > r:
ws.merge_cells(start_row=r,start_column=col,end_row=r2-1,end_column=col)
r = r2
wb.save(OUT)
print(f'저장: {OUT} 데이터행 {len(records)} (3~{last})')
# 통계
from collections import Counter
Lc = Counter(r.get('L','') for r in records)
print('L 분포:', dict(Lc))
att = sum(1 for r in records if r.get('O') and '유형' in str(r.get('O')))
print('공공누리 부착행:', att, '| 새 순번:', sum(1 for r in records if r.get('newseq')))
if __name__ == '__main__':
main()