r"""커뮤니티 트렌드 스크래퍼 — DCInside 갤러리 인기글 조회 + 본문 스크래핑.
사용법:
# 인기글 랭킹 (기본 6시간, 상위 10개, 복합 스코어)
python tools/community_scraper.py trend coffee
# 조회수 기준 상위 5개
python tools/community_scraper.py trend coffee --sort views --top 5
# 추천 기준 3시간 이내
python tools/community_scraper.py trend coffee --sort recommends --hours 3
# 개별 글 본문 조회
python tools/community_scraper.py view coffee 544618
# 일반 갤러리 (마이너 아닌 경우)
python tools/community_scraper.py trend programming --type gallery
"""
import argparse
import asyncio
import json
import logging
import re
import sys
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
try:
from bs4 import BeautifulSoup
except ImportError:
print("ERROR: beautifulsoup4 필요. pip install beautifulsoup4 lxml", file=sys.stderr)
sys.exit(1)
logger = logging.getLogger("variet.tools.community")
# ──────────────────────────────────────────────
# 데이터 모델
# ──────────────────────────────────────────────
KST = timezone(timedelta(hours=9))
SORT_OPTIONS = ("score", "views", "recommends", "comments")
@dataclass
class CommunityPost:
"""스크래핑된 게시글."""
no: int
title: str
author: str
date: str # ISO 8601
views: int
recommends: int
comments: int
url: str
gallery_id: str
category: str = ""
score: float = 0.0
def compute_score(self) -> float:
"""가중 복합 스코어 계산."""
self.score = self.views + (self.recommends * 10) + (self.comments * 5)
return self.score
# ──────────────────────────────────────────────
# DCInside 스크래퍼
# ──────────────────────────────────────────────
# 요청 헤더 (차단 방지)
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
"Referer": "https://gall.dcinside.com/",
}
# 갤러리 타입별 URL 패턴
_BASE_URLS = {
"mgallery": "https://gall.dcinside.com/mgallery/board",
"mini": "https://gall.dcinside.com/mini/board",
"gallery": "https://gall.dcinside.com/board",
}
class DCInsideScraper:
"""DCInside 갤러리 스크래퍼."""
def __init__(self, request_delay: float = 0.5):
self.delay = request_delay
def _list_url(self, gallery_id: str, gtype: str, page: int) -> str:
base = _BASE_URLS.get(gtype, _BASE_URLS["mgallery"])
return f"{base}/lists/?id={gallery_id}&page={page}"
def _view_url(self, gallery_id: str, gtype: str, post_no: int) -> str:
base = _BASE_URLS.get(gtype, _BASE_URLS["mgallery"])
return f"{base}/view/?id={gallery_id}&no={post_no}"
async def fetch_posts(
self,
gallery_id: str,
gallery_type: str = "mgallery",
pages: int = 3,
) -> list[CommunityPost]:
"""갤러리 목록 스크래핑 → 게시글 리스트 반환."""
posts: list[CommunityPost] = []
seen_nos: set[int] = set()
async with httpx.AsyncClient(
headers=_HEADERS, follow_redirects=True, timeout=15.0
) as client:
for page in range(1, pages + 1):
url = self._list_url(gallery_id, gallery_type, page)
try:
resp = await client.get(url)
resp.raise_for_status()
except httpx.HTTPError as e:
logger.warning(f"페이지 {page} 요청 실패: {e}")
continue
page_posts = self._parse_list_page(
resp.text, gallery_id, gallery_type
)
for p in page_posts:
if p.no not in seen_nos:
seen_nos.add(p.no)
posts.append(p)
if page < pages:
await asyncio.sleep(self.delay)
return posts
def _parse_list_page(
self, html: str, gallery_id: str, gallery_type: str
) -> list[CommunityPost]:
"""HTML 파싱 → CommunityPost 리스트."""
soup = BeautifulSoup(html, "lxml")
rows = soup.select("tr.ub-content")
posts: list[CommunityPost] = []
now = datetime.now(KST)
for row in rows:
try:
# 공지 스킵
num_cell = row.select_one("td.gall_num")
if not num_cell:
continue
num_text = num_cell.get_text(strip=True)
if num_text in ("공지", "설문", "AD"):
continue
# 글 번호
post_no = int(row.get("data-no", 0) or num_text)
if post_no == 0:
continue
# 제목
title_link = row.select_one("td.gall_tit a:first-of-type")
title = title_link.get_text(strip=True) if title_link else ""
# 댓글 수
reply_span = row.select_one("td.gall_tit span.reply_num")
comments = 0
if reply_span:
m = re.search(r"\d+", reply_span.get_text())
comments = int(m.group()) if m else 0
# 카테고리
subject_cell = row.select_one("td.gall_subject")
category = subject_cell.get_text(strip=True) if subject_cell else ""
# 작성자
writer_cell = row.select_one("td.gall_writer")
author = ""
if writer_cell:
nick = writer_cell.select_one(".nickname")
author = nick.get_text(strip=True) if nick else ""
# 날짜
date_cell = row.select_one("td.gall_date")
date_str = ""
if date_cell:
# title 속성에 전체 날짜가 있을 수 있음
full_date = date_cell.get("title", "")
if full_date:
date_str = full_date
else:
raw = date_cell.get_text(strip=True)
# HH:mm 형식이면 오늘 날짜 붙이기
if re.match(r"^\d{2}:\d{2}$", raw):
date_str = f"{now.strftime('%Y-%m-%d')} {raw}:00"
elif re.match(r"^\d{2}\.\d{2}$", raw):
date_str = f"{now.year}-{raw.replace('.', '-')} 00:00:00"
else:
date_str = raw
# 조회수
count_cell = row.select_one("td.gall_count")
views = 0
if count_cell:
v_text = count_cell.get_text(strip=True).replace(",", "")
views = int(v_text) if v_text.isdigit() else 0
# 추천수
rec_cell = row.select_one("td.gall_recommend")
recommends = 0
if rec_cell:
r_text = rec_cell.get_text(strip=True).replace(",", "")
recommends = int(r_text) if r_text.isdigit() else 0
# URL
url = self._view_url(gallery_id, gallery_type, post_no)
post = CommunityPost(
no=post_no,
title=title,
author=author,
date=date_str,
views=views,
recommends=recommends,
comments=comments,
url=url,
gallery_id=gallery_id,
category=category,
)
post.compute_score()
posts.append(post)
except Exception as e:
logger.debug(f"행 파싱 오류: {e}")
continue
return posts
async def fetch_post_content(
self,
gallery_id: str,
post_no: int,
gallery_type: str = "mgallery",
) -> dict:
"""개별 게시글 본문 조회.
Returns:
{title, author, date, views, recommends, comments, content, url}
"""
url = self._view_url(gallery_id, gallery_type, post_no)
async with httpx.AsyncClient(
headers=_HEADERS, follow_redirects=True, timeout=15.0
) as client:
resp = await client.get(url)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "lxml")
# 제목
title_el = soup.select_one("span.title_subject")
title = title_el.get_text(strip=True) if title_el else ""
# 작성자
nick_el = soup.select_one("div.gall_writer .nickname")
author = nick_el.get_text(strip=True) if nick_el else ""
# 날짜
date_el = soup.select_one("span.gall_date")
date_str = date_el.get_text(strip=True) if date_el else ""
# 본문
body_el = soup.select_one("div.write_div")
content = ""
if body_el:
# 이미지 alt 텍스트 보존, 나머지 HTML 태그 제거
for img in body_el.find_all("img"):
alt = img.get("alt", "")
if alt:
img.replace_with(f"[이미지: {alt}]")
else:
img.replace_with("[이미지]")
# 줄바꿈 보존
for br in body_el.find_all("br"):
br.replace_with("\n")
for p in body_el.find_all("p"):
p.insert_after("\n")
content = body_el.get_text().strip()
# 연속 줄바꿈 정리
content = re.sub(r"\n{3,}", "\n\n", content)
# 조회수: 조회 685
views = 0
views_el = soup.select_one("span.gall_count")
if views_el:
m = re.search(r"\d[\d,]*", views_el.get_text())
views = int(m.group().replace(",", "")) if m else 0
# 추천수: 추천 24 또는 기타 위치
rec = 0
rec_el = soup.select_one("span.up_num, p.up_num span, .btn_recommend_box .up_num")
if rec_el:
m = re.search(r"\d[\d,]*", rec_el.get_text())
rec = int(m.group().replace(",", "")) if m else 0
# 댓글수: 댓글 35
cmt = 0
cmt_el = soup.select_one("a[href='#focus_cmt'], span.gall_comment")
if cmt_el:
m = re.search(r"\d[\d,]*", cmt_el.get_text())
cmt = int(m.group().replace(",", "")) if m else 0
return {
"title": title,
"author": author,
"date": date_str,
"views": views,
"recommends": rec,
"comments": cmt,
"content": content,
"url": url,
}
# ──────────────────────────────────────────────
# 랭킹 & 포매팅
# ──────────────────────────────────────────────
def rank_posts(
posts: list[CommunityPost],
hours: float = 6.0,
top_n: int = 10,
sort_by: str = "score",
) -> list[CommunityPost]:
"""시간 필터링 + 정렬.
Args:
posts: 게시글 리스트
hours: 최근 N시간 이내 필터 (0이면 필터 없음)
top_n: 상위 N개
sort_by: score | views | recommends | comments
"""
now = datetime.now(KST)
if hours > 0:
cutoff = now - timedelta(hours=hours)
filtered = []
for p in posts:
try:
# 다양한 날짜 포맷 파싱
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y.%m.%d %H:%M:%S", "%Y.%m.%d %H:%M"):
try:
dt = datetime.strptime(p.date, fmt).replace(tzinfo=KST)
break
except ValueError:
continue
else:
# 파싱 실패 시 포함 (데이터 유실 방지)
filtered.append(p)
continue
if dt >= cutoff:
filtered.append(p)
except Exception:
filtered.append(p)
else:
filtered = list(posts)
# 스코어 재계산
for p in filtered:
p.compute_score()
# 정렬
key_map = {
"score": lambda p: p.score,
"views": lambda p: p.views,
"recommends": lambda p: p.recommends,
"comments": lambda p: p.comments,
}
key_fn = key_map.get(sort_by, key_map["score"])
filtered.sort(key=key_fn, reverse=True)
return filtered[:top_n]
def format_ranking(posts: list[CommunityPost], sort_by: str = "score") -> str:
"""랭킹 결과를 마크다운으로 포맷."""
if not posts:
return "해당 기간에 게시글이 없습니다."
sort_labels = {
"score": "종합점수",
"views": "조회수",
"recommends": "추천수",
"comments": "댓글수",
}
label = sort_labels.get(sort_by, "종합점수")
lines = [f"## 커뮤니티 인기글 ({label} 기준)\n"]
lines.append("| # | 제목 | 조회 | 추천 | 댓글 | 점수 |")
lines.append("|---|------|------|------|------|------|")
for i, p in enumerate(posts, 1):
# 제목 길이 제한
title = p.title[:40] + ("…" if len(p.title) > 40 else "")
lines.append(
f"| {i} | {title} | {p.views} | {p.recommends} | {p.comments} | {p.score:.0f} |"
)
lines.append(f"\n> 갤러리: {posts[0].gallery_id} | "
f"조회 기준 시각: {datetime.now(KST).strftime('%Y-%m-%d %H:%M')}")
return "\n".join(lines)
def format_post_detail(data: dict) -> str:
"""게시글 본문을 마크다운으로 포맷."""
lines = [
f"## {data['title']}",
f"**작성자**: {data['author']} | **날짜**: {data['date']}",
f"**조회** {data['views']} | **추천** {data['recommends']}",
"",
"---",
"",
data["content"][:3000] if data["content"] else "(본문 없음)",
"",
f"> 원문: {data['url']}",
]
return "\n".join(lines)
# ──────────────────────────────────────────────
# JSON 출력 (Gemini CLI 연동용)
# ──────────────────────────────────────────────
def output_json(data):
"""JSON 출력 (Gemini CLI가 파싱하기 쉽도록)."""
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
# ──────────────────────────────────────────────
# CLI 진입점
# ──────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="커뮤니티 인기글 조회 도구 (DCInside)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""예시:
trend coffee # 커피갤 인기글 (6시간, 상위 10개)
trend coffee --hours 3 --top 5 # 3시간, 상위 5개
trend coffee --sort views --top 5 # 조회수 기준 상위 5개
trend electricguitar --hours 12 # 일렉기타갤 12시간
view coffee 544618 # 글 본문 조회
""",
)
sub = parser.add_subparsers(dest="command", required=True)
# trend 커맨드
trend_p = sub.add_parser("trend", help="인기글 랭킹")
trend_p.add_argument("gallery_id", help="갤러리 ID (예: coffee, electricguitar)")
trend_p.add_argument("--type", dest="gallery_type", default="mgallery",
choices=["mgallery", "mini", "gallery"],
help="갤러리 유형 (기본: mgallery)")
trend_p.add_argument("--hours", type=float, default=6.0,
help="최근 N시간 이내 (기본: 6)")
trend_p.add_argument("--top", type=int, default=10,
help="상위 N개 (기본: 10)")
trend_p.add_argument("--sort", default="score",
choices=SORT_OPTIONS,
help="정렬 기준: score(종합), views(조회수), recommends(추천), comments(댓글)")
trend_p.add_argument("--pages", type=int, default=5,
help="스크래핑할 페이지 수 (기본: 5)")
trend_p.add_argument("--format", dest="out_format", default="markdown",
choices=["markdown", "json"],
help="출력 형식 (기본: markdown)")
# view 커맨드
view_p = sub.add_parser("view", help="글 본문 조회")
view_p.add_argument("gallery_id", help="갤러리 ID")
view_p.add_argument("post_no", type=int, help="글 번호")
view_p.add_argument("--type", dest="gallery_type", default="mgallery",
choices=["mgallery", "mini", "gallery"],
help="갤러리 유형 (기본: mgallery)")
view_p.add_argument("--format", dest="out_format", default="markdown",
choices=["markdown", "json"],
help="출력 형식 (기본: markdown)")
args = parser.parse_args()
asyncio.run(_async_main(args))
async def _async_main(args):
scraper = DCInsideScraper()
if args.command == "trend":
posts = await scraper.fetch_posts(
args.gallery_id,
gallery_type=args.gallery_type,
pages=args.pages,
)
ranked = rank_posts(
posts,
hours=args.hours,
top_n=args.top,
sort_by=args.sort,
)
if args.out_format == "json":
output_json([asdict(p) for p in ranked])
else:
print(format_ranking(ranked, sort_by=args.sort))
elif args.command == "view":
data = await scraper.fetch_post_content(
args.gallery_id,
args.post_no,
gallery_type=args.gallery_type,
)
if args.out_format == "json":
output_json(data)
else:
print(format_post_detail(data))
if __name__ == "__main__":
main()