@clawhub-thedalbee-78184554b5
와디즈/텀블벅 펀딩 프로젝트를 크롤링해서 달성률 50~99% 메이커에게 보낼 개인화 DM을 자동 생성하는 파이프라인. 달비님 맥에서 직접 실행. 사용 시 — 와디즈 DM 뽑아줘, 텀블벅 프로젝트 스크랩해서 DM 만들어줘, 광고 소재 서비스 DM 파이프라인 돌려줘.
---
name: wadiz-dm-pipeline
description: "와디즈/텀블벅 펀딩 프로젝트를 크롤링해서 달성률 50~99% 메이커에게 보낼 개인화 DM을 자동 생성하는 파이프라인. 달비님 맥에서 직접 실행. 사용 시 — 와디즈 DM 뽑아줘, 텀블벅 프로젝트 스크랩해서 DM 만들어줘, 광고 소재 서비스 DM 파이프라인 돌려줘."
---
# Wadiz DM Pipeline
와디즈/텀블벅 카테고리 페이지 크롤링 → 달성률 50~99% 필터 → 프로젝트 분석 → 개인화 DM 생성.
## 실행 방법
```bash
python3 scripts/pipeline.py [카테고리] [최대개수]
```
**카테고리 옵션:**
- `food` — 식품
- `beauty` — 뷰티
- `lifestyle` — 라이프스타일
- `fashion` — 패션
- `all` — 전체 (기본값)
**예시:**
```bash
python3 scripts/pipeline.py all 100
python3 scripts/pipeline.py beauty 50
```
## 출력
실행 후 `output/dm_results_YYYYMMDD.csv` 파일 생성:
- 프로젝트명, URL, 달성률, 카테고리
- 개인화 DM 텍스트 (즉시 복붙 가능)
## DM 오퍼 설정
`references/dm-template.md` 참조. 현재 오퍼: **무료 소재 10개 먼저 제작, 마음에 들면 결제**.
## 주의사항
- 달비님 맥 로컬에서만 실행 (서버 IP는 CDN 차단됨)
- Playwright 필요: `pip install playwright && python -m playwright install chromium`
- 과도한 요청 방지를 위해 요청 간 2~3초 딜레이 내장
- 결과 파일은 `output/` 폴더에 저장됨
FILE:references/dm-template.md
# DM 템플릿
## 현재 오퍼
무료 소재 10개 먼저 제작 → 마음에 들면 결제 (첫 고객 한정)
## 템플릿 구조
Hook(첫 줄) → Value → Soft CTA. 70단어 이내.
## 달성률 50~99% 메이커용 (텀블벅)
```
안녕하세요, {프로젝트명} 보고 연락드렸어요.
{프로젝트 특징 한 줄} 보고 광고 소재 방향이 더 있겠다 싶어서요.
저는 상세 페이지 읽고 소재 100개 만들어드리는 서비스를 하는데,
첫 의뢰는 10개 먼저 무료로 만들어드리고 마음에 드시면 그때 결제하시는 방식이에요.
관심 있으시면 편하게 말씀해 주세요.
```
## 개인화 포인트
- `{프로젝트명}`: 실제 프로젝트 이름
- `{프로젝트 특징 한 줄}`: 상세 페이지에서 읽은 USP 또는 특이점
- 예: "친환경 패키징으로 기획하신 거"
- 예: "한국 전통 재료 쓰신 거"
- 예: "소량 생산으로 가격대 잡으신 거"
## 금지 사항
- "AI로 만들어드려요" 절대 금지
- 자기자랑 금지 ("저는 ~~한 전문가입니다" 등)
- 질문 2개 이상 금지
- 이모지 과다 사용 금지
FILE:scripts/pipeline.py
#!/usr/bin/env python3
"""
와디즈/텀블벅 DM 파이프라인
달비님 맥에서 직접 실행하는 버전 (Playwright 기반)
사용법:
python3 pipeline.py [카테고리] [최대개수]
python3 pipeline.py all 100
python3 pipeline.py beauty 50
"""
import sys
import os
import csv
import time
import random
import json
import re
from datetime import datetime
from pathlib import Path
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("❌ Playwright 미설치. 아래 명령어 실행 후 다시 시도하세요:")
print(" pip install playwright")
print(" python -m playwright install chromium")
sys.exit(1)
try:
import anthropic
except ImportError:
print("❌ anthropic 미설치. 아래 명령어 실행 후 다시 시도하세요:")
print(" pip install anthropic")
sys.exit(1)
# ── 설정 ─────────────────────────────────────────
CATEGORY_MAP = {
"food": "https://www.wadiz.kr/web/wreward/category/100?sort=funding&status=opening",
"beauty": "https://www.wadiz.kr/web/wreward/category/106?sort=funding&status=opening",
"lifestyle": "https://www.wadiz.kr/web/wreward/category/101?sort=funding&status=opening",
"fashion": "https://www.wadiz.kr/web/wreward/category/102?sort=funding&status=opening",
"all": "https://www.wadiz.kr/web/wreward/main?sort=funding&status=opening",
}
ACHIEVEMENT_MIN = 50
ACHIEVEMENT_MAX = 99
REQUEST_DELAY = (2, 4) # 초 (랜덤 딜레이)
OUTPUT_DIR = Path(__file__).parent.parent / "output"
OUTPUT_DIR.mkdir(exist_ok=True)
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
DM_TEMPLATE = """안녕하세요, {project_name} 보고 연락드렸어요.
{hook}
저는 상세 페이지 읽고 소재 100개 만들어드리는 서비스를 하는데,
첫 의뢰는 10개 먼저 무료로 만들어드리고 마음에 드시면 그때 결제하시는 방식이에요.
관심 있으시면 편하게 말씀해 주세요."""
# ── 크롤링 ─────────────────────────────────────────
def get_project_list(page, category_url: str, max_count: int) -> list[dict]:
"""와디즈 카테고리 페이지에서 프로젝트 목록 수집"""
print(f"\n📋 프로젝트 목록 수집 중...")
page.goto(category_url, wait_until="networkidle", timeout=30000)
time.sleep(2)
projects = []
last_count = 0
scroll_attempts = 0
while len(projects) < max_count and scroll_attempts < 30:
# 카드 수집
cards = page.query_selector_all(".ProjectCard_projectCard__GcBEG, [class*='projectCard'], [class*='ProjectCard']")
if not cards:
# 대안 셀렉터
cards = page.query_selector_all("a[href*='/web/wreward/project/']")
for card in cards:
if len(projects) >= max_count:
break
try:
# URL
href = card.get_attribute("href") or ""
if not href.startswith("http"):
href = "https://www.wadiz.kr" + href
if href in [p["url"] for p in projects]:
continue
# 달성률 추출
text = card.inner_text()
rate_match = re.search(r"(\d+(?:\.\d+)?)\s*%", text)
if not rate_match:
continue
rate = float(rate_match.group(1))
if not (ACHIEVEMENT_MIN <= rate <= ACHIEVEMENT_MAX):
continue
# 프로젝트명
title_el = card.query_selector("h3, h2, [class*='title'], [class*='Title']")
title = title_el.inner_text().strip() if title_el else "알 수 없음"
projects.append({
"url": href,
"title": title,
"achievement_rate": rate,
"description": "",
"dm": "",
})
print(f" [{len(projects)}] {title[:30]} — {rate}%")
except Exception:
continue
# 스크롤
if len(projects) == last_count:
scroll_attempts += 1
page.evaluate("window.scrollBy(0, window.innerHeight)")
time.sleep(random.uniform(*REQUEST_DELAY))
else:
scroll_attempts = 0
last_count = len(projects)
return projects
def get_project_detail(page, url: str) -> str:
"""프로젝트 상세 페이지에서 핵심 내용 추출"""
try:
page.goto(url, wait_until="networkidle", timeout=20000)
time.sleep(1)
# 제목 + 소개 텍스트만
texts = []
for sel in ["h1", ".reward-intro", "[class*='intro']", "[class*='summary']", "[class*='description']"]:
els = page.query_selector_all(sel)
for el in els[:2]:
t = el.inner_text().strip()
if len(t) > 10:
texts.append(t[:300])
return " | ".join(texts[:3])
except Exception:
return ""
# ── DM 생성 ─────────────────────────────────────────
def generate_dm(title: str, description: str) -> str:
"""Anthropic API로 개인화 DM 생성"""
if not ANTHROPIC_API_KEY:
# API 키 없으면 기본 템플릿
hook = f"{title}의 제품 방향이 흥미로워서요."
return DM_TEMPLATE.format(project_name=title, hook=hook)
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
prompt = f"""아래 와디즈 펀딩 프로젝트를 보고, 메이커에게 보낼 짧은 DM 메시지를 만들어줘.
프로젝트명: {title}
프로젝트 내용: {description[:500]}
DM 조건:
- 전체 100단어 이내
- 첫 줄: 프로젝트 특징 한 가지를 짚어서 "보고 연락드렸어요" 형태
- AI 언급 절대 금지
- 자기자랑 금지
- 오퍼: "상세 페이지 읽고 소재 100개 만들어드리는 서비스인데, 첫 의뢰는 10개 먼저 무료로 만들어드리고 마음에 드시면 결제하는 방식"
- 마무리: "관심 있으시면 편하게 말씀해 주세요."
- 존댓말, 자연스러운 한국어
DM 텍스트만 출력. 다른 설명 없이."""
try:
msg = client.messages.create(
model="claude-haiku-4-5",
max_tokens=300,
messages=[{"role": "user", "content": prompt}],
)
return msg.content[0].text.strip()
except Exception as e:
print(f" ⚠️ DM 생성 실패 ({e}), 기본 템플릿 사용")
hook = f"{title}의 방향이 흥미로워서요."
return DM_TEMPLATE.format(project_name=title, hook=hook)
# ── 메인 ─────────────────────────────────────────
def main():
category = sys.argv[1] if len(sys.argv) > 1 else "all"
max_count = int(sys.argv[2]) if len(sys.argv) > 2 else 50
if category not in CATEGORY_MAP:
print(f"❌ 지원하지 않는 카테고리: {category}")
print(f" 사용 가능: {', '.join(CATEGORY_MAP.keys())}")
sys.exit(1)
if not ANTHROPIC_API_KEY:
print("⚠️ ANTHROPIC_API_KEY 환경변수 없음. 기본 템플릿으로 DM 생성합니다.")
print(" export ANTHROPIC_API_KEY='sk-ant-...' 설정 후 실행하면 개인화 DM 생성됩니다.\n")
print(f"🚀 와디즈 DM 파이프라인 시작")
print(f" 카테고리: {category} | 목표: {max_count}개 | 달성률: {ACHIEVEMENT_MIN}~{ACHIEVEMENT_MAX}%")
with sync_playwright() as p:
browser = p.chromium.launch(headless=False) # 디버깅 위해 헤드풀
context = browser.new_context(
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
viewport={"width": 1280, "height": 900},
)
page = context.new_page()
# 1. 프로젝트 목록 수집
projects = get_project_list(page, CATEGORY_MAP[category], max_count)
print(f"\n✅ {len(projects)}개 프로젝트 수집 완료 (달성률 {ACHIEVEMENT_MIN}~{ACHIEVEMENT_MAX}%)")
if not projects:
print("❌ 조건에 맞는 프로젝트가 없습니다.")
browser.close()
return
# 2. 상세 페이지 분석 + DM 생성
print(f"\n✍️ DM 생성 중...")
for i, proj in enumerate(projects):
print(f" [{i+1}/{len(projects)}] {proj['title'][:30]}")
desc = get_project_detail(page, proj["url"])
proj["description"] = desc
proj["dm"] = generate_dm(proj["title"], desc)
time.sleep(random.uniform(*REQUEST_DELAY))
browser.close()
# 3. CSV 저장
today = datetime.now().strftime("%Y%m%d_%H%M")
output_path = OUTPUT_DIR / f"dm_results_{today}.csv"
with open(output_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["title", "url", "achievement_rate", "description", "dm"])
writer.writeheader()
writer.writerows(projects)
print(f"\n🎉 완료! {len(projects)}개 DM 저장됨")
print(f" 📁 {output_path}")
print(f"\n--- DM 미리보기 (첫 3개) ---")
for proj in projects[:3]:
print(f"\n[{proj['title']} — {proj['achievement_rate']}%]")
print(proj["dm"])
print()
if __name__ == "__main__":
main()
Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to "dogfood", "QA", "exploratory test", "find i...
--- name: dogfood description: Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to "dogfood", "QA", "exploratory test", "find issues", "bug hunt", "test this app/site/platform", or review the quality of a web application. Produces a structured report with full reproduction evidence -- step-by-step screenshots, repro videos, and detailed repro steps for every issue -- so findings can be handed directly to the responsible teams. allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) --- # Dogfood Systematically explore a web application, find issues, and produce a report with full reproduction evidence for every finding. ## Setup Only the **Target URL** is required. Everything else has sensible defaults -- use them unless the user explicitly provides an override. | Parameter | Default | Example override | |-----------|---------|-----------------| | **Target URL** | _(required)_ | `vercel.com`, `http://localhost:3000` | | **Session name** | Slugified domain (e.g., `vercel.com` -> `vercel-com`) | `--session my-session` | | **Output directory** | `./dogfood-output/` | `Output directory: /tmp/qa` | | **Scope** | Full app | `Focus on the billing page` | | **Authentication** | None | `Sign in to [email protected]` | If the user says something like "dogfood vercel.com", start immediately with defaults. Do not ask clarifying questions unless authentication is mentioned but credentials are missing. Always use `agent-browser` directly -- never `npx agent-browser`. The direct binary uses the fast Rust client. `npx` routes through Node.js and is significantly slower. ## Workflow ``` 1. Initialize Set up session, output dirs, report file 2. Authenticate Sign in if needed, save state 3. Orient Navigate to starting point, take initial snapshot 4. Explore Systematically visit pages and test features 5. Document Screenshot + record each issue as found 6. Wrap up Update summary counts, close session ``` ### 1. Initialize ```bash mkdir -p {OUTPUT_DIR}/screenshots {OUTPUT_DIR}/videos ``` Copy the report template into the output directory and fill in the header fields: ```bash cp {SKILL_DIR}/templates/dogfood-report-template.md {OUTPUT_DIR}/report.md ``` Start a named session: ```bash agent-browser --session {SESSION} open {TARGET_URL} agent-browser --session {SESSION} wait --load networkidle ``` ### 2. Authenticate If the app requires login: ```bash agent-browser --session {SESSION} snapshot -i # Identify login form refs, fill credentials agent-browser --session {SESSION} fill @e1 "{EMAIL}" agent-browser --session {SESSION} fill @e2 "{PASSWORD}" agent-browser --session {SESSION} click @e3 agent-browser --session {SESSION} wait --load networkidle ``` For OTP/email codes: ask the user, wait for their response, then enter the code. After successful login, save state for potential reuse: ```bash agent-browser --session {SESSION} state save {OUTPUT_DIR}/auth-state.json ``` ### 3. Orient Take an initial annotated screenshot and snapshot to understand the app structure: ```bash agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/initial.png agent-browser --session {SESSION} snapshot -i ``` Identify the main navigation elements and map out the sections to visit. ### 4. Explore Read [references/issue-taxonomy.md](references/issue-taxonomy.md) for the full list of what to look for and the exploration checklist. **Strategy -- work through the app systematically:** - Start from the main navigation. Visit each top-level section. - Within each section, test interactive elements: click buttons, fill forms, open dropdowns/modals. - Check edge cases: empty states, error handling, boundary inputs. - Try realistic end-to-end workflows (create, edit, delete flows). - Check the browser console for errors periodically. **At each page:** ```bash agent-browser --session {SESSION} snapshot -i agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/{page-name}.png agent-browser --session {SESSION} errors agent-browser --session {SESSION} console ``` Use your judgment on how deep to go. Spend more time on core features and less on peripheral pages. If you find a cluster of issues in one area, investigate deeper. ### 5. Document Issues (Repro-First) Steps 4 and 5 happen together -- explore and document in a single pass. When you find an issue, stop exploring and document it immediately before moving on. Do not explore the whole app first and document later. Every issue must be reproducible. When you find something wrong, do not just note it -- prove it with evidence. The goal is that someone reading the report can see exactly what happened and replay it. **Choose the right level of evidence for the issue:** #### Interactive / behavioral issues (functional, ux, console errors on action) These require user interaction to reproduce -- use full repro with video and step-by-step screenshots: 1. **Start a repro video** _before_ reproducing: ```bash agent-browser --session {SESSION} record start {OUTPUT_DIR}/videos/issue-{NNN}-repro.webm ``` 2. **Walk through the steps at human pace.** Pause 1-2 seconds between actions so the video is watchable. Take a screenshot at each step: ```bash agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-1.png sleep 1 # Perform action (click, fill, etc.) sleep 1 agent-browser --session {SESSION} screenshot {OUTPUT_DIR}/screenshots/issue-{NNN}-step-2.png sleep 1 # ...continue until the issue manifests ``` 3. **Capture the broken state.** Pause so the viewer can see it, then take an annotated screenshot: ```bash sleep 2 agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}-result.png ``` 4. **Stop the video:** ```bash agent-browser --session {SESSION} record stop ``` 5. Write numbered repro steps in the report, each referencing its screenshot. #### Static / visible-on-load issues (typos, placeholder text, clipped text, misalignment, console errors on load) These are visible without interaction -- a single annotated screenshot is sufficient. No video, no multi-step repro: ```bash agent-browser --session {SESSION} screenshot --annotate {OUTPUT_DIR}/screenshots/issue-{NNN}.png ``` Write a brief description and reference the screenshot in the report. Set **Repro Video** to `N/A`. --- **For all issues:** 1. **Append to the report immediately.** Do not batch issues for later. Write each one as you find it so nothing is lost if the session is interrupted. 2. **Increment the issue counter** (ISSUE-001, ISSUE-002, ...). ### 6. Wrap Up Aim to find **5-10 well-documented issues**, then wrap up. Depth of evidence matters more than total count -- 5 issues with full repro beats 20 with vague descriptions. After exploring: 1. Re-read the report and update the summary severity counts so they match the actual issues. Every `### ISSUE-` block must be reflected in the totals. 2. Close the session: ```bash agent-browser --session {SESSION} close ``` 3. Tell the user the report is ready and summarize findings: total issues, breakdown by severity, and the most critical items. ## Guidance - **Repro is everything.** Every issue needs proof -- but match the evidence to the issue. Interactive bugs need video and step-by-step screenshots. Static bugs (typos, placeholder text, visual glitches visible on load) only need a single annotated screenshot. - **Don't record video for static issues.** A typo or clipped text doesn't benefit from a video. Save video for issues that involve user interaction, timing, or state changes. - **For interactive issues, screenshot each step.** Capture the before, the action, and the after -- so someone can see the full sequence. - **Write repro steps that map to screenshots.** Each numbered step in the report should reference its corresponding screenshot. A reader should be able to follow the steps visually without touching a browser. - **Be thorough but use judgment.** You are not following a test script -- you are exploring like a real user would. If something feels off, investigate. - **Write findings incrementally.** Append each issue to the report as you discover it. If the session is interrupted, findings are preserved. Never batch all issues for the end. - **Never delete output files.** Do not `rm` screenshots, videos, or the report mid-session. Do not close the session and restart. Work forward, not backward. - **Never read the target app's source code.** You are testing as a user, not auditing code. Do not read HTML, JS, or config files of the app under test. All findings must come from what you observe in the browser. - **Check the console.** Many issues are invisible in the UI but show up as JS errors or failed requests. - **Test like a user, not a robot.** Try common workflows end-to-end. Click things a real user would click. Enter realistic data. - **Type like a human.** When filling form fields during video recording, use `type` instead of `fill` -- it types character-by-character. Use `fill` only outside of video recording when speed matters. - **Pace repro videos for humans.** Add `sleep 1` between actions and `sleep 2` before the final result screenshot. Videos should be watchable at 1x speed -- a human reviewing the report needs to see what happened, not a blur of instant state changes. - **Be efficient with commands.** Batch multiple `agent-browser` commands in a single shell call when they are independent (e.g., `agent-browser ... screenshot ... && agent-browser ... console`). Use `agent-browser --session {SESSION} scroll down 300` for scrolling -- do not use `key` or `evaluate` to scroll. ## References | Reference | When to Read | |-----------|--------------| | [references/issue-taxonomy.md](references/issue-taxonomy.md) | Start of session -- calibrate what to look for, severity levels, exploration checklist | ## Templates | Template | Purpose | |----------|---------| | [templates/dogfood-report-template.md](templates/dogfood-report-template.md) | Copy into output directory as the report file | FILE:references/issue-taxonomy.md # Issue Taxonomy Reference for categorizing issues found during dogfooding. Read this at the start of a dogfood session to calibrate what to look for. ## Contents - [Severity Levels](#severity-levels) - [Categories](#categories) - [Exploration Checklist](#exploration-checklist) ## Severity Levels | Severity | Definition | |----------|------------| | **critical** | Blocks a core workflow, causes data loss, or crashes the app | | **high** | Major feature broken or unusable, no workaround | | **medium** | Feature works but with noticeable problems, workaround exists | | **low** | Minor cosmetic or polish issue | ## Categories ### Visual / UI - Layout broken or misaligned elements - Overlapping or clipped text - Inconsistent spacing, padding, or margins - Missing or broken icons/images - Dark mode / light mode rendering issues - Responsive layout problems (viewport sizes) - Z-index stacking issues (elements hidden behind others) - Font rendering issues (wrong font, size, weight) - Color contrast problems - Animation glitches or jank ### Functional - Broken links (404, wrong destination) - Buttons or controls that do nothing on click - Form validation that rejects valid input or accepts invalid input - Incorrect redirects - Features that fail silently - State not persisted when expected (lost on refresh, navigation) - Race conditions (double-submit, stale data) - Broken search or filtering - Pagination issues - File upload/download failures ### UX - Confusing or unclear navigation - Missing loading indicators or feedback after actions - Slow or unresponsive interactions (>300ms perceived delay) - Unclear error messages - Missing confirmation for destructive actions - Dead ends (no way to go back or proceed) - Inconsistent patterns across similar features - Missing keyboard shortcuts or focus management - Unintuitive defaults - Missing empty states or unhelpful empty states ### Content - Typos or grammatical errors - Outdated or incorrect text - Placeholder or lorem ipsum content left in - Truncated text without tooltip or expansion - Missing or wrong labels - Inconsistent terminology ### Performance - Slow page loads (>3s) - Janky scrolling or animations - Large layout shifts (content jumping) - Excessive network requests (check via console/network) - Memory leaks (page slows over time) - Unoptimized images (large file sizes) ### Console / Errors - JavaScript exceptions in console - Failed network requests (4xx, 5xx) - Deprecation warnings - CORS errors - Mixed content warnings - Unhandled promise rejections ### Accessibility - Missing alt text on images - Unlabeled form inputs - Poor keyboard navigation (can't tab to elements) - Focus traps - Insufficient color contrast - Missing ARIA attributes on dynamic content - Screen reader incompatible patterns ## Exploration Checklist Use this as a guide for what to test on each page/feature: 1. **Visual scan** -- Take an annotated screenshot. Look for layout, alignment, and rendering issues. 2. **Interactive elements** -- Click every button, link, and control. Do they work? Is there feedback? 3. **Forms** -- Fill and submit. Test empty submission, invalid input, and edge cases. 4. **Navigation** -- Follow all navigation paths. Check breadcrumbs, back button, deep links. 5. **States** -- Check empty states, loading states, error states, and full/overflow states. 6. **Console** -- Check for JS errors, failed requests, and warnings. 7. **Responsiveness** -- If relevant, test at different viewport sizes. 8. **Auth boundaries** -- Test what happens when not logged in, with different roles if applicable. FILE:templates/dogfood-report-template.md # Dogfood Report: {APP_NAME} | Field | Value | |-------|-------| | **Date** | {DATE} | | **App URL** | {URL} | | **Session** | {SESSION_NAME} | | **Scope** | {SCOPE} | ## Summary | Severity | Count | |----------|-------| | Critical | 0 | | High | 0 | | Medium | 0 | | Low | 0 | | **Total** | **0** | ## Issues <!-- Copy this block for each issue found. Interactive issues need video + step-by-step screenshots. Static issues (typos, visual glitches) only need a single screenshot -- set Repro Video to N/A. --> ### ISSUE-001: {Short title} | Field | Value | |-------|-------| | **Severity** | critical / high / medium / low | | **Category** | visual / functional / ux / content / performance / console / accessibility | | **URL** | {page URL where issue was found} | | **Repro Video** | {path to video, or N/A for static issues} | **Description** {What is wrong, what was expected, and what actually happened.} **Repro Steps** <!-- Each step has a screenshot. A reader should be able to follow along visually. --> 1. Navigate to {URL}  2. {Action -- e.g., click "Settings" in the sidebar}  3. {Action -- e.g., type "test" in the search field and press Enter}  4. **Observe:** {what goes wrong -- e.g., the page shows a blank white screen instead of search results}  ---
한국어 YouTube 영상을 바이럴 클립으로 자동 분할하는 스킬. Whisper로 음성 분석 → Claude로 바이럴 구간 선정 → ffmpeg으로 클립 추출. 무음/숨소리 구간 제거, 3색 한국어 자막(SUIT 폰트), 바이럴 점수 + 리즌 텍스트 포함. YouTube URL 또...
---
name: youtube-clipper-ko
description: >
한국어 YouTube 영상을 바이럴 클립으로 자동 분할하는 스킬.
Whisper로 음성 분석 → Claude로 바이럴 구간 선정 → ffmpeg으로 클립 추출.
무음/숨소리 구간 제거, 3색 한국어 자막(SUIT 폰트), 바이럴 점수 + 리즌 텍스트 포함.
YouTube URL 또는 로컬 파일 입력 지원. Clawitzer 파이프라인 연결 가능.
triggers:
- youtube 클립
- 영상 클리핑
- 바이럴 구간 추출
- 쇼츠 변환
- 롱폼 쇼츠
---
# YouTube Clipper KO
한국어 YouTube 영상 → 바이럴 숏폼 클립 자동 분할기.
## 개요
1. YouTube URL 또는 로컬 영상 파일 입력
2. yt-dlp로 영상 다운로드
3. Whisper API로 전체 자막 + word-level 타임스탬프 추출
4. Claude Haiku로 바이럴 구간 8~12개 선정 + 점수 + 한국어 리즌
5. ffmpeg으로 각 구간 9:16 클립 추출
6. 무음/숨소리 압축 (max_gap_sec=0.06)
7. 3색 한국어 자막 burn-in (SUIT ExtraBold)
8. 결과 폴더에 클립 + 메타데이터 JSON 저장
## 필수 도구 확인
에이전트는 작업 시작 전 반드시 환경을 확인해야 한다:
```bash
# yt-dlp
yt-dlp --version
# ffmpeg (libass 포함 여부 확인)
ffmpeg -filters 2>&1 | grep subtitles
# python 의존성
python3 -c "import openai, anthropic; print('OK')"
# SUIT 폰트
ls /usr/share/fonts/truetype/SUIT-ExtraBold.ttf 2>/dev/null || echo "MISSING"
```
없으면:
- yt-dlp: `pip install yt-dlp`
- ffmpeg: `apt install ffmpeg` 또는 `brew install ffmpeg`
- 의존성: `pip install openai anthropic`
- SUIT 폰트 없으면 NotoSansCJK-Bold.ttc fallback 자동 적용
## 사용법
에이전트는 사용자에게 다음을 물어야 한다:
1. YouTube URL 또는 로컬 영상 경로
2. 구간 설정 (선택, 기본: 전체)
3. 클립 길이 (선택: 15~30초 / 30~60초 / 60~90초 / 90~120초, 기본: 30~60초)
4. 자막 언어 (선택: 한국어 / 영어, 기본: 한국어)
받은 후 scripts/clip.py 실행:
```bash
python3 skills/youtube-clipper-ko/scripts/clip.py \
--url "https://youtu.be/VIDEO_ID" \
--clip-length 60 \
--lang ko
```
또는 로컬 파일:
```bash
python3 skills/youtube-clipper-ko/scripts/clip.py \
--file "/path/to/video.mp4" \
--clip-length 60
```
## 출력 구조
```
outputs/YYYYMMDD_HHMMSS/
├── source.mp4 # 원본 (URL 입력 시 다운로드)
├── transcript.json # Whisper 전체 자막 + 타임스탬프
├── viral_segments.json # 바이럴 구간 분석 결과
├── clips/
│ ├── clip_01_[제목].mp4
│ ├── clip_02_[제목].mp4
│ └── ...
└── result.json # 전체 메타데이터
```
## viral_segments.json 구조
```json
[
{
"rank": 1,
"score": 87,
"start": "03:14",
"end": "04:02",
"title": "월 200만원 자동화하는 방법",
"reason": "구체적인 금액과 방법이 동시에 나옴. 첫 3초 안에 결론 제시.",
"hook": "근데 진짜 이게 되거든요",
"clip_file": "clips/clip_01_월200만원자동화.mp4"
}
]
```
## Clawitzer 연결
클립 추출 후 Clawitzer 파이프라인으로 넘기려면:
```bash
# 추출된 클립을 Clawitzer 소스로 사용
python3 projects/clawitzer/main.py \
--video "skills/youtube-clipper-ko/outputs/TIMESTAMP/clips/clip_01.mp4" \
--script-file "skills/youtube-clipper-ko/outputs/TIMESTAMP/clip_01_script.json"
```
단, Clawitzer의 TTS는 사용하지 않음 (원본 음성 유지).
editor.py의 자막/편집 로직만 활용 가능.
## API 키
- OpenAI Whisper: TOOLS.md의 Gemini 키 대신 OpenAI API 필요 (없으면 달비님께 요청)
- Anthropic Claude: 기존 설정 사용 (환경변수 ANTHROPIC_API_KEY)
## 바이럴 구간 선정 기준 (Claude 프롬프트 기반)
1. **감정 강도**: 놀람, 공감, 궁금증, 웃음 유발 구간
2. **정보 밀도**: 구체적 숫자/금액/방법이 나오는 구간
3. **완결성**: 클립 단독으로 이해 가능한 구간
4. **훅 가능성**: 첫 3초 안에 시청자를 잡을 수 있는 문장 포함 여부
5. **한국어 최적화**: 한국 시청자 반응 패턴 반영
## 무음/숨소리 처리
Clawitzer editor.py 로직 차용:
- silenceremove 필터 (max_gap_sec=0.06)
- 완전 제거가 아닌 압축 (자연스러운 흐름 유지)
- 숨소리 구간: 50ms로 압축
## 자막 스타일 (Clawitzer 3색 룰)
- 기본: 흰색(#FFFFFF), 52px
- 빨강 앞뒤: 노란색(#FFFF00), 52px
- 강조 키워드: 빨강(#FF0000), 62px (연속 불가)
- 폰트: SUIT ExtraBold (없으면 NotoSansCJK fallback)
- 자간: -1 (좁힘)
- 위치: 화면 중앙 (an5, Y=900/1920)
FILE:README.md
# YouTube Clipper KO
**한국어 YouTube 영상 → 바이럴 숏폼 클립 자동 분할**
알파컷 써보고 맘에 안 들어서 직접 만들었습니다.
---
## 왜 만들었나
알파컷/피카클립은 "몇 개 뽑아서 점수 붙여준다"는 건데, 정작 **왜 이 클립을 올려야 하는지**는 안 알려줘요. 점수 숫자만 보고 고르다 보면 결국 감에 의존하게 됨.
그래서 3단계 AI 검증으로 각 클립에 이런 걸 붙여줍니다:
- 왜 이 구간이 바이럴 가능성이 있는지 (구체적 이유)
- 첫 3초 훅 문장이 뭔지
- 어떤 감정을 유발하는지 (공감/놀람/궁금증/웃음/정보)
- 올릴 때 주의할 약점
유저가 직접 보고 고르는 구조예요. 알고리즘이 대신 결정하지 않습니다.
---
## 특징
- **Haiku 3중 검증** — 1차(후보 20개 넓게) → 2차(독립 재평가, 완결성 미달 탈락) → 3차(메타데이터 보강, 시간순 정렬)
- **Whisper word-level 타임스탬프** — 자막 싱크 정확도 최고
- **3색 한국어 자막** — SUIT ExtraBold, 강조 키워드 자동 빨강/노랑 처리
- **9:16 자동 크롭** — 쇼츠/릴스/틱톡 바로 올릴 수 있는 사이즈
- **원본 음성 유지** — 클리퍼니까 TTS 없음, 무음 압축 없음
- **로컬 실행** — 영상 외부 서버에 올라가지 않음
---
## 설치
```bash
npx skills add thedalbee/youtube-clipper-ko -g -y
```
또는 직접:
```bash
git clone https://github.com/thedalbee/youtube-clipper-ko
pip install yt-dlp openai anthropic
```
필요한 것:
- ffmpeg (`apt install ffmpeg` / `brew install ffmpeg`)
- SUIT ExtraBold 폰트 (없으면 NotoSansCJK로 자동 대체)
---
## 사용법
```bash
# YouTube URL
python3 scripts/clip.py --url "https://youtu.be/VIDEO_ID"
# 구간 지정 (긴 영상)
python3 scripts/clip.py --url "https://youtu.be/VIDEO_ID" --start "10:00" --end "40:00"
# 클립 길이 조정 (기본 60초)
python3 scripts/clip.py --url "https://youtu.be/VIDEO_ID" --clip-length 90
# 자막만 먼저 확인하고 싶을 때
python3 scripts/clip.py --url "https://youtu.be/VIDEO_ID" --dry
# 로컬 파일
python3 scripts/clip.py --file "/path/to/video.mp4"
```
환경변수:
```bash
export OPENAI_API_KEY="sk-..." # Whisper 자막 추출
export ANTHROPIC_API_KEY="sk-..." # Claude Haiku 구간 선정
```
---
## 출력 예시
```
outputs/20260315_225000/
├── source.mp4 # 원본
├── transcript.json # Whisper 전체 자막 + 타임스탬프
├── viral_segments.json # 구간 분석 결과 (시간순)
├── result.json # 전체 메타데이터
└── clips/
├── clip_01_월200만원자동화.mp4
├── clip_02_AI로3일만에만든것.mp4
└── ...
```
`viral_segments.json` 안에 이런 게 들어있어요:
```json
[
{
"start_sec": 194.5,
"end_sec": 248.0,
"score": 82,
"title": "월 200만원 자동화하는 법",
"hook": "근데 진짜 이게 되거든요",
"emotion_type": "궁금증",
"reason": "구체적인 금액과 방법이 동시에 나옵니다. 첫 3초 안에 결론을 먼저 던지는 구조라 이탈률이 낮을 것. '이게 되거든요'라는 훅이 다음 내용을 궁금하게 만듦.",
"weakness": "앞 맥락 모르면 '뭘 자동화한다는 건지' 불분명할 수 있음."
}
]
```
---
## Haiku 3중 검증이 뭔가요
일반 AI 클리퍼는 한 번 판단하고 끝이에요. 이건 다릅니다.
**1차 (후보 추출)** — 자막 전체 보고 가능성 있는 구간 20개 넓게 뽑음. 이 단계에선 엄격하게 안 자름.
**2차 (독립 재평가)** — 1차 결과를 모르는 척하고 다시 평가. 이 단계에서 탈락 기준 엄격하게 적용:
- 앞 맥락 없이는 이해 불가 → 탈락
- 너무 일반적이어서 차별점 없음 → 탈락
- 단순 나열에 불과 → 탈락
**3차 (메타데이터 보강)** — 살아남은 것들에 훅/리즌/감정타입/약점 붙여서 유저가 고를 수 있게 정리. 랭킹 매기지 않음. 시간순으로 나열.
---
## 비교
| | 알파컷 | 피카클립 | YouTube Clipper KO |
|---|---|---|---|
| 바이럴 이유 설명 | ✗ | 간단히 | 3~4문장 구체적 |
| 약점 명시 | ✗ | ✗ | ✅ |
| 훅 문장 추출 | ✗ | ✗ | ✅ |
| 감정 타입 분류 | ✗ | ✗ | ✅ |
| AI 검증 단계 | 1회 | 1회 | 3회 |
| 유저가 고르는 구조 | ✗ (알고리즘 순서) | ✗ | ✅ (시간순 나열) |
| 로컬 실행 | ✗ | ✗ | ✅ |
| 오픈소스 | ✗ | ✗ | ✅ |
| 비용 | 월 5,800원~ | 월 5,500원~ | Whisper ~7원/분 + Haiku 소액 |
---
## 비용
영상 30분 기준 대략:
- Whisper: ~210원 (30분 × 7원/분)
- Claude Haiku 3회: ~50원
- **합계: 약 260원**
알파컷 월 5,800원이면 22회 사용 가능한 금액이에요.
---
## Clawitzer 연결 (선택)
[Clawitzer](https://github.com/thedalbee/clawitzer)로 추출한 클립에 한국어 자막/배경음악/편집 효과를 추가할 수 있어요.
```bash
python3 projects/clawitzer/main.py \
--video "skills/youtube-clipper-ko/outputs/TIMESTAMP/clips/clip_01.mp4"
```
---
## 만든 사람
달비 ([@thedalbee](https://x.com/thedalbee))
YouTube 채널 "기획자 달비"에서 이 툴 만드는 과정을 공개하고 있습니다.
→ [youtube.com/@기획자달비](https://youtube.com/@기획자달비)
---
## 라이선스
MIT
FILE:scripts/clip.py
#!/usr/bin/env python3
"""
clip.py — YouTube Clipper KO 메인 스크립트
YouTube URL / 로컬 파일 → 바이럴 클립 자동 분할
Usage:
python3 clip.py --url "https://youtu.be/VIDEO_ID"
python3 clip.py --file "/path/to/video.mp4"
python3 clip.py --url "..." --start "10:00" --end "30:00" # 구간 지정
python3 clip.py --url "..." --clip-length 60 # 클립 최대 길이(초)
python3 clip.py --url "..." --dry # 자막 추출만 (클립 생성 X)
"""
import os, sys, json, argparse, subprocess, tempfile, re
from datetime import datetime, timezone
from pathlib import Path
# ── 경로 설정 ─────────────────────────────────────────
SKILL_DIR = Path(__file__).parent.parent
OUTPUT_DIR = SKILL_DIR / "outputs"
# Clawitzer 편집 모듈 재사용
CLAWITZER_DIR = Path(__file__).parent.parent.parent.parent / "projects" / "clawitzer"
if CLAWITZER_DIR.exists():
sys.path.insert(0, str(CLAWITZER_DIR))
# ── 폰트 설정 ─────────────────────────────────────────
SUIT_FONT = "/usr/share/fonts/truetype/SUIT-ExtraBold.ttf"
FALLBACK_FONT = "/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc"
# ── 자막 색상 (Clawitzer 3색 룰) ─────────────────────
EMPHASIS_KEYWORDS = [
"진짜", "완전", "대박", "충격", "헐", "미쳤", "놀랐", "말도 안",
"실화", "이게 뭐야", "어떡해", "깜짝", "세상에", "진심", "실화냐",
"미친", "개쩐", "레전드", "이상한", "생겼어", "없다는", "안 한다",
"있죠", "거 있죠", "진짜로", "솔직히", "사실은", "결론은",
]
OUT_W, OUT_H = 1080, 1920
LETTER_SPACING = -1
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 1. 환경 검사
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def check_env():
"""필수 도구 존재 여부 확인"""
errors = []
# yt-dlp
r = subprocess.run(["yt-dlp", "--version"], capture_output=True)
if r.returncode != 0:
errors.append("yt-dlp 없음: pip install yt-dlp")
# ffmpeg
r = subprocess.run(["ffmpeg", "-version"], capture_output=True)
if r.returncode != 0:
errors.append("ffmpeg 없음: apt install ffmpeg")
# libass (자막 burn-in에 필요)
r = subprocess.run(["ffmpeg", "-filters"], capture_output=True, text=True)
if "subtitles" not in r.stderr:
print(" ⚠ ffmpeg libass 없음 — 자막 burn-in 불가 (SRT로 대체)")
# python 패키지
try:
import openai
except ImportError:
errors.append("openai 없음: pip install openai")
try:
import anthropic
except ImportError:
errors.append("anthropic 없음: pip install anthropic")
# 폰트
font = get_font()
print(f" 폰트: {font}")
if errors:
for e in errors:
print(f" ✗ {e}")
sys.exit(1)
print(" ✅ 환경 OK")
def get_font() -> str:
for p in [SUIT_FONT, FALLBACK_FONT]:
if os.path.exists(p):
return p
# 시스템 폰트 검색
r = subprocess.run(["fc-list", ":lang=ko"], capture_output=True, text=True)
for line in r.stdout.splitlines():
if ".ttf" in line or ".ttc" in line:
return line.split(":")[0].strip()
return ""
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 2. 영상 다운로드
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def download_video(url: str, out_dir: Path, start: str = None, end: str = None) -> str:
"""YouTube URL → 로컬 MP4 다운로드"""
print(f"\n[1] 영상 다운로드: {url}")
out_path = str(out_dir / "source.%(ext)s")
cmd = [
"yt-dlp",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best",
"--merge-output-format", "mp4",
"-o", out_path,
"--no-playlist",
url,
]
# 구간 지정 시 (yt-dlp --download-sections)
if start or end:
s = start or "0"
e = end or "99:59:59"
cmd += ["--download-sections", f"*{s}-{e}"]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(" ✗ 다운로드 실패:\n", r.stderr[-500:])
raise RuntimeError("yt-dlp 다운로드 실패")
# 실제 파일 경로 찾기
mp4_files = list(out_dir.glob("source.mp4"))
if not mp4_files:
mp4_files = list(out_dir.glob("source.*"))
if not mp4_files:
raise FileNotFoundError("다운로드된 파일 없음")
path = str(mp4_files[0])
size_mb = os.path.getsize(path) / 1024 / 1024
print(f" 완료: {path} ({size_mb:.1f}MB)")
return path
def get_video_duration(path: str) -> float:
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", path]
r = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(r.stdout)
return float(data["format"]["duration"])
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 3. Whisper 자막 추출
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def extract_transcript(video_path: str, out_dir: Path, lang: str = "ko") -> dict:
"""
Whisper API로 자막 + word-level 타임스탬프 추출
OpenAI API 키 필요 (환경변수 OPENAI_API_KEY)
"""
print(f"\n[2] 자막 추출 중 (Whisper)...")
# 오디오 추출 (MP3로 변환, Whisper 25MB 제한 대응)
audio_path = str(out_dir / "audio.mp3")
subprocess.run([
"ffmpeg", "-y", "-i", video_path,
"-q:a", "0", "-map", "a",
"-b:a", "64k", # 낮은 비트레이트로 용량 절약
audio_path,
], check=True, capture_output=True)
size_mb = os.path.getsize(audio_path) / 1024 / 1024
print(f" 오디오 추출: {size_mb:.1f}MB")
# 파일 크기 체크 (25MB 초과 시 분할)
if size_mb > 24:
print(f" ⚠ 25MB 초과 ({size_mb:.1f}MB) → 분할 처리")
return extract_transcript_chunked(audio_path, out_dir, lang)
# Whisper API 호출
import openai
client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
with open(audio_path, "rb") as f:
response = client.audio.transcriptions.create(
model="whisper-1",
file=f,
language=lang,
response_format="verbose_json",
timestamp_granularities=["segment", "word"],
)
transcript = {
"text": response.text,
"language": response.language,
"duration": response.duration,
"segments": [
{
"id": s.id,
"start": s.start,
"end": s.end,
"text": s.text.strip(),
}
for s in response.segments
],
"words": [
{
"word": w.word,
"start": w.start,
"end": w.end,
}
for w in (response.words or [])
],
}
# 저장
transcript_path = out_dir / "transcript.json"
with open(transcript_path, "w", encoding="utf-8") as f:
json.dump(transcript, f, ensure_ascii=False, indent=2)
print(f" 자막 {len(transcript['segments'])}개 세그먼트, {len(transcript['words'])}개 단어")
print(f" 저장: {transcript_path}")
return transcript
def extract_transcript_chunked(audio_path: str, out_dir: Path, lang: str) -> dict:
"""25MB 초과 오디오 분할 처리"""
import openai
client = openai.OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
# 10분 단위로 분할
duration = get_video_duration(audio_path)
chunk_sec = 600
chunks = []
offset = 0.0
while offset < duration:
chunk_path = str(out_dir / f"audio_chunk_{int(offset)}.mp3")
subprocess.run([
"ffmpeg", "-y", "-i", audio_path,
"-ss", str(offset), "-t", str(chunk_sec),
"-b:a", "64k", chunk_path,
], check=True, capture_output=True)
with open(chunk_path, "rb") as f:
resp = client.audio.transcriptions.create(
model="whisper-1",
file=f,
language=lang,
response_format="verbose_json",
timestamp_granularities=["segment"],
)
for s in resp.segments:
chunks.append({
"start": s.start + offset,
"end": s.end + offset,
"text": s.text.strip(),
})
os.remove(chunk_path)
offset += chunk_sec
transcript = {
"text": " ".join(c["text"] for c in chunks),
"language": lang,
"duration": duration,
"segments": [{"id": i, **c} for i, c in enumerate(chunks)],
"words": [],
}
transcript_path = out_dir / "transcript.json"
with open(transcript_path, "w", encoding="utf-8") as f:
json.dump(transcript, f, ensure_ascii=False, indent=2)
return transcript
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 4. 바이럴 구간 선정 (Claude)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _call_haiku(client, messages: list, max_tokens: int = 2000) -> str:
"""Haiku 호출 + JSON 코드블록 제거"""
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=max_tokens,
messages=messages,
)
raw = response.content[0].text.strip()
raw = re.sub(r"```json?\n?", "", raw).replace("```", "").strip()
return raw
def _parse_json(raw: str, fallback=None):
try:
return json.loads(raw)
except json.JSONDecodeError:
print(f" ⚠ JSON 파싱 실패:\n{raw[:200]}")
return fallback if fallback is not None else []
def select_viral_segments(
transcript: dict,
clip_length: int = 60,
n_clips: int = 10,
) -> list[dict]:
"""
Haiku 3중 검증으로 바이럴 구간 선정
1차: 후보 20개 넓게 추출
2차: 독립 재평가 + 엄격한 탈락 기준 적용
3차: 최종 랭킹 + 리즌 보강 → 상위 n_clips개 반환
"""
import anthropic
client = anthropic.Anthropic()
segments_text = "\n".join(
f"[{fmt_time(s['start'])} → {fmt_time(s['end'])}] {s['text']}"
for s in transcript["segments"]
)
total_duration = transcript.get("duration", 0)
# ── 1차: 후보 20개 넓게 뽑기 ─────────────────────────
print(f"\n[3-1] 1차 후보 추출 (Haiku)...")
prompt_1 = f"""다음은 YouTube 영상 전체 자막입니다. 총 길이: {fmt_time(total_duration)}
자막:
{segments_text}
---
숏폼(쇼츠/릴스/틱톡) 제작용으로 바이럴 가능성이 있는 구간 후보 20개를 뽑아주세요.
이 단계에서는 엄격하게 필터링하지 말고 가능성 있는 것들을 넓게 포함하세요.
조건:
- 각 클립 길이: {clip_length}초 이내 (최소 20초)
- 클립 겹치지 않게 할 것
JSON 형식으로만 답하세요:
[
{{
"id": 1,
"start_sec": 194.5,
"end_sec": 248.0,
"text_preview": "해당 구간 핵심 문장 1~2개",
"candidate_reason": "후보로 뽑은 이유 (1문장)"
}},
...
]"""
raw1 = _call_haiku(client, [{"role": "user", "content": prompt_1}], max_tokens=3000)
candidates = _parse_json(raw1, fallback=[])
print(f" 후보 {len(candidates)}개 추출")
if not candidates:
return []
# ── 2차: 독립 재평가 + 엄격한 탈락 ─────────────────────
print(f"\n[3-2] 2차 독립 재평가 (Haiku)...")
candidates_json = json.dumps(candidates, ensure_ascii=False, indent=2)
prompt_2 = f"""당신은 한국 숏폼 콘텐츠 전문 편집자입니다.
아래는 YouTube 영상에서 바이럴 후보로 뽑힌 구간 목록입니다.
각 구간을 독립적으로 엄격하게 평가해주세요.
평가 기준 (0~100점):
- 완결성: 클립 단독으로 이해 가능한가? (필수, 미달 시 탈락)
- 훅: 첫 3초 안에 시청자를 잡을 수 있는가?
- 감정: 공감/놀람/궁금증/웃음 중 하나 이상 유발하는가?
- 구체성: 숫자/금액/방법 등 구체적 정보가 있는가?
- 한국 시청자 반응: 한국 커뮤니티 정서에 맞는가?
탈락 기준 (하나라도 해당하면 탈락):
- 중간 맥락 없이는 이해 불가
- 너무 일반적이어서 차별점 없음
- 내용이 단순 나열에 불과
후보 목록:
{candidates_json}
JSON 형식으로만 답하세요:
[
{{
"id": 1,
"score": 82,
"pass": true,
"weakness": "가장 큰 약점 (1문장)"
}},
...
]
pass=false인 항목도 반드시 포함하세요."""
raw2 = _call_haiku(client, [{"role": "user", "content": prompt_2}], max_tokens=2000)
evaluations = _parse_json(raw2, fallback=[])
# 평가 결과 매핑
eval_map = {e["id"]: e for e in evaluations if isinstance(e, dict)}
passed = [
c for c in candidates
if eval_map.get(c["id"], {}).get("pass", False)
]
# 점수 붙이기
for c in passed:
c["score_2nd"] = eval_map.get(c["id"], {}).get("score", 50)
c["weakness"] = eval_map.get(c["id"], {}).get("weakness", "")
# 점수 기준 정렬, 상위 n_clips*1.5개만 3차로
passed.sort(key=lambda x: x.get("score_2nd", 0), reverse=True)
passed = passed[:int(n_clips * 1.5)]
print(f" 통과: {len(passed)}개 (탈락: {len(candidates) - len(passed)}개)")
if not passed:
return []
# ── 3차: 최종 랭킹 + 리즌 보강 ─────────────────────────
print(f"\n[3-3] 3차 최종 선정 (Haiku)...")
passed_json = json.dumps(passed, ensure_ascii=False, indent=2)
prompt_3 = f"""당신은 한국 숏폼 콘텐츠 편집자입니다.
아래는 2차 검증을 통과한 클립 후보들입니다.
각 클립에 대해 유저가 어떤 클립을 올릴지 스스로 판단할 수 있도록 메타데이터를 보강해주세요.
목표: 순위 매기기가 아니라, 각 클립의 특성을 명확하게 설명해서 유저가 고를 수 있게 하는 것.
순서는 영상 시간 순서(start_sec 오름차순)로 유지하세요.
통과 후보:
{passed_json}
JSON 형식으로만 답하세요:
[
{{
"start_sec": 194.5,
"end_sec": 248.0,
"score": 82,
"title": "클립 제목 (15자 이내, 한국어)",
"hook": "첫 3초에 나오는 훅 문장 (자막 원문 그대로)",
"emotion_type": "공감|놀람|궁금증|웃음|정보 중 하나",
"reason": "이 클립이 왜 바이럴 가능성이 있는지 (2~3문장, 구체적으로)",
"weakness": "이 클립의 잠재적 약점 또는 올릴 때 주의할 점 (1문장)"
}},
...
]
통과한 후보 전부 반환. 탈락 없음."""
raw3 = _call_haiku(client, [{"role": "user", "content": prompt_3}], max_tokens=3000)
final = _parse_json(raw3, fallback=[])
# start_sec/end_sec 없으면 candidates에서 보완
id_to_candidate = {c["id"]: c for c in candidates}
for item in final:
if "start_sec" not in item or "end_sec" not in item:
# text_preview로 후보 찾기 시도
for c in passed:
if c.get("text_preview", "") in item.get("hook", ""):
item["start_sec"] = c["start_sec"]
item["end_sec"] = c["end_sec"]
break
# 유효한 항목만 (start_sec, end_sec 있는 것) + 시간순 정렬
final = [f for f in final if "start_sec" in f and "end_sec" in f]
final.sort(key=lambda x: x["start_sec"])
print(f" 메타데이터 보강 완료: {len(final)}개")
for s in final[:3]:
print(f" [{s['score']}점/{s.get('emotion_type','?')}] {s['title']} "
f"({fmt_time(s['start_sec'])}~{fmt_time(s['end_sec'])})")
print(f" 훅: {s.get('hook', '-')[:40]}")
return final
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 5. 클립 추출 + 자막 burn-in
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def fmt_time(sec: float) -> str:
"""초 → MM:SS 또는 HH:MM:SS"""
sec = float(sec)
h = int(sec // 3600)
m = int((sec % 3600) // 60)
s = sec % 60
if h > 0:
return f"{h}:{m:02d}:{s:05.2f}"
return f"{m}:{s:05.2f}"
def fmt_ass_time(sec: float) -> str:
"""초 → ASS 타임코드 (H:MM:SS.cc)"""
h = int(sec // 3600)
m = int((sec % 3600) // 60)
s = sec % 60
cs = int((s % 1) * 100)
return f"{h}:{m:02d}:{int(s):02d}.{cs:02d}"
def assign_subtitle_colors(sentences: list[dict]) -> list[dict]:
"""
Clawitzer 3색 룰 적용
- 강조 키워드 포함 → 빨강(#FF0000), 62px
- 빨강 앞뒤 → 노랑(#FFFF00), 52px
- 나머지 → 흰색(#FFFFFF), 52px
- 빨강 연속 불가
- 빨강 하나도 없으면 첫 문장 빨강
"""
n = len(sentences)
result = [dict(s) for s in sentences]
is_red = [False] * n
for i, s in enumerate(result):
text = s.get("text", "")
if any(kw in text for kw in EMPHASIS_KEYWORDS):
is_red[i] = True
# 연속 빨강 방지
for i in range(1, n):
if is_red[i] and is_red[i-1]:
is_red[i] = False
# 빨강 없으면 첫 문장
if not any(is_red):
is_red[0] = True
# 앞뒤 노랑
is_yellow = [False] * n
for i in range(n):
if is_red[i]:
if i > 0 and not is_red[i-1]:
is_yellow[i-1] = True
if i < n-1 and not is_red[i+1]:
is_yellow[i+1] = True
for i, s in enumerate(result):
if is_red[i]:
result[i]["color"] = "#FF0000"
result[i]["size"] = 62
elif is_yellow[i]:
result[i]["color"] = "#FFFF00"
result[i]["size"] = 52
else:
result[i]["color"] = "#FFFFFF"
result[i]["size"] = 52
return result
def hex_to_ass(hex_color: str) -> str:
"""#RRGGBB → &H00BBGGRR"""
h = hex_color.lstrip("#")
r, g, b = h[0:2], h[2:4], h[4:6]
return f"&H00{b}{g}{r}"
def make_ass_subtitle(sentences: list[dict], offset_sec: float, font_path: str) -> str:
"""세그먼트 목록 → ASS 자막 문자열"""
font_name = os.path.basename(font_path).replace(".ttf", "").replace(".ttc", "")
header = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {OUT_W}
PlayResY: {OUT_H}
WrapStyle: 1
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,{font_name},52,&H00FFFFFF,&H000000FF,&H00000000,&HAA000000,1,0,0,0,100,100,{LETTER_SPACING},0,3,8,0,5,30,30,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
lines = [header]
for s in sentences:
start = s["start"] - offset_sec
end = s["end"] - offset_sec
if start < 0: start = 0
color = hex_to_ass(s.get("color", "#FFFFFF"))
size = s.get("size", 52)
text = s["text"].replace("{", "\\{").replace("}", "\\}")
styled = f"{{\\an5\\pos({OUT_W//2},900)\\c{color}\\fs{size}}}{text}"
lines.append(f"Dialogue: 0,{fmt_ass_time(start)},{fmt_ass_time(end)},Default,,0,0,0,,{styled}")
return "\n".join(lines) + "\n"
def extract_clip(
source_video: str,
segment: dict,
transcript_segments: list,
out_dir: Path,
font_path: str,
index: int,
) -> str:
"""단일 구간 → 9:16 클립 MP4 추출"""
start_sec = segment["start_sec"]
end_sec = segment["end_sec"]
duration = end_sec - start_sec
title_safe = re.sub(r"[^\w가-힣]", "_", segment.get("title", f"clip_{index:02d}"))[:30]
out_filename = f"clip_{index:02d}_{title_safe}.mp4"
out_path = str(out_dir / out_filename)
tmpdir = tempfile.mkdtemp(prefix="clipper_")
# 해당 구간 자막 필터링
clip_segs = [
s for s in transcript_segments
if s["end"] > start_sec and s["start"] < end_sec
]
# 자막 색상/크기 배정
sentences = assign_subtitle_colors([
{
"text": s["text"],
"start": s["start"],
"end": min(s["end"], end_sec),
}
for s in clip_segs
])
# ASS 자막 파일 생성
ass_content = make_ass_subtitle(sentences, offset_sec=start_sec, font_path=font_path)
ass_path = os.path.join(tmpdir, "sub.ass")
with open(ass_path, "w", encoding="utf-8") as f:
f.write(ass_content)
# ffmpeg 클립 추출 + 9:16 변환 + 자막 burn-in + 무음 압축
vf = (
f"scale={OUT_W}:{OUT_H}:force_original_aspect_ratio=increase,"
f"crop={OUT_W}:{OUT_H},"
f"ass={ass_path}"
)
# 영상 + 오디오 한번에 (원본 음성 유지, 무음 압축 없음)
subprocess.run([
"ffmpeg", "-y",
"-ss", str(start_sec), "-t", str(duration),
"-i", source_video,
"-vf", vf,
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "128k",
"-ar", "44100",
out_path,
], check=True, capture_output=True)
size_mb = os.path.getsize(out_path) / 1024 / 1024
print(f" [{index:02d}] {out_filename} ({duration:.0f}초, {size_mb:.1f}MB)")
# 임시 파일 정리
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
return out_path
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 메인
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def main():
parser = argparse.ArgumentParser(description="YouTube Clipper KO")
parser.add_argument("--url", help="YouTube URL")
parser.add_argument("--file", help="로컬 영상 파일 경로")
parser.add_argument("--start", help="시작 시간 (MM:SS 또는 HH:MM:SS)")
parser.add_argument("--end", help="종료 시간 (MM:SS 또는 HH:MM:SS)")
parser.add_argument("--clip-length", type=int, default=60, help="클립 최대 길이(초) [기본: 60]")
parser.add_argument("--n-clips", type=int, default=10, help="추출할 클립 수 [기본: 10]")
parser.add_argument("--lang", default="ko", help="자막 언어 [기본: ko]")
parser.add_argument("--dry", action="store_true", help="자막 추출만 (클립 미생성)")
parser.add_argument("--no-check", action="store_true", help="환경 검사 스킵")
args = parser.parse_args()
if not args.url and not args.file:
parser.error("--url 또는 --file 필수")
# 환경 검사
if not args.no_check:
print("\n[0] 환경 검사...")
check_env()
# 출력 폴더
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
out_dir = OUTPUT_DIR / ts
out_dir.mkdir(parents=True, exist_ok=True)
clips_dir = out_dir / "clips"
clips_dir.mkdir(exist_ok=True)
print(f"\n출력 폴더: {out_dir}")
# 폰트
font = get_font()
if not font:
print(" ⚠ 한국어 폰트 없음 — 자막 품질 저하될 수 있음")
# 영상 준비
if args.url:
video_path = download_video(args.url, out_dir, args.start, args.end)
else:
video_path = args.file
print(f"\n[1] 로컬 파일: {video_path}")
# 자막 추출
transcript = extract_transcript(video_path, out_dir, args.lang)
if args.dry:
print(f"\n[dry] 자막 추출 완료. 클립 생성 스킵.")
print(f" 결과: {out_dir / 'transcript.json'}")
return
# 바이럴 구간 선정
segments = select_viral_segments(transcript, args.clip_length, args.n_clips)
if not segments:
print(" ✗ 바이럴 구간 선정 실패")
return
# 결과 저장
viral_path = out_dir / "viral_segments.json"
with open(viral_path, "w", encoding="utf-8") as f:
json.dump(segments, f, ensure_ascii=False, indent=2)
# 클립 추출
print(f"\n[4] 클립 추출 중 ({len(segments)}개)...")
results = []
for i, seg in enumerate(segments, 1):
try:
clip_path = extract_clip(
source_video=video_path,
segment=seg,
transcript_segments=transcript["segments"],
out_dir=clips_dir,
font_path=font,
index=i,
)
seg["clip_file"] = str(Path(clip_path).relative_to(out_dir))
results.append(seg)
except Exception as e:
print(f" ✗ clip_{i:02d} 실패: {e}")
# 최종 결과
result = {
"source": video_path,
"url": args.url or "",
"clips": results,
"output_dir": str(out_dir),
"created_at": ts,
}
result_path = out_dir / "result.json"
with open(result_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\n{'='*55}")
print(f"✅ 완료! {len(results)}개 클립 생성")
print(f" 출력: {out_dir}")
print(f" 결과: {result_path}")
print(f"\n상위 3개:")
for s in results[:3]:
print(f" [{s['score']}점] {s['title']}")
print(f" {s['reason'][:60]}...")
print(f"{'='*55}")
if __name__ == "__main__":
main()