feat(tools): 커뮤니티 트렌드 스크래퍼 구현 (DCInside 인기글 + Gemini Skill)
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> AI 에이전트는 **세션 시작 시** 이 문서를 읽어 전체 맥락을 파악합니다.
|
> AI 에이전트는 **세션 시작 시** 이 문서를 읽어 전체 맥락을 파악합니다.
|
||||||
> **세션 종료 시** 변경사항을 이 문서에 반영합니다.
|
> **세션 종료 시** 변경사항을 이 문서에 반영합니다.
|
||||||
> Last updated: 2026-03-16
|
> Last updated: 2026-03-17
|
||||||
|
|
||||||
## 시스템 개요
|
## 시스템 개요
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ Discord 메시지
|
|||||||
| Vikunja 태스크 관리 | `.agent/workflows/helpers/vikunja_helper.py` | 태스크 CRUD + 라벨 + 안전 업데이트 | ✅ |
|
| Vikunja 태스크 관리 | `.agent/workflows/helpers/vikunja_helper.py` | 태스크 CRUD + 라벨 + 안전 업데이트 | ✅ |
|
||||||
| Gitea Wiki 관리 | `.agent/workflows/helpers/wiki_helper.py` | Wiki 페이지 CRUD | ✅ |
|
| Gitea Wiki 관리 | `.agent/workflows/helpers/wiki_helper.py` | Wiki 페이지 CRUD | ✅ |
|
||||||
| Discord Bot | `api/discord_bot.py` | 이벤트 핸들러 + NLU 라우팅 (~310줄) | ✅ |
|
| Discord Bot | `api/discord_bot.py` | 이벤트 핸들러 + NLU 라우팅 (~310줄) | ✅ |
|
||||||
|
| 커뮤니티 트렌드 | `tools/community_scraper.py` | DCInside 갤러리 인기글 스크래핑 + 랭킹 + 본문 조회 (Gemini Skill) | ✅ |
|
||||||
| CLI Bridge | - | Gemini CLI 영속 프로세스 (PTY 미지원으로 보류) | ⏸️ |
|
| CLI Bridge | - | Gemini CLI 영속 프로세스 (PTY 미지원으로 보류) | ⏸️ |
|
||||||
|
|
||||||
## 모듈 상태
|
## 모듈 상태
|
||||||
@@ -53,10 +54,12 @@ Discord 메시지
|
|||||||
| 컨텍스트 | `core/context_manager.py` | ✅ | 관련 파일 선별 + 토큰 예산 |
|
| 컨텍스트 | `core/context_manager.py` | ✅ | 관련 파일 선별 + 토큰 예산 |
|
||||||
| 애니 파이프라인 | `tools/anime_pipeline.py` | ✅ | v2 에피소드 추출, 그룹 필터링, 자막 보호 |
|
| 애니 파이프라인 | `tools/anime_pipeline.py` | ✅ | v2 에피소드 추출, 그룹 필터링, 자막 보호 |
|
||||||
| Discord Bot | `api/discord_bot.py` | ✅ | 레거시 800줄 제거, ~310줄 |
|
| Discord Bot | `api/discord_bot.py` | ✅ | 레거시 800줄 제거, ~310줄 |
|
||||||
|
| 커뮤니티 스크래퍼 | `tools/community_scraper.py` | ✅ | DCInside 스크래퍼 + 4종 정렬 + Gemini Skill |
|
||||||
| 에이전트 워크플로우 | `.agent/` | ✅ | STATUS.md + 수칙 업데이트 |
|
| 에이전트 워크플로우 | `.agent/` | ✅ | STATUS.md + 수칙 업데이트 |
|
||||||
|
|
||||||
## 최근 마일스톤
|
## 최근 마일스톤
|
||||||
|
|
||||||
|
- **2026-03-17**: 커뮤니티 트렌드 스크래퍼 구현 (DCInside 인기글 + Gemini Skill)
|
||||||
- **2026-03-16**: 에이전트 수칙 업데이트 + STATUS.md 도입 + .agents 통합
|
- **2026-03-16**: 에이전트 수칙 업데이트 + STATUS.md 도입 + .agents 통합
|
||||||
- **2026-03-15**: MCP 서버 제거 → CLI 직접 실행 전환
|
- **2026-03-15**: MCP 서버 제거 → CLI 직접 실행 전환
|
||||||
- **2026-03-15**: 레거시 NLU/핸들러 800줄 제거
|
- **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 환경 필요
|
- **CLI Bridge PoC** (#203) — Windows PTY 미지원으로 보류. pywinpty 또는 Docker 환경 필요
|
||||||
|
|||||||
81
.gemini/skills/community/SKILL.md
Normal file
81
.gemini/skills/community/SKILL.md
Normal 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. 출력은 항상 한국어로 해주세요.
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
| 1 | 23:35~11:14 | 봇 기동 버그 3건 수정 (apscheduler, cp949 인코딩, workspaces 경로) + Vikunja 프로젝트 관리 기능 추가 (projects, report 커맨드) + agent 프롬프트 보강 | `c9068cd` | ✅ |
|
| 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` | ✅ |
|
| 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) | - | ✅ |
|
| 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 등록 | - | 🔧 |
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ discord.py>=2.4.0
|
|||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
apscheduler>=3.10.0
|
apscheduler>=3.10.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
lxml>=5.0.0
|
||||||
|
|
||||||
|
|||||||
538
tools/community_scraper.py
Normal file
538
tools/community_scraper.py
Normal 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()
|
||||||
Reference in New Issue
Block a user