Add Puppeteer support to bypass bot detection
Major changes: - Install puppeteer, puppeteer-extra, puppeteer-extra-plugin-stealth - Create PuppeteerFetcher class with Stealth plugin - Update all crawlers to use Puppeteer instead of Axios - Add browser lifecycle management (init/close) - Update test.ts and index.ts with browser cleanup Features: - Real Chrome browser execution (bypasses TLS fingerprinting) - Stealth plugin to avoid bot detection - Headless mode for background operation - Proper error handling and browser cleanup Limitations: - Requires Chrome/Chromium installation - Higher resource usage (~200MB memory) - Slower than Axios (browser startup time) - Cannot test in current environment (Chrome install blocked) Next steps: - Test in local environment with Chrome installed - Adjust HTML selectors based on actual page structure - Monitor for Cloudflare blocks
This commit is contained in:
129
crawler/README.md
Normal file
129
crawler/README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Community Crawler (Puppeteer)
|
||||||
|
|
||||||
|
루리웹, 아카라이브 등 한국 커뮤니티 크롤러 (Puppeteer 기반)
|
||||||
|
|
||||||
|
## 특징
|
||||||
|
|
||||||
|
- ✅ **Puppeteer** - 실제 Chrome 브라우저 사용
|
||||||
|
- ✅ **Stealth Plugin** - 봇 탐지 우회
|
||||||
|
- ✅ **TLS Fingerprinting 우회** - 403 에러 해결
|
||||||
|
- ✅ **안전 장치** - 딜레이, 재시도 로직, robots.txt 준수
|
||||||
|
|
||||||
|
## 설치
|
||||||
|
|
||||||
|
### 1. 의존성 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd crawler
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Chrome/Chromium 설치 (필수)
|
||||||
|
|
||||||
|
Puppeteer가 Chrome을 자동으로 다운로드하지 못하는 경우, 시스템에 Chrome을 설치해야 합니다:
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
- [Chrome 다운로드](https://www.google.com/chrome/)
|
||||||
|
|
||||||
|
**Mac:**
|
||||||
|
```bash
|
||||||
|
brew install --cask google-chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux (Ubuntu/Debian):**
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y chromium-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 Puppeteer가 Chrome을 자동으로 다운로드하도록:
|
||||||
|
```bash
|
||||||
|
node node_modules/puppeteer/install.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
### 테스트 실행 (한 번만)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 스케줄러 실행 (30분마다 자동 실행)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 개발 모드 (코드 변경 시 자동 재시작)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출력
|
||||||
|
|
||||||
|
크롤링 결과는 다음 경로에 저장됩니다:
|
||||||
|
```
|
||||||
|
../src/data/posts.json
|
||||||
|
```
|
||||||
|
|
||||||
|
프론트엔드에서 이 파일을 import하여 사용합니다.
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
`src/config.ts` 파일에서 설정 변경 가능:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const CRAWLER_CONFIG = {
|
||||||
|
delay: 3000, // 요청 간 딜레이 (ms)
|
||||||
|
maxRetries: 3, // 재시도 횟수
|
||||||
|
timeout: 10000, // 타임아웃 (ms)
|
||||||
|
maxPostsPerBoard: 20, // 게시판당 최대 게시글 수
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
⚠️ **법적/윤리적 책임**
|
||||||
|
- 크롤링으로 인한 법적 책임은 사용자에게 있습니다
|
||||||
|
- 서버 부하를 최소화하기 위해 적절한 딜레이를 설정하세요
|
||||||
|
- robots.txt를 준수합니다
|
||||||
|
|
||||||
|
⚠️ **기술적 제약**
|
||||||
|
- Puppeteer는 리소스를 많이 사용합니다 (메모리 ~200MB)
|
||||||
|
- 헤드리스 브라우저 실행 시간이 필요합니다
|
||||||
|
- 여전히 차단될 수 있습니다 (Cloudflare 고급 탐지)
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### Chrome not found 에러
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Puppeteer가 Chrome을 찾지 못하는 경우
|
||||||
|
# 시스템 Chrome 경로 지정 (puppeteer-fetcher.ts 수정)
|
||||||
|
executablePath: '/usr/bin/chromium-browser', // Linux
|
||||||
|
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // Mac
|
||||||
|
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', // Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
### 403 Forbidden 여전히 발생
|
||||||
|
|
||||||
|
- Cloudflare가 더 강화되었을 수 있습니다
|
||||||
|
- User-Agent를 최신 버전으로 업데이트
|
||||||
|
- 더 긴 딜레이 설정
|
||||||
|
- VPN/프록시 사용 고려
|
||||||
|
|
||||||
|
### 메모리 부족
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// puppeteer-fetcher.ts에서 headless 모드 유지
|
||||||
|
headless: true, // 'new'로 변경하면 더 적은 메모리 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
## 다음 단계
|
||||||
|
|
||||||
|
- [ ] HTML 선택자 실제 페이지에 맞게 조정
|
||||||
|
- [ ] 더 많은 게시판 추가
|
||||||
|
- [ ] 에러 핸들링 강화
|
||||||
|
- [ ] Spring 백엔드로 마이그레이션
|
||||||
1578
crawler/package-lock.json
generated
1578
crawler/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,20 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "tsx src/test.ts"
|
"test": "tsx src/test.ts"
|
||||||
},
|
},
|
||||||
"keywords": ["crawler", "community", "korea"],
|
"keywords": [
|
||||||
|
"crawler",
|
||||||
|
"community",
|
||||||
|
"korea"
|
||||||
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"node-cron": "^3.0.3"
|
"node-cron": "^3.0.3",
|
||||||
|
"puppeteer": "^24.30.0",
|
||||||
|
"puppeteer-extra": "^3.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { BaseCrawler } from './base.js';
|
import { BaseCrawler } from './base.js';
|
||||||
import type { Post, BoardConfig } from '../types.js';
|
import type { Post, BoardConfig } from '../types.js';
|
||||||
import { Fetcher } from '../utils/fetcher.js';
|
import { PuppeteerFetcher } from '../utils/puppeteer-fetcher.js';
|
||||||
import { Logger } from '../utils/logger.js';
|
import { Logger } from '../utils/logger.js';
|
||||||
import { CRAWLER_CONFIG } from '../config.js';
|
import { CRAWLER_CONFIG } from '../config.js';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export class ArcaliveCrawler extends BaseCrawler {
|
|||||||
protected communityName = 'Arcalive';
|
protected communityName = 'Arcalive';
|
||||||
|
|
||||||
async crawlBoard(board: BoardConfig): Promise<Post[]> {
|
async crawlBoard(board: BoardConfig): Promise<Post[]> {
|
||||||
const html = await Fetcher.fetchHTML(board.url);
|
const html = await PuppeteerFetcher.fetchHTML(board.url);
|
||||||
if (!html) return [];
|
if (!html) return [];
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Post, BoardConfig } from '../types.js';
|
import type { Post, BoardConfig } from '../types.js';
|
||||||
import { Fetcher } from '../utils/fetcher.js';
|
import { PuppeteerFetcher } from '../utils/puppeteer-fetcher.js';
|
||||||
import { Logger } from '../utils/logger.js';
|
import { Logger } from '../utils/logger.js';
|
||||||
|
|
||||||
export abstract class BaseCrawler {
|
export abstract class BaseCrawler {
|
||||||
@@ -20,7 +20,7 @@ export abstract class BaseCrawler {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 다음 게시판으로 넘어가기 전 딜레이
|
// 다음 게시판으로 넘어가기 전 딜레이
|
||||||
await Fetcher.delay();
|
await PuppeteerFetcher.delay();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(
|
Logger.error(
|
||||||
`Failed to crawl ${this.communityName} - ${board.name}`,
|
`Failed to crawl ${this.communityName} - ${board.name}`,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { BaseCrawler } from './base.js';
|
import { BaseCrawler } from './base.js';
|
||||||
import type { Post, BoardConfig } from '../types.js';
|
import type { Post, BoardConfig } from '../types.js';
|
||||||
import { Fetcher } from '../utils/fetcher.js';
|
import { PuppeteerFetcher } from '../utils/puppeteer-fetcher.js';
|
||||||
import { Logger } from '../utils/logger.js';
|
import { Logger } from '../utils/logger.js';
|
||||||
import { CRAWLER_CONFIG } from '../config.js';
|
import { CRAWLER_CONFIG } from '../config.js';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export class RuliwebCrawler extends BaseCrawler {
|
|||||||
protected communityName = 'Ruliweb';
|
protected communityName = 'Ruliweb';
|
||||||
|
|
||||||
async crawlBoard(board: BoardConfig): Promise<Post[]> {
|
async crawlBoard(board: BoardConfig): Promise<Post[]> {
|
||||||
const html = await Fetcher.fetchHTML(board.url);
|
const html = await PuppeteerFetcher.fetchHTML(board.url);
|
||||||
if (!html) return [];
|
if (!html) return [];
|
||||||
|
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { RuliwebCrawler } from './crawlers/ruliweb.js';
|
import { RuliwebCrawler } from './crawlers/ruliweb.js';
|
||||||
import { ArcaliveCrawler } from './crawlers/arcalive.js';
|
import { ArcaliveCrawler } from './crawlers/arcalive.js';
|
||||||
|
import { PuppeteerFetcher } from './utils/puppeteer-fetcher.js';
|
||||||
import { Logger } from './utils/logger.js';
|
import { Logger } from './utils/logger.js';
|
||||||
import { RULIWEB_BOARDS, ARCALIVE_CHANNELS } from './config.js';
|
import { RULIWEB_BOARDS, ARCALIVE_CHANNELS } from './config.js';
|
||||||
import type { Post } from './types.js';
|
import type { Post } from './types.js';
|
||||||
@@ -37,6 +38,9 @@ async function crawlAll(): Promise<void> {
|
|||||||
Logger.error('Failed to save posts', error);
|
Logger.error('Failed to save posts', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 브라우저 종료 (다음 실행 시 재초기화됨)
|
||||||
|
await PuppeteerFetcher.closeBrowser();
|
||||||
|
|
||||||
Logger.info('========== Crawl job completed ==========');
|
Logger.info('========== Crawl job completed ==========');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { RuliwebCrawler } from './crawlers/ruliweb.js';
|
import { RuliwebCrawler } from './crawlers/ruliweb.js';
|
||||||
import { ArcaliveCrawler } from './crawlers/arcalive.js';
|
import { ArcaliveCrawler } from './crawlers/arcalive.js';
|
||||||
|
import { PuppeteerFetcher } from './utils/puppeteer-fetcher.js';
|
||||||
import { Logger } from './utils/logger.js';
|
import { Logger } from './utils/logger.js';
|
||||||
import { RULIWEB_BOARDS, ARCALIVE_CHANNELS } from './config.js';
|
import { RULIWEB_BOARDS, ARCALIVE_CHANNELS } from './config.js';
|
||||||
import type { Post } from './types.js';
|
import type { Post } from './types.js';
|
||||||
@@ -56,7 +57,13 @@ async function test(): Promise<void> {
|
|||||||
Logger.error('Failed to save posts', error);
|
Logger.error('Failed to save posts', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 브라우저 종료
|
||||||
|
await PuppeteerFetcher.closeBrowser();
|
||||||
|
|
||||||
Logger.info('========== Test crawl completed ==========');
|
Logger.info('========== Test crawl completed ==========');
|
||||||
}
|
}
|
||||||
|
|
||||||
test();
|
test().catch((error) => {
|
||||||
|
Logger.error('Test failed', error);
|
||||||
|
PuppeteerFetcher.closeBrowser().finally(() => process.exit(1));
|
||||||
|
});
|
||||||
|
|||||||
134
crawler/src/utils/puppeteer-fetcher.ts
Normal file
134
crawler/src/utils/puppeteer-fetcher.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import puppeteer from 'puppeteer-extra';
|
||||||
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
|
import { Browser, Page } from 'puppeteer';
|
||||||
|
import { Logger } from './logger.js';
|
||||||
|
import { CRAWLER_CONFIG } from '../config.js';
|
||||||
|
|
||||||
|
// Stealth 플러그인 적용 (봇 탐지 우회)
|
||||||
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
|
export class PuppeteerFetcher {
|
||||||
|
private static browser: Browser | null = null;
|
||||||
|
private static pagePool: Page[] = [];
|
||||||
|
|
||||||
|
private static async sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 브라우저 초기화
|
||||||
|
static async initBrowser(): Promise<void> {
|
||||||
|
if (this.browser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Logger.info('Launching browser...');
|
||||||
|
this.browser = await puppeteer.launch({
|
||||||
|
headless: true, // headless 모드 (백그라운드 실행)
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-accelerated-2d-canvas',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--window-size=1920,1080',
|
||||||
|
'--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
Logger.success('Browser launched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to launch browser', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 브라우저 종료
|
||||||
|
static async closeBrowser(): Promise<void> {
|
||||||
|
if (this.browser) {
|
||||||
|
await this.browser.close();
|
||||||
|
this.browser = null;
|
||||||
|
this.pagePool = [];
|
||||||
|
Logger.info('Browser closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 가져오기
|
||||||
|
static async fetchHTML(
|
||||||
|
url: string,
|
||||||
|
retries: number = CRAWLER_CONFIG.maxRetries
|
||||||
|
): Promise<string | null> {
|
||||||
|
await this.initBrowser();
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
let page: Page | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Logger.info(`Fetching with Puppeteer: ${url} (attempt ${attempt}/${retries})`);
|
||||||
|
|
||||||
|
if (!this.browser) {
|
||||||
|
throw new Error('Browser not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 페이지 생성
|
||||||
|
page = await this.browser.newPage();
|
||||||
|
|
||||||
|
// 타임아웃 설정
|
||||||
|
page.setDefaultTimeout(CRAWLER_CONFIG.timeout);
|
||||||
|
|
||||||
|
// User-Agent 설정
|
||||||
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extra HTTP 헤더 설정
|
||||||
|
await page.setExtraHTTPHeaders({
|
||||||
|
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 페이지 이동
|
||||||
|
const response = await page.goto(url, {
|
||||||
|
waitUntil: 'networkidle2', // 네트워크 활동이 거의 없을 때까지 대기
|
||||||
|
timeout: CRAWLER_CONFIG.timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('No response from page');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = response.status();
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error(`HTTP ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 로딩 추가 대기 (JavaScript 실행 완료)
|
||||||
|
await this.sleep(2000);
|
||||||
|
|
||||||
|
// HTML 가져오기
|
||||||
|
const html = await page.content();
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
Logger.success(`Fetched: ${url} (${html.length} bytes)`);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (page) {
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(`Failed to fetch ${url}`, error.message);
|
||||||
|
|
||||||
|
if (attempt < retries) {
|
||||||
|
const backoffDelay = CRAWLER_CONFIG.delay * Math.pow(2, attempt - 1);
|
||||||
|
Logger.warn(`Retrying after ${backoffDelay}ms...`);
|
||||||
|
await this.sleep(backoffDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 딜레이
|
||||||
|
static async delay(): Promise<void> {
|
||||||
|
await this.sleep(CRAWLER_CONFIG.delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user