From ced1523c404ba9f7874f14c362e01118f8821965 Mon Sep 17 00:00:00 2001 From: CD Date: Tue, 17 Mar 2026 00:35:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=8A=B8=EB=A0=8C=EB=93=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=9E=98=ED=8D=BC=20=EA=B5=AC=ED=98=84=20(DCInside=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=EA=B8=80=20+=20Gemini=20Skill)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/references/STATUS.md | 7 +- .gemini/skills/community/SKILL.md | 81 +++++ docs/devlog/2026-03-16.md | 1 + requirements.txt | 2 + tools/community_scraper.py | 538 ++++++++++++++++++++++++++++++ 5 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 .gemini/skills/community/SKILL.md create mode 100644 tools/community_scraper.py diff --git a/.agent/references/STATUS.md b/.agent/references/STATUS.md index aead131..c020c92 100644 --- a/.agent/references/STATUS.md +++ b/.agent/references/STATUS.md @@ -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 환경 필요 diff --git a/.gemini/skills/community/SKILL.md b/.gemini/skills/community/SKILL.md new file mode 100644 index 0000000..a829402 --- /dev/null +++ b/.gemini/skills/community/SKILL.md @@ -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 [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. 출력은 항상 한국어로 해주세요. diff --git a/docs/devlog/2026-03-16.md b/docs/devlog/2026-03-16.md index 7ddde2d..1133ce3 100644 --- a/docs/devlog/2026-03-16.md +++ b/docs/devlog/2026-03-16.md @@ -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 등록 | - | 🔧 | diff --git a/requirements.txt b/requirements.txt index f669f3a..380aee4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tools/community_scraper.py b/tools/community_scraper.py new file mode 100644 index 0000000..4167f3f --- /dev/null +++ b/tools/community_scraper.py @@ -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) + + # 조회수: 조회 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()