feat(tools): 커뮤니티 트렌드 스크래퍼 구현 (DCInside 인기글 + Gemini Skill)

This commit is contained in:
2026-03-17 00:35:36 +09:00
parent ddbe0dc89a
commit ced1523c40
5 changed files with 628 additions and 1 deletions

View File

@@ -2,7 +2,7 @@
> AI 에이전트는 **세션 시작 시** 이 문서를 읽어 전체 맥락을 파악합니다.
> **세션 종료 시** 변경사항을 이 문서에 반영합니다.
> Last updated: 2026-03-16
> Last updated: 2026-03-17
## 시스템 개요
@@ -41,6 +41,7 @@ Discord 메시지
| Vikunja 태스크 관리 | `.agent/workflows/helpers/vikunja_helper.py` | 태스크 CRUD + 라벨 + 안전 업데이트 | ✅ |
| Gitea Wiki 관리 | `.agent/workflows/helpers/wiki_helper.py` | Wiki 페이지 CRUD | ✅ |
| Discord Bot | `api/discord_bot.py` | 이벤트 핸들러 + NLU 라우팅 (~310줄) | ✅ |
| 커뮤니티 트렌드 | `tools/community_scraper.py` | DCInside 갤러리 인기글 스크래핑 + 랭킹 + 본문 조회 (Gemini Skill) | ✅ |
| CLI Bridge | - | Gemini CLI 영속 프로세스 (PTY 미지원으로 보류) | ⏸️ |
## 모듈 상태
@@ -53,10 +54,12 @@ Discord 메시지
| 컨텍스트 | `core/context_manager.py` | ✅ | 관련 파일 선별 + 토큰 예산 |
| 애니 파이프라인 | `tools/anime_pipeline.py` | ✅ | v2 에피소드 추출, 그룹 필터링, 자막 보호 |
| Discord Bot | `api/discord_bot.py` | ✅ | 레거시 800줄 제거, ~310줄 |
| 커뮤니티 스크래퍼 | `tools/community_scraper.py` | ✅ | DCInside 스크래퍼 + 4종 정렬 + Gemini Skill |
| 에이전트 워크플로우 | `.agent/` | ✅ | STATUS.md + 수칙 업데이트 |
## 최근 마일스톤
- **2026-03-17**: 커뮤니티 트렌드 스크래퍼 구현 (DCInside 인기글 + Gemini Skill)
- **2026-03-16**: 에이전트 수칙 업데이트 + STATUS.md 도입 + .agents 통합
- **2026-03-15**: MCP 서버 제거 → CLI 직접 실행 전환
- **2026-03-15**: 레거시 NLU/핸들러 800줄 제거
@@ -65,4 +68,6 @@ Discord 메시지
## 현재 미해결
- **핫딜 통합 수집기** (#393) — 뽐뿌/퀘이사존/딜봄/클리앙 핫딜 통합 크롤링 + 트렌드 분석
- **AI 회의실 (Debate Room)** (#387) — Gemini + Claude CLI 래퍼로 3자 토론 세션
- **CLI Bridge PoC** (#203) — Windows PTY 미지원으로 보류. pywinpty 또는 Docker 환경 필요

View File

@@ -0,0 +1,81 @@
---
name: community-trend
description: 커뮤니티 사이트(DCInside 등) 갤러리의 최신 인기글을 조회하고 트렌드를 분석하는 도구
---
# 커뮤니티 트렌드 도구
DCInside 갤러리(마이너/일반)의 인기글을 스크래핑하여 랭킹을 보여주거나 글 본문을 조회합니다.
## 사용 시나리오
사용자가 "커피갤 트렌드", "일렉기타갤 인기글", "XX갤 최신글 요약" 등을 요청하면 이 도구를 사용합니다.
## 도구 위치
```
tools/community_scraper.py
```
## 실행 방법
반드시 아래 Python 경로를 사용하세요:
```bash
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py <command> [args]
```
### 1. 인기글 랭킹 (trend)
```bash
# 기본: 최근 6시간, 상위 10개, 종합점수 기준
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py trend coffee
# 조회수 기준 상위 5개
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py trend coffee --sort views --top 5
# 추천수 기준, 3시간 이내
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py trend coffee --sort recommends --hours 3
# 댓글수 기준
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py trend coffee --sort comments --top 10
# 일반 갤러리 (마이너가 아닌 경우)
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py trend programming --type gallery
# JSON 출력
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py trend coffee --format json
```
**옵션:**
- `--hours N`: 최근 N시간 이내 필터 (기본: 6)
- `--top N`: 상위 N개 (기본: 10)
- `--sort`: score(종합), views(조회수), recommends(추천), comments(댓글)
- `--type`: mgallery(마이너), mini(미니), gallery(일반)
- `--pages N`: 스크래핑할 페이지 수 (기본: 5)
- `--format`: markdown 또는 json
### 2. 글 본문 조회 (view)
```bash
# 특정 글 본문 읽기
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py view coffee 544618
# JSON 출력
C:\ProgramData\miniforge3\envs\variet-agent\python.exe tools/community_scraper.py view coffee 544618 --format json
```
## 알려진 갤러리 ID (예시)
| 갤러리 | ID | 유형 |
|--------|-----|------|
| 커피 | coffee | mgallery |
| 일렉기타 | electricguitar | mgallery |
| 프로그래밍 | programming | gallery |
## 트렌드 응답 시 지침
1. `trend` 실행 결과를 받으면, 상위 글들의 제목과 수치를 요약해서 보여주세요.
2. "어떤 주제가 화제인지", "어떤 트렌드가 보이는지" 간략히 분석해주세요.
3. 사용자가 특정 글을 더 보고 싶어하면 `view`로 본문을 조회해주세요.
4. 출력은 항상 한국어로 해주세요.

View File

@@ -5,3 +5,4 @@
| 1 | 23:35~11:14 | 봇 기동 버그 3건 수정 (apscheduler, cp949 인코딩, workspaces 경로) + Vikunja 프로젝트 관리 기능 추가 (projects, report 커맨드) + agent 프롬프트 보강 | `c9068cd` | ✅ |
| 2 | 13:46~14:29 | .agents→.agent 통합 + AGENT.md 수칙 업데이트(Security/Context/Blast Radius) + STATUS.md 도입 + end.md Wiki 동기화 개편 | `6c17f9b` | ✅ |
| 3 | 14:39~18:09 | Vikunja CRUD 읽기/쓰기 테스트 (7 ops all pass) + AI 회의실(Debate Room) 설계서 작성 + CLI 비교 조사 (Gemini/Claude/Codex) | - | ✅ |
| 4 | 23:02~00:32 | 커뮤니티 트렌드 스크래퍼 구현 (tools/community_scraper.py + .gemini/skills/community/SKILL.md) — DCInside 갤러리 인기글 랭킹(조회수/추천/댓글/종합) + 본문 조회 + CLI. 핫딜 애그리게이터 Vikunja #393 등록 | - | 🔧 |

View File

@@ -5,4 +5,6 @@ discord.py>=2.4.0
pydantic>=2.0.0
httpx>=0.27.0
apscheduler>=3.10.0
beautifulsoup4>=4.12.0
lxml>=5.0.0

538
tools/community_scraper.py Normal file
View File

@@ -0,0 +1,538 @@
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)
# 조회수: <span class="gall_count">조회 685</span>
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
# 추천수: <span class="up_num">추천 24</span> 또는 기타 위치
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
# 댓글수: <a href="#focus_cmt">댓글 35</a>
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()