Skill: mupengi-bot/korean-scraper (version 1.0.0)

--- name: korean-scraper description: Korean website specialized scraper with anti-bot protection (Naver, Coupang, Daum, Instagram) version: 1.0.0 author: 무펭이 🐧 --- # korean-scraper **한국 웹사이트 전문 스크래퍼** — Playwright 기반으로 네이버, 쿠팡, 다음 등 한국 주요 사이트에서 구조화된 데이터를 추출합니다. Anti-bot 보호 우회 기능 포함. ## When to Use - 네이버 블로그 검색 결과 수집 또는 특정 블로그 본문 추출 - 네이버 카페 인기글/최신글 스크래핑 - 쿠팡 상품 정보 (가격, 리뷰, 별점) 수집 - 네이버 뉴스/다음 뉴스 기사 본문 추출 - 한국 사이트 대상 자동화된 데이터 수집 ## Installation ```bash cd skills/korean-scraper npm install npx playwright install chromium ``` ## Quick Start ### 네이버 블로그 ```bash # 검색 결과 수집 node scripts/naver-blog.js search "맛집 추천" --limit 10 # 특정 블로그 본문 추출 node scripts/naver-blog.js extract "https://blog.naver.com/..." ``` ### 네이버 카페 ```bash # 인기글 수집 node scripts/naver-cafe.js popular "카페URL" --limit 20 # 최신글 수집 node scripts/naver-cafe.js recent "카페URL" --limit 20 ``` ### 쿠팡 상품 ```bash # 상품 정보 추출 node scripts/coupang.js product "상품URL" # 검색 결과 수집 node scripts/coupang.js search "무선 이어폰" --limit 20 ``` ### 네이버 뉴스 ```bash # 검색 결과 수집 node scripts/naver-news.js search "AI" --limit 10 # 기사 본문 추출 node scripts/naver-news.js extract "https://n.news.naver.com/..." ``` ### 다음 뉴스 ```bash # 검색 결과 수집 node scripts/daum-news.js search "경제" --limit 10 # 기사 본문 추출 node scripts/daum-news.js extract "https://v.daum.net/..." ``` ## Output Format 모든 스크립트는 구조화된 JSON을 반환합니다: ### 네이버 블로그 검색 ```json { "status": "success", "query": "맛집 추천", "count": 10, "results": [ { "title": "서울 강남 맛집 추천 BEST 5", "url": "https://blog.naver.com/...", "blogger": "맛집탐험가", "date": "2026-02-15", "snippet": "강남역 근처 숨은 맛집들을..." } ] } ``` ### 네이버 블로그 본문 ```json { "status": "success", "url": "https://blog.naver.com/...", "title": "서울 강남 맛집 추천 BEST 5", "author": "맛집탐험가", "date": "2026-02-15", "content": "# 서울 강남 맛집 추천 BEST 5\n\n1. ...", "images": ["https://..."], "tags": ["맛집", "강남", "서울"] } ``` ### 쿠팡 상품 ```json { "status": "success", "url": "https://www.coupang.com/...", "productName": "애플 에어팟 프로 2세대", "price": 299000, "originalPrice": 359000, "discount": "17%", "rating": 4.8, "reviewCount": 1523, "rocketDelivery": true, "seller": "쿠팡", "images": ["https://..."] } ``` ### 네이버 카페 ```json { "status": "success", "cafeUrl": "https://cafe.naver.com/...", "type": "popular", "count": 20, "posts": [ { "title": "신입 회원 인사드립니다", "url": "https://cafe.naver.com/.../12345", "author": "닉네임", "date": "2026-02-17", "views": 523, "comments": 12 } ] } ``` ### 뉴스 기사 ```json { "status": "success", "url": "https://n.news.naver.com/...", "title": "AI 시장 규모 급성장 전망", "media": "조선일보", "author": "홍길동 기자", "date": "2026-02-17 09:30", "content": "# AI 시장 규모 급성장 전망\n\n...", "category": "IT/과학", "images": ["https://..."] } ``` ## Anti-Bot Features - **navigator.webdriver 숨김** — 자동화 탐지 회피 - **실제 User-Agent 사용** — 모바일/데스크탑 랜덤 - **인간 행동 모방** — 랜덤 딜레이, 스크롤 - **Stealth Plugin** — Playwright extra stealth - **Cloudflare 우회** — 대기 시간 자동 조정 ## Rate Limiting 모든 스크립트는 기본적으로 사이트를 보호합니다: - 요청당 2-5초 랜덤 딜레이 - 동일 도메인 1초당 최대 1회 요청 - 429 응답 시 자동 백오프 - `--fast` 플래그로 딜레이 축소 가능 (주의) ## Error Handling | 상황 | 동작 | |------|------| | 404 | JSON으로 에러 반환, 계속 진행 | | 403/차단 | 재시도 (최대 3회) | | 타임아웃 | 대기 시간 연장 후 재시도 | | 로그인 필요 | 경고 메시지 + 가능한 데이터만 반환 | ## Environment Variables ```bash # Headless 모드 끄기 (디버깅용) HEADLESS=false node scripts/naver-blog.js ... # 스크린샷 저장 SCREENSHOT=true node scripts/coupang.js ... # 대기 시간 조정 (ms) WAIT_TIME=10000 node scripts/naver-cafe.js ... # User-Agent 커스텀 USER_AGENT="..." node scripts/naver-news.js ... ``` ## Integration Examples ### OpenClaw Agent 통합 ```javascript // 네이버 블로그 검색 const result = await exec({ command: 'node scripts/naver-blog.js search "AI 트렌드" --limit 5', workdir: '/path/to/skills/korean-scraper' }); const data = JSON.parse(result.stdout); ``` ### Batch Processing ```bash # 여러 URL 일괄 처리 cat urls.txt | while read url; do node scripts/naver-blog.js extract "$url" >> results.jsonl done ``` ## Limitations - **로그인 필요 콘텐츠**: 현재 비로그인 상태로만 스크래핑 (쿠팡 일부 리뷰 등) - **동적 로딩**: 무한 스크롤은 기본 10개까지만 (--scroll 플래그로 확장 가능) - **CAPTCHA**: 수동 우회 필요 (자동화 불가) - **IP 차단**: 과도한 요청 시 일시적 차단 가능 (rate limiting 준수 필요) ## Compliance & Ethics - ✅ 공개된 정보만 수집 - ✅ robots.txt 준수 (기본값) - ✅ Rate limiting으로 서버 부하 최소화 - ❌ 개인정보 수집 금지 - ❌ 로그인 필요 콘텐츠 무단 접근 금지 - ❌ 저작권 침해 목적 사용 금지 ## Troubleshooting ### 문제: 403 Forbidden **해결책**: 1. User-Agent 변경 시도 2. 대기 시간 늘리기 (`WAIT_TIME=15000`) 3. Headless 모드 끄기 (`HEADLESS=false`) ### 문제: 빈 결과 반환 **해결책**: 1. URL 형식 확인 2. 사이트 구조 변경 가능성 (셀렉터 업데이트 필요) 3. 로그인 필요 여부 확인 ### 문제: Timeout **해결책**: 1. `WAIT_TIME` 늘리기 2. 인터넷 연결 확인 3. 사이트 접근 가능 여부 확인 (VPN 필요 등) ## Maintenance 한국 사이트들은 UI를 자주 변경하므로, 셀렉터 업데이트가 필요할 수 있습니다. 셀렉터 위치: `scripts/` 내 각 파일 상단 `SELECTORS` 객체 ```javascript const SELECTORS = { blogTitle: '.se-title-text', blogContent: '.se-main-container', // ... }; ``` ## Future Improvements - [ ] 인스타그램 게시물 스크래핑 - [ ] 네이버 쇼핑 가격 비교 - [ ] 유튜브 한국 채널 메타데이터 - [ ] 배치 처리 최적화 (병렬 실행) - [ ] 쿠키/세션 관리 (로그인 유지) - [ ] Proxy 지원 ## References - [Playwright Official Docs](https://playwright.dev/) - [playwright-extra-plugin-stealth](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth) - [네이버 개발자 센터](https://developers.naver.com/)

path: lib/common.js
content: /**
 * common.js - Korean Scraper 공통 유틸리티
 */

const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();

// Stealth 플러그인 적용
chromium.use(stealth);

/**
 * Anti-bot 설정이 적용된 브라우저 생성
 */
async function createStealthBrowser(options = {}) {
  const headless = process.env.HEADLESS !== 'false';
  
  const browser = await chromium.launch({
    headless,
    args: [
      '--disable-blink-features=AutomationControlled',
      '--no-sandbox',
      '--disable-setuid-sandbox',
      '--disable-web-security',
      '--disable-features=IsolateOrigins,site-per-process'
    ]
  });

  const context = await browser.newContext({
    userAgent: options.userAgent || getRandomUserAgent(),
    viewport: { width: 1920, height: 1080 },
    locale: 'ko-KR',
    timezoneId: 'Asia/Seoul',
    extraHTTPHeaders: {
      'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7'
    }
  });

  // navigator.webdriver 제거
  await context.addInitScript(() => {
    Object.defineProperty(navigator, 'webdriver', {
      get: () => undefined
    });
    
    // Chrome detection 우회
    window.chrome = {
      runtime: {}
    };
    
    // Permissions API 우회
    const originalQuery = window.navigator.permissions.query;
    window.navigator.permissions.query = (parameters) => (
      parameters.name === 'notifications' ?
        Promise.resolve({ state: Notification.permission }) :
        originalQuery(parameters)
    );
  });

  return { browser, context };
}

/**
 * 랜덤 User-Agent 생성 (한국 사용자 기준)
 */
function getRandomUserAgent() {
  const userAgents = [
    // Desktop Chrome (Windows)
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    // Desktop Chrome (Mac)
    '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',
    // Mobile Chrome (Android)
    'Mozilla/5.0 (Linux; Android 13; SM-S901N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
    // Mobile Safari (iPhone)
    'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1'
  ];
  
  return userAgents[Math.floor(Math.random() * userAgents.length)];
}

/**
 * 인간처럼 랜덤 딜레이
 */
async function humanDelay(minMs = 1000, maxMs = 3000) {
  const delay = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
  await new Promise(resolve => setTimeout(resolve, delay));
}

/**
 * 스크롤 다운 (무한 스크롤 대응)
 */
async function scrollDown(page, times = 3) {
  for (let i = 0; i < times; i++) {
    await page.evaluate(() => {
      window.scrollBy(0, window.innerHeight);
    });
    await humanDelay(500, 1500);
  }
}

/**
 * 텍스트 정제 (공백, 개행 정리)
 */
function cleanText(text) {
  if (!text) return '';
  return text
    .replace(/\s+/g, ' ')
    .replace(/\n+/g, '\n')
    .trim();
}

/**
 * JSON 출력 (stdout)
 */
function outputJSON(data) {
  console.log(JSON.stringify(data, null, 2));
}

/**
 * 에러 JSON 출력
 */
function outputError(message, details = {}) {
  outputJSON({
    status: 'error',
    message,
    ...details
  });
}

/**
 * 성공 JSON 출력
 */
function outputSuccess(data) {
  outputJSON({
    status: 'success',
    ...data
  });
}

/**
 * 스크린샷 저장 (디버깅용)
 */
async function saveScreenshot(page, filename) {
  if (process.env.SCREENSHOT === 'true') {
    await page.screenshot({ path: filename, fullPage: true });
    console.error(`Screenshot saved: ${filename}`);
  }
}

/**
 * Rate limiting (동일 도메인 요청 간격)
 */
const lastRequestTime = {};
async function rateLimit(domain, minInterval = 2000) {
  const now = Date.now();
  const lastTime = lastRequestTime[domain] || 0;
  const elapsed = now - lastTime;
  
  if (elapsed < minInterval) {
    const waitTime = minInterval - elapsed;
    await new Promise(resolve => setTimeout(resolve, waitTime));
  }
  
  lastRequestTime[domain] = Date.now();
}

/**
 * URL에서 도메인 추출
 */
function getDomain(url) {
  try {
    return new URL(url).hostname;
  } catch (e) {
    return 'unknown';
  }
}

/**
 * 날짜 파싱 (한국 형식)
 * 예: "2시간 전", "2026.02.17", "어제", "2월 17일"
 */
function parseKoreanDate(dateStr) {
  const now = new Date();
  
  // "N시간 전"
  const hoursAgo = dateStr.match(/(\d+)시간\s*전/);
  if (hoursAgo) {
    const hours = parseInt(hoursAgo[1]);
    return new Date(now.getTime() - hours * 60 * 60 * 1000).toISOString().split('T')[0];
  }
  
  // "N분 전"
  const minsAgo = dateStr.match(/(\d+)분\s*전/);
  if (minsAgo) {
    return now.toISOString().split('T')[0];
  }
  
  // "어제"
  if (dateStr.includes('어제')) {
    const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
    return yesterday.toISOString().split('T')[0];
  }
  
  // "2026.02.17"
  const dotFormat = dateStr.match(/(\d{4})\.(\d{2})\.(\d{2})/);
  if (dotFormat) {
    return `${dotFormat[1]}-${dotFormat[2]}-${dotFormat[3]}`;
  }
  
  // "2월 17일" (올해)
  const monthDay = dateStr.match(/(\d+)월\s*(\d+)일/);
  if (monthDay) {
    const year = now.getFullYear();
    const month = String(monthDay[1]).padStart(2, '0');
    const day = String(monthDay[2]).padStart(2, '0');
    return `${year}-${month}-${day}`;
  }
  
  // 파싱 실패 시 원본 반환
  return dateStr;
}

/**
 * 숫자 파싱 (한국 단위)
 * 예: "1.2만", "523", "1,234"
 */
function parseKoreanNumber(numStr) {
  if (!numStr) return 0;
  
  // 쉼표 제거
  let cleaned = String(numStr).replace(/,/g, '');
  
  // "만" 단위
  const manMatch = cleaned.match(/([\d.]+)만/);
  if (manMatch) {
    return Math.floor(parseFloat(manMatch[1]) * 10000);
  }
  
  // 일반 숫자
  return parseInt(cleaned) || 0;
}

module.exports = {
  createStealthBrowser,
  getRandomUserAgent,
  humanDelay,
  scrollDown,
  cleanText,
  outputJSON,
  outputError,
  outputSuccess,
  saveScreenshot,
  rateLimit,
  getDomain,
  parseKoreanDate,
  parseKoreanNumber
};


path: package.json
content: {
  "name": "korean-scraper",
  "version": "1.0.0",
  "description": "Korean website specialized scraper with anti-bot protection",
  "main": "index.js",
  "scripts": {
    "install": "npx playwright install chromium",
    "test": "node scripts/test.js"
  },
  "keywords": [
    "scraper",
    "korea",
    "naver",
    "coupang",
    "playwright",
    "anti-bot"
  ],
  "author": "무펭이",
  "license": "MIT",
  "dependencies": {
    "playwright": "^1.41.0",
    "playwright-extra": "^4.3.6",
    "puppeteer-extra-plugin-stealth": "^2.11.2"
  }
}


path: scripts/coupang.js
content: #!/usr/bin/env node
/**
 * coupang.js - 쿠팡 상품 스크래퍼
 * 
 * Usage:
 *   node coupang.js product "https://www.coupang.com/vp/products/..."
 *   node coupang.js search "무선 이어폰" --limit 20
 */

const {
  createStealthBrowser,
  humanDelay,
  cleanText,
  outputSuccess,
  outputError,
  rateLimit,
  getDomain,
  parseKoreanNumber,
  scrollDown
} = require('../lib/common.js');

const SELECTORS = {
  // 상품 상세
  productName: '.prod-buy-header__title, h1.prod-buy-header__title',
  price: '.total-price strong, .prod-price .total-price',
  originalPrice: '.prod-origin-price, .origin-price',
  discount: '.discount-percentage, .prod-coupon-price .discount-rate',
  rating: '.rating-star-num, .rating-total-review em',
  reviewCount: '.rating-total-review span, .sdp-review__average__total-star',
  rocketBadge: '.rocket-badge, img[alt*="로켓"]',
  seller: '.prod-sale-vendor-name a, .seller-name',
  productImages: '.prod-image__detail img, #thumbnailImg img',
  
  // 검색 결과
  searchItem: 'li.search-product',
  searchTitle: '.name',
  searchPrice: '.price-value',
  searchRating: '.rating',
  searchReviews: '.rating-total-count'
};

async function scrapeProduct(url) {
  const { browser, context } = await createStealthBrowser();
  
  try {
    const page = await context.newPage();
    
    await rateLimit(getDomain(url));
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await humanDelay(3000, 5000);
    
    // 상품 정보 추출
    const data = await page.evaluate((selectors) => {
      const getText = (selector) => {
        const el = document.querySelector(selector);
        return el?.textContent?.trim() || '';
      };
      
      const getNumber = (selector) => {
        const text = getText(selector);
        return text.replace(/[^0-9]/g, '');
      };
      
      return {
        productName: getText(selectors.productName),
        price: getNumber(selectors.price),
        originalPrice: getNumber(selectors.originalPrice),
        discount: getText(selectors.discount),
        rating: getText(selectors.rating),
        reviewCount: getNumber(selectors.reviewCount),
        rocketDelivery: !!document.querySelector(selectors.rocketBadge),
        seller: getText(selectors.seller),
        images: Array.from(document.querySelectorAll(selectors.productImages))
          .map(img => img.src)
          .filter(src => src && !src.includes('icon'))
          .slice(0, 5) // 최대 5개
      };
    }, SELECTORS);
    
    await browser.close();
    
    outputSuccess({
      url,
      productName: data.productName,
      price: parseInt(data.price) || 0,
      originalPrice: parseInt(data.originalPrice) || 0,
      discount: data.discount,
      rating: parseFloat(data.rating) || 0,
      reviewCount: parseInt(data.reviewCount) || 0,
      rocketDelivery: data.rocketDelivery,
      seller: data.seller,
      images: data.images
    });
    
  } catch (error) {
    await browser.close();
    outputError(`Failed to scrape product: ${error.message}`, { url });
    process.exit(1);
  }
}

async function searchProduct(query, limit = 20) {
  const { browser, context } = await createStealthBrowser();
  
  try {
    const page = await context.newPage();
    const searchUrl = `https://www.coupang.com/np/search?q=${encodeURIComponent(query)}`;
    
    await rateLimit('coupang.com');
    await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await humanDelay(2000, 4000);
    
    // 스크롤로 더 많은 결과 로드
    await scrollDown(page, 2);
    
    // 검색 결과 추출
    const products = await page.$$eval(SELECTORS.searchItem, (items, selectors, limit) => {
      return items.slice(0, limit).map(item => {
        const titleEl = item.querySelector(selectors.searchTitle);
        const priceEl = item.querySelector(selectors.searchPrice);
        const ratingEl = item.querySelector(selectors.searchRating);
        const reviewsEl = item.querySelector(selectors.searchReviews);
        const linkEl = item.querySelector('a.search-product-link');
        
        return {
          name: titleEl?.textContent?.trim() || '',
          price: priceEl?.textContent?.replace(/[^0-9]/g, '') || '0',
          rating: ratingEl?.textContent?.trim() || '0',
          reviews: reviewsEl?.textContent?.replace(/[^0-9]/g, '') || '0',
          url: linkEl?.href || ''
        };
      }).filter(p => p.name);
    }, SELECTORS, limit);
    
    // 데이터 정제
    products.forEach(p => {
      p.price = parseInt(p.price) || 0;
      p.rating = parseFloat(p.rating) || 0;
      p.reviews = parseInt(p.reviews) || 0;
    });
    
    await browser.close();
    
    outputSuccess({
      query,
      count: products.length,
      products
    });
    
  } catch (error) {
    await browser.close();
    outputError(`Failed to search products: ${error.message}`, { query });
    process.exit(1);
  }
}

// CLI 파싱
const args = process.argv.slice(2);
const command = args[0];
const target = args[1];

if (!command || !target) {
  console.error('Usage:');
  console.error('  node coupang.js product "product_url"');
  console.error('  node coupang.js search "query" [--limit N]');
  process.exit(1);
}

const limitIndex = args.indexOf('--limit');
const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 20;

if (command === 'product') {
  scrapeProduct(target);
} else if (command === 'search') {
  searchProduct(target, limit);
} else {
  console.error(`Unknown command: ${command}`);
  process.exit(1);
}


path: scripts/daum-news.js
content: #!/usr/bin/env node
/**
 * daum-news.js - 다음 뉴스 스크래퍼
 * 
 * Usage:
 *   node daum-news.js search "경제" --limit 10
 *   node daum-news.js extract "https://v.daum.net/..."
 */

const {
  createStealthBrowser,
  humanDelay,
  cleanText,
  outputSuccess,
  outputError,
  rateLimit,
  getDomain,
  parseKoreanDate
} = require('../lib/common.js');

const SELECTORS = {
  // 검색 결과
  searchItem: '.c-item-doc, .news-item',
  searchTitle: '.tit-g, .item-title a',
  searchMedia: '.info-cp, .source',
  searchDate: '.date, .info-time',
  searchSnippet: '.desc, .item-contents',
  
  // 기사 본문
  articleTitle: 'h3.tit_view, .news_title',
  articleMedia: '.info_view .txt_info:first-child, .head_view .txt_info',
  articleAuthor: '.link_txt, .txt_info .link_txt',
  articleDate: '.num_date, .txt_info .num_date',
  articleContent: '#harmonyContainer, .article_view',
  articleImages: '#harmonyContainer img, .article_view img',
  articleCategory: '.link_txt, .head_view .link_txt'
};

async function searchNews(query, limit = 10) {
  const { browser, context } = await createStealthBrowser();
  
  try {
    const page = await context.newPage();
    const searchUrl = `https://search.daum.net/search?w=news&q=${encodeURIComponent(query)}`;
    
    await rateLimit('daum.net');
    await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await humanDelay(2000, 4000);
    
    // 검색 결과 추출
    const results = await page.$$eval(SELECTORS.searchItem, (items, selectors, limit) => {
      return items.slice(0, limit).map(item => {
        const titleEl = item.querySelector(selectors.searchTitle);
        const mediaEl = item.querySelector(selectors.searchMedia);
        const dateEl = item.querySelector(selectors.searchDate);
        const snippetEl = item.querySelector(selectors.searchSnippet);
        
        return {
          title: titleEl?.textContent?.trim() || '',
          url: titleEl?.href || '',
          media: mediaEl?.textContent?.trim() || '',
          date: dateEl?.textContent?.trim() || '',
          snippet: snippetEl?.textContent?.trim() || ''
        };
      }).filter(item => item.title);
    }, SELECTORS, limit);
    
    // 날짜 파싱
    results.forEach(item => {
      item.date = parseKoreanDate(item.date);
    });
    
    await browser.close();
    
    outputSuccess({
      query,
      count: results.length,
      results
    });
    
  } catch (error) {
    await browser.close();
    outputError(`Failed to search news: ${error.message}`, { query });
    process.exit(1);
  }
}

async function extractArticle(url) {
  const { browser, context } = await createStealthBrowser();
  
  try {
    const page = await context.newPage();
    
    await rateLimit(getDomain(url));
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await humanDelay(2000, 4000);
    
    // 기사 본문 추출
    const data = await page.evaluate((selectors) => {
      const getText = (selector) => {
        const el = document.querySelector(selector);
        return el?.textContent?.trim() || '';
      };
      
      return {
        title: getText(selectors.articleTitle),
        media: getText(selectors.articleMedia),
        author: getText(selectors.articleAuthor),
        date: getText(selectors.articleDate),
        content: getText(selectors.articleContent),
        images: Array.from(document.querySelectorAll(selectors.articleImages))
          .map(img => img.src)
          .filter(src => src && !src.includes('icon') && !src.includes('logo')),
        category: getText(selectors.articleCategory)
      };
    }, SELECTORS);
    
    // 본문에서 불필요한 텍스트 제거
    let content = data.content;
    content = content.replace(/무단\s*전재\s*및?\s*재배포\s*금지/gi, '');
    content = content.replace(/ⓒ\s*.*?무단\s*전재.*?\n/gi, '');
    content = content.replace(/\[.*?기자.*?\]/g, '');
    
    await browser.close();
    
    outputSuccess({
      url,
      title: data.title,
      media: data.media,
      author: data.author,
      date: parseKoreanDate(data.date),
      content: cleanText(content),
      category: data.category,
      images: data.images
    });
    
  } catch (error) {
    await browser.close();
    outputError(`Failed to extract article: ${error.message}`, { url });
    process.exit(1);
  }
}

// CLI 파싱
const args = process.argv.slice(2);
const command = args[0];
const target = args[1];

if (!command || !target) {
  console.error('Usage:');
  console.error('  node daum-news.js search "query" [--limit N]');
  console.error('  node daum-news.js extract "article_url"');
  process.exit(1);
}

const limitIndex = args.indexOf('--limit');
const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 10;

if (command === 'search') {
  searchNews(target, limit);
} else if (command === 'extract') {
  extractArticle(target);
} else {
  console.error(`Unknown command: ${command}`);
  process.exit(1);
}


path: scripts/naver-blog.js
content: #!/usr/bin/env node
/**
 * naver-blog.js - 네이버 블로그 스크래퍼
 * 
 * Usage:
 *   node naver-blog.js search "맛집" --limit 10
 *   node naver-blog.js extract "https://blog.naver.com/..."
 */

const path = require('path');
const {
  createStealthBrowser,
  humanDelay,
  cleanText,
  outputSuccess,
  outputError,
  rateLimit,
  getDomain,
  parseKoreanDate
} = require('../lib/common.js');

const SELECTORS = {
  // 검색 결과 페이지
  searchItem: '.view_wrap',
  searchTitle: '.title_link',
  searchBlogger: '.name',
  searchDate: '.sub_time',
  searchSnippet: '.dsc_link',
  
  // 블로그 본문
  blogTitle: '.se-title-text, .pcol1',
  blogDate: '.se_publishDate, .se_date',
  blogContent: '.se-main-container, #postViewArea',
  blogImages: '.se-image-resource, #postViewArea img',
  blogTags: '.tag_list a'
};

async function searchBlog(query, limit = 10) {
  const { browser, context } = await createStealthBrowser();
  
  try {
    const page = await context.newPage();
    const searchUrl = `https://search.naver.com/search.naver?where=blog&query=${encodeURIComponent(query)}`;
    
    await rateLimit('naver.com');
    await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await humanDelay(2000, 4000);
    
    // 검색 결과 추출
    const results = await page.$$eval(SELECTORS.searchItem, (items, selectors, limit) => {
      return items.slice(0, limit).map(item => {
        const titleEl = item.querySelector(selectors.searchTitle);
        const bloggerEl = item.querySelector(selectors.searchBlogger);
        const dateEl = item.querySelector(selectors.searchDate);
        const snippetEl = item.querySelector(selectors.searchSnippet);
        
        return {
          title: titleEl?.textContent?.trim() || '',
          url: titleEl?.href || '',
          blogger: bloggerEl?.textContent?.trim() || '',
          date: dateEl?.textContent?.trim() || '',
          snippet: snippetEl?.textContent?.trim() || ''
        };
      });
    }, SELECTORS, limit);
    
    // 날짜 파싱
    results.forEach(item => {
      item.date = parseKoreanDate(item.date);
    });
    
    await browser.close();
    
    outputSuccess({
      query,
      count: results.length,
      results
    });
    
  } catch (error) {
    await browser.close();
    outputError(`Failed to search blog: ${error.message}`, { query });
    process.exit(1);
  }
}

async function extractBlog(url) {
  const { browser, context } = await createStealthBrowser();
  
  try {
    const page = await context.newPage();
    
    await rateLimit(getDomain(url));
    await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
    await humanDelay(2000, 4000);
    
    // iframe 처리 (네이버 블로그는 iframe 사용)
    let targetFrame = page;
    const frames = page.frames();
    const mainFrame = frames.find(f => f.url().includes('blog.naver.com'));
    if (mainFrame) {
      targetFrame = mainFrame;
    }
    
    // 본문 추출
    const data = await targetFrame.evaluate((selectors) => {
      const titleEl = document.querySelector(selectors.blogTitle);
      const dateEl = document.querySelector(selectors.blogDate);
      const contentEl = document.querySelector(selectors.blogContent);
      const imageEls = document.querySelectorAll(selectors.blogImages);
      const tagEls = document.querySelectorAll(selectors.blogTags);
      
      return {
        title: titleEl?.textContent?.trim() || '',
        date: dateEl?.textContent?.trim() || '',
        content: contentEl?.textContent?.trim() || '',
        images: Array.from(imageEls).map(img => img.src || img.dataset.src).filter(Boolean),
        tags: Array.from(tagEls).map(tag => tag.textContent?.trim()).filter(Boolean)
      };
    }, SELECTORS);
    
    // 작성자 추출 (URL에서)
    const authorMatch = url.match(/blog\.naver\.com\/([^\/\?]+)/);
    const author = authorMatch ? authorMatch[1] : '';
    
    await browser.close();
    
    outputSuccess({
      url,
      title: data.title,
      author,
      date: parseKoreanDate(data.date),
      content: cleanText(data.content),
      images: data.images,
      tags: data.tags
    });
    
  } catch (error) {
    await browser.close();
    outputError(`Failed to extract blog: ${error.message}`, { url });
    process.exit(1);
  }
}

// CLI 파싱
const args = process.argv.slice(2);
const command = args[0];
const target = args[1];

if (!command || !target) {
  console.error('Usage:');
  console.error('  node naver-blog.js search "query" [--limit N]');
  console.error('  node naver-blog.js extract "url"');
  process.exit(1);
}

const limitIndex = args.indexOf('--limit');
const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1]) : 10;

if (command === 'search') {
  searchBlog(target, limit);
} else if (command === 'extract') {
  extractBlog(target);
} else {
  console.error(`Unknown command: ${command}`);
  process.exit(1);
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)