init: 크롤링 관리 솔루션 초기 구성
- Express.js 기반 관리자 페이지 (사이트/크롤링/AdSense/도메인 관리) - PostgreSQL 16 + Docker Compose (Traefik 연동) - 크롤러: axios + cheerio 기반 HTML 파싱 - 스케줄러: node-cron 기반 자동 크롤링 - 공개 사이트: slug/도메인 기반 DB에서 렌더링 HTML 서빙 - 도메인: admin.startover.co.kr Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,14 @@
|
||||
# 로컬 개발용 환경변수
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# PostgreSQL
|
||||
DB_HOST=crawl-manager-db
|
||||
DB_PORT=5432
|
||||
DB_NAME=crawler
|
||||
DB_USER=crawler
|
||||
DB_PASSWORD=qlalfqjsgh11!!
|
||||
|
||||
# 관리자 인증
|
||||
ADMIN_USER=chpark@admin.co.kr
|
||||
ADMIN_PASS=1313Qkrckd!!!!!!
|
||||
@@ -0,0 +1,14 @@
|
||||
# 서버 설정
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# PostgreSQL 설정
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_NAME=crawl_manager
|
||||
DB_USER=crawl_admin
|
||||
DB_PASSWORD=change_this_password
|
||||
|
||||
# 관리자 인증 (Basic Auth)
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASS=change_this_password
|
||||
@@ -0,0 +1,19 @@
|
||||
# 서버 설정
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# PostgreSQL 설정 (Docker postgres 컨테이너용)
|
||||
POSTGRES_DB=crawler
|
||||
POSTGRES_USER=crawler
|
||||
POSTGRES_PASSWORD=qlalfqjsgh11!!
|
||||
|
||||
# App에서 사용하는 DB 연결 (컨테이너 이름 기반)
|
||||
DB_HOST=crawl-manager-db
|
||||
DB_PORT=5432
|
||||
DB_NAME=crawler
|
||||
DB_USER=crawler
|
||||
DB_PASSWORD=qlalfqjsgh11!!
|
||||
|
||||
# 관리자 인증 (Basic Auth)
|
||||
ADMIN_USER=chpark@admin.co.kr
|
||||
ADMIN_PASS=1313Qkrckd!!!!!!
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
crawl-pgdata/
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/app.js"]
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
REM ==========================================
|
||||
REM Crawl Manager 서버 배포 스크립트 (Windows)
|
||||
REM ==========================================
|
||||
REM 사용법: deploy.bat
|
||||
REM 사전 준비: SSH 키 설정 또는 비밀번호 인증
|
||||
REM ==========================================
|
||||
|
||||
REM ===== 설정 (본인 환경에 맞게 수정) =====
|
||||
SET SERVER_USER=root
|
||||
SET SERVER_HOST=your-server-ip
|
||||
SET SERVER_PORT=22
|
||||
SET REMOTE_DIR=/home/crawl-manager
|
||||
SET PROJECT_DIR=%~dp0
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo Crawl Manager 배포 시작
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
REM 1. 서버에 디렉토리 생성
|
||||
echo [1/4] 서버 디렉토리 준비...
|
||||
ssh -p %SERVER_PORT% %SERVER_USER%@%SERVER_HOST% "mkdir -p %REMOTE_DIR%/postgres_data %REMOTE_DIR%/app_data"
|
||||
|
||||
REM 2. 파일 전송 (scp)
|
||||
echo [2/4] 파일 전송 중...
|
||||
scp -P %SERVER_PORT% -r "%PROJECT_DIR%src" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
scp -P %SERVER_PORT% -r "%PROJECT_DIR%views" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
scp -P %SERVER_PORT% -r "%PROJECT_DIR%public" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
scp -P %SERVER_PORT% "%PROJECT_DIR%package.json" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
scp -P %SERVER_PORT% "%PROJECT_DIR%Dockerfile" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
scp -P %SERVER_PORT% "%PROJECT_DIR%docker-compose.yml" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
scp -P %SERVER_PORT% "%PROJECT_DIR%.env.production" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
scp -P %SERVER_PORT% "%PROJECT_DIR%.dockerignore" %SERVER_USER%@%SERVER_HOST%:%REMOTE_DIR%/
|
||||
|
||||
REM 3. Docker 빌드 & 실행
|
||||
echo [3/4] Docker 빌드 및 실행...
|
||||
ssh -p %SERVER_PORT% %SERVER_USER%@%SERVER_HOST% "cd %REMOTE_DIR% && docker compose down && docker compose build --no-cache && docker compose up -d"
|
||||
|
||||
REM 4. 상태 확인
|
||||
echo [4/4] 상태 확인...
|
||||
ssh -p %SERVER_PORT% %SERVER_USER%@%SERVER_HOST% "cd %REMOTE_DIR% && docker compose ps"
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo 배포 완료!
|
||||
echo 관리자: https://admin.startover.co.kr/admin
|
||||
echo ==========================================
|
||||
pause
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
# ==========================================
|
||||
# Crawl Manager 서버 배포 스크립트 (PowerShell)
|
||||
# ==========================================
|
||||
# 사용법: .\deploy.ps1
|
||||
# 또는: .\deploy.ps1 -ServerHost "1.2.3.4" -ServerUser "root"
|
||||
# ==========================================
|
||||
|
||||
param(
|
||||
[string]$ServerUser = "root",
|
||||
[string]$ServerHost = "your-server-ip",
|
||||
[int]$ServerPort = 22,
|
||||
[string]$RemoteDir = "/home/crawl-manager"
|
||||
)
|
||||
|
||||
$ProjectDir = $PSScriptRoot
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host " Crawl Manager 배포 시작" -ForegroundColor Cyan
|
||||
Write-Host "==========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 1. 서버 디렉토리 생성
|
||||
Write-Host "[1/4] 서버 디렉토리 준비..." -ForegroundColor Yellow
|
||||
ssh -p $ServerPort "$ServerUser@$ServerHost" "mkdir -p $RemoteDir/postgres_data $RemoteDir/app_data"
|
||||
|
||||
# 2. 파일 전송
|
||||
Write-Host "[2/4] 파일 전송 중..." -ForegroundColor Yellow
|
||||
$filesToCopy = @(
|
||||
"src", "views", "public"
|
||||
)
|
||||
$singleFiles = @(
|
||||
"package.json", "Dockerfile", "docker-compose.yml",
|
||||
".env.production", ".dockerignore"
|
||||
)
|
||||
|
||||
foreach ($dir in $filesToCopy) {
|
||||
Write-Host " - $dir/" -ForegroundColor Gray
|
||||
scp -P $ServerPort -r "$ProjectDir/$dir" "$ServerUser@${ServerHost}:$RemoteDir/"
|
||||
}
|
||||
|
||||
foreach ($file in $singleFiles) {
|
||||
$filePath = "$ProjectDir/$file"
|
||||
if (Test-Path $filePath) {
|
||||
Write-Host " - $file" -ForegroundColor Gray
|
||||
scp -P $ServerPort "$filePath" "$ServerUser@${ServerHost}:$RemoteDir/"
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Docker 빌드 & 실행
|
||||
Write-Host "[3/4] Docker 빌드 및 실행..." -ForegroundColor Yellow
|
||||
ssh -p $ServerPort "$ServerUser@$ServerHost" @"
|
||||
cd $RemoteDir
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
"@
|
||||
|
||||
# 4. 상태 확인
|
||||
Write-Host "[4/4] 상태 확인..." -ForegroundColor Yellow
|
||||
ssh -p $ServerPort "$ServerUser@$ServerHost" "cd $RemoteDir && docker compose ps && echo '' && docker compose logs --tail=20 crawl-manager"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==========================================" -ForegroundColor Green
|
||||
Write-Host " 배포 완료!" -ForegroundColor Green
|
||||
Write-Host " 관리자: https://admin.startover.co.kr/admin" -ForegroundColor Green
|
||||
Write-Host "==========================================" -ForegroundColor Green
|
||||
@@ -0,0 +1,43 @@
|
||||
# 로컬 개발용 (Windows)
|
||||
# 사용법: docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
services:
|
||||
crawl-manager:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: crawl-manager
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
crawl-manager-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
- ./views:/app/views
|
||||
- ./public:/app/public
|
||||
|
||||
crawl-manager-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: crawl-manager-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: crawler
|
||||
POSTGRES_USER: crawler
|
||||
POSTGRES_PASSWORD: "qlalfqjsgh11!!"
|
||||
volumes:
|
||||
- crawl-pgdata:/var/lib/postgresql/data
|
||||
- ./src/migrations/init.sql:/docker-entrypoint-initdb.d/01-init.sql
|
||||
ports:
|
||||
- "11137:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U crawler -d crawler"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
crawl-pgdata:
|
||||
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
crawl-manager:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: crawl-manager
|
||||
restart: always
|
||||
env_file:
|
||||
- .env.production
|
||||
stdin_open: true
|
||||
tty: true
|
||||
depends_on:
|
||||
crawl-manager-db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- /home/crawl-manager/app_data:/app/data
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.crawl-manager.rule=Host(`admin.startover.co.kr`)
|
||||
- traefik.http.routers.crawl-manager.entrypoints=websecure,web
|
||||
- traefik.http.routers.crawl-manager.tls=true
|
||||
- traefik.http.routers.crawl-manager.tls.certresolver=le
|
||||
- traefik.http.services.crawl-manager.loadbalancer.server.port=3000
|
||||
|
||||
crawl-manager-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: crawl-manager-db
|
||||
restart: always
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- /home/crawl-manager/postgres_data:/var/lib/postgresql/data
|
||||
- ./src/migrations/init.sql:/docker-entrypoint-initdb.d/01-init.sql
|
||||
ports:
|
||||
- "11137:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U crawler -d crawler"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
default:
|
||||
external:
|
||||
name: toktork_server_default
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "crawl-manager",
|
||||
"version": "1.0.0",
|
||||
"description": "크롤링 관리 솔루션",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "node --watch src/app.js",
|
||||
"migrate": "node src/migrations/run.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"pg": "^8.13.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"axios": "^1.7.0",
|
||||
"ejs": "^3.1.10",
|
||||
"dotenv": "^16.4.0",
|
||||
"https-proxy-agent": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
REM ==========================================
|
||||
REM Crawl Manager 로컬 실행 (Windows Docker)
|
||||
REM ==========================================
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo Crawl Manager 로컬 실행
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
REM 기존 컨테이너 정리
|
||||
echo [1/3] 기존 컨테이너 정리...
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
|
||||
REM 빌드 & 실행
|
||||
echo [2/3] Docker 빌드 및 실행...
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
REM 상태 확인
|
||||
echo [3/3] 상태 확인...
|
||||
timeout /t 5 /nobreak >nul
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo 실행 완료!
|
||||
echo 관리자: http://localhost:3000/admin
|
||||
echo ID: chpark@admin.co.kr
|
||||
echo DB 외부접속: localhost:11137
|
||||
echo ==========================================
|
||||
echo.
|
||||
pause
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
const apiRouter = require('./routes/api');
|
||||
const { router: publicRouter, domainRouter } = require('./routes/public');
|
||||
const { initScheduler } = require('./services/scheduler');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// ===== 미들웨어 =====
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// 정적 파일
|
||||
app.use('/public', express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// EJS 템플릿
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, '..', 'views'));
|
||||
|
||||
// ===== 도메인 기반 라우팅 (최우선) =====
|
||||
app.use(domainRouter);
|
||||
|
||||
// ===== Basic Auth (관리자 영역만) =====
|
||||
function adminAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="Crawl Manager Admin"');
|
||||
return res.status(401).send('인증이 필요합니다');
|
||||
}
|
||||
|
||||
const [user, pass] = Buffer.from(authHeader.split(' ')[1], 'base64').toString().split(':');
|
||||
if (user === process.env.ADMIN_USER && pass === process.env.ADMIN_PASS) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="Crawl Manager Admin"');
|
||||
res.status(401).send('인증 실패');
|
||||
}
|
||||
|
||||
// ===== 관리자 페이지 =====
|
||||
app.get('/admin', adminAuth, (req, res) => {
|
||||
res.render('admin/dashboard');
|
||||
});
|
||||
|
||||
app.get('/admin/sites', adminAuth, (req, res) => {
|
||||
res.render('admin/sites');
|
||||
});
|
||||
|
||||
app.get('/admin/sites/:id', adminAuth, (req, res) => {
|
||||
res.render('admin/site-detail', { siteId: req.params.id });
|
||||
});
|
||||
|
||||
app.get('/admin/adsense', adminAuth, (req, res) => {
|
||||
res.render('admin/adsense');
|
||||
});
|
||||
|
||||
app.get('/admin/domains', adminAuth, (req, res) => {
|
||||
res.render('admin/domains');
|
||||
});
|
||||
|
||||
app.get('/admin/logs', adminAuth, (req, res) => {
|
||||
res.render('admin/logs');
|
||||
});
|
||||
|
||||
// ===== API =====
|
||||
app.use('/api', adminAuth, apiRouter);
|
||||
|
||||
// ===== 공개 사이트 =====
|
||||
app.use('/', publicRouter);
|
||||
|
||||
// ===== 시작 =====
|
||||
async function start() {
|
||||
try {
|
||||
// DB 연결 확인
|
||||
await db.query('SELECT 1');
|
||||
console.log('[DB] PostgreSQL 연결 성공');
|
||||
|
||||
// 스케줄러 초기화
|
||||
await initScheduler();
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[APP] 서버 시작: http://0.0.0.0:${PORT}`);
|
||||
console.log(`[APP] 관리자: http://localhost:${PORT}/admin`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[APP] 시작 실패:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,20 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'crawl_manager',
|
||||
user: process.env.DB_USER || 'crawl_admin',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected error on idle client', err);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
query: (text, params) => pool.query(text, params),
|
||||
pool,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 크롤링 관리 솔루션 DB 스키마
|
||||
|
||||
-- 크롤링 대상 사이트
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
-- CSS 셀렉터 / 파싱 규칙 (JSON)
|
||||
parse_rules JSONB DEFAULT '{}',
|
||||
-- 크론 스케줄 (예: "0 6 * * *" = 매일 06시)
|
||||
cron_schedule VARCHAR(100) DEFAULT '',
|
||||
-- 스케줄 활성화 여부
|
||||
schedule_active BOOLEAN DEFAULT FALSE,
|
||||
-- 공개 사이트 슬러그 (도메인별 매핑용)
|
||||
slug VARCHAR(100) UNIQUE,
|
||||
-- 공개 페이지 템플릿 이름
|
||||
template VARCHAR(100) DEFAULT 'default',
|
||||
-- 상태
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
last_crawled_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 크롤링 결과 저장
|
||||
CREATE TABLE IF NOT EXISTS crawl_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
-- 원본 HTML 전체
|
||||
raw_html TEXT,
|
||||
-- 파싱된 데이터 (JSON)
|
||||
parsed_data JSONB DEFAULT '[]',
|
||||
-- 최종 렌더링용 HTML
|
||||
rendered_html TEXT,
|
||||
-- 크롤링 상태
|
||||
status VARCHAR(20) DEFAULT 'success',
|
||||
error_message TEXT,
|
||||
crawled_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 최신 크롤링 결과 빠른 조회용 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_crawl_results_site_latest
|
||||
ON crawl_results(site_id, crawled_at DESC);
|
||||
|
||||
-- AdSense 설정
|
||||
CREATE TABLE IF NOT EXISTS adsense_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
client_id VARCHAR(100) NOT NULL,
|
||||
-- 광고 슬롯들 (JSON: {top: "slot1", middle: "slot2", bottom: "slot3"})
|
||||
slots JSONB DEFAULT '{}',
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 사이트별 AdSense 연결
|
||||
ALTER TABLE sites ADD COLUMN IF NOT EXISTS adsense_config_id INTEGER REFERENCES adsense_configs(id);
|
||||
|
||||
-- 도메인 매핑 (하나의 사이트를 여러 도메인에서 서비스)
|
||||
CREATE TABLE IF NOT EXISTS domain_mappings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL UNIQUE,
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
adsense_config_id INTEGER REFERENCES adsense_configs(id),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 크롤링 로그
|
||||
CREATE TABLE IF NOT EXISTS crawl_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 최신 결과만 빠르게 가져오는 뷰
|
||||
CREATE OR REPLACE VIEW latest_crawl_results AS
|
||||
SELECT DISTINCT ON (site_id)
|
||||
cr.*,
|
||||
s.name AS site_name,
|
||||
s.slug AS site_slug
|
||||
FROM crawl_results cr
|
||||
JOIN sites s ON s.id = cr.site_id
|
||||
WHERE cr.status = 'success'
|
||||
ORDER BY site_id, crawled_at DESC;
|
||||
@@ -0,0 +1,18 @@
|
||||
require('dotenv').config();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../db');
|
||||
|
||||
async function migrate() {
|
||||
const sql = fs.readFileSync(path.join(__dirname, 'init.sql'), 'utf-8');
|
||||
try {
|
||||
await db.query(sql);
|
||||
console.log('[MIGRATE] 마이그레이션 완료');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('[MIGRATE] 실패:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
@@ -0,0 +1,240 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const { crawlSite } = require('../services/crawler');
|
||||
const { updateSchedule, getActiveJobs } = require('../services/scheduler');
|
||||
|
||||
// ===================== 사이트 CRUD =====================
|
||||
|
||||
// 목록
|
||||
router.get('/sites', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query(`
|
||||
SELECT s.*, ac.name AS adsense_name,
|
||||
(SELECT COUNT(*) FROM crawl_results WHERE site_id = s.id) AS crawl_count
|
||||
FROM sites s
|
||||
LEFT JOIN adsense_configs ac ON ac.id = s.adsense_config_id
|
||||
ORDER BY s.id
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 단건 조회
|
||||
router.get('/sites/:id', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query('SELECT * FROM sites WHERE id = $1', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 생성
|
||||
router.post('/sites', async (req, res) => {
|
||||
try {
|
||||
const { name, url, description, parse_rules, slug, template, adsense_config_id } = req.body;
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO sites (name, url, description, parse_rules, slug, template, adsense_config_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[name, url, description || '', parse_rules || {}, slug || null, template || 'default', adsense_config_id || null]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 수정
|
||||
router.put('/sites/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, url, description, parse_rules, slug, template, adsense_config_id } = req.body;
|
||||
const { rows } = await db.query(
|
||||
`UPDATE sites SET name=$1, url=$2, description=$3, parse_rules=$4,
|
||||
slug=$5, template=$6, adsense_config_id=$7, updated_at=NOW()
|
||||
WHERE id=$8 RETURNING *`,
|
||||
[name, url, description, parse_rules, slug || null, template, adsense_config_id || null, req.params.id]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 삭제
|
||||
router.delete('/sites/:id', async (req, res) => {
|
||||
try {
|
||||
await db.query('DELETE FROM sites WHERE id = $1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===================== 크롤링 =====================
|
||||
|
||||
// 즉시 크롤링 실행
|
||||
router.post('/sites/:id/crawl', async (req, res) => {
|
||||
try {
|
||||
const result = await crawlSite(parseInt(req.params.id));
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 크롤링 결과 목록
|
||||
router.get('/sites/:id/results', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const { rows } = await db.query(
|
||||
`SELECT id, site_id, status, error_message, crawled_at,
|
||||
jsonb_array_length(COALESCE(parsed_data->'items', '[]'::jsonb)) AS item_count
|
||||
FROM crawl_results WHERE site_id = $1 ORDER BY crawled_at DESC LIMIT $2`,
|
||||
[req.params.id, limit]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 특정 크롤링 결과 상세 (파싱 데이터)
|
||||
router.get('/results/:id', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
'SELECT * FROM crawl_results WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===================== 스케줄 =====================
|
||||
|
||||
// 스케줄 업데이트
|
||||
router.put('/sites/:id/schedule', async (req, res) => {
|
||||
try {
|
||||
const { cron_schedule, schedule_active } = req.body;
|
||||
await updateSchedule(parseInt(req.params.id), cron_schedule, schedule_active);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 활성 스케줄 목록
|
||||
router.get('/schedules/active', async (req, res) => {
|
||||
res.json({ active_site_ids: getActiveJobs() });
|
||||
});
|
||||
|
||||
// ===================== AdSense =====================
|
||||
|
||||
// 목록
|
||||
router.get('/adsense', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query('SELECT * FROM adsense_configs ORDER BY id');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 생성
|
||||
router.post('/adsense', async (req, res) => {
|
||||
try {
|
||||
const { name, client_id, slots } = req.body;
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO adsense_configs (name, client_id, slots) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[name, client_id, slots || {}]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 수정
|
||||
router.put('/adsense/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, client_id, slots, is_active } = req.body;
|
||||
const { rows } = await db.query(
|
||||
`UPDATE adsense_configs SET name=$1, client_id=$2, slots=$3, is_active=$4, updated_at=NOW()
|
||||
WHERE id=$5 RETURNING *`,
|
||||
[name, client_id, slots, is_active, req.params.id]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 삭제
|
||||
router.delete('/adsense/:id', async (req, res) => {
|
||||
try {
|
||||
await db.query('DELETE FROM adsense_configs WHERE id = $1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===================== 도메인 매핑 =====================
|
||||
|
||||
router.get('/domains', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query(`
|
||||
SELECT d.*, s.name AS site_name FROM domain_mappings d
|
||||
LEFT JOIN sites s ON s.id = d.site_id ORDER BY d.id
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/domains', async (req, res) => {
|
||||
try {
|
||||
const { domain, site_id, adsense_config_id } = req.body;
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO domain_mappings (domain, site_id, adsense_config_id) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[domain, site_id, adsense_config_id || null]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/domains/:id', async (req, res) => {
|
||||
try {
|
||||
await db.query('DELETE FROM domain_mappings WHERE id = $1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===================== 로그 =====================
|
||||
|
||||
router.get('/logs', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const { rows } = await db.query(`
|
||||
SELECT l.*, s.name AS site_name FROM crawl_logs l
|
||||
LEFT JOIN sites s ON s.id = l.site_id
|
||||
ORDER BY l.created_at DESC LIMIT $1
|
||||
`, [limit]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,73 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
|
||||
/**
|
||||
* 공개 사이트 라우터
|
||||
* - slug 기반: /s/torrent-rank → sites.slug = 'torrent-rank'의 최신 rendered_html 반환
|
||||
* - 도메인 기반: Host 헤더로 domain_mappings 조회
|
||||
*/
|
||||
|
||||
// slug 기반 접근
|
||||
router.get('/s/:slug', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await db.query(`
|
||||
SELECT cr.rendered_html
|
||||
FROM crawl_results cr
|
||||
JOIN sites s ON s.id = cr.site_id
|
||||
WHERE s.slug = $1 AND cr.status = 'success'
|
||||
ORDER BY cr.crawled_at DESC LIMIT 1
|
||||
`, [req.params.slug]);
|
||||
|
||||
if (rows.length === 0 || !rows[0].rendered_html) {
|
||||
return res.status(404).send('<h1>페이지를 찾을 수 없습니다</h1><p>아직 크롤링 데이터가 없습니다.</p>');
|
||||
}
|
||||
|
||||
res.type('html').send(rows[0].rendered_html);
|
||||
} catch (err) {
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
// 도메인 기반 접근 (미들웨어로 사용)
|
||||
async function domainRouter(req, res, next) {
|
||||
// 관리자 경로는 무시
|
||||
if (req.path.startsWith('/admin') || req.path.startsWith('/api') || req.path.startsWith('/s/')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// 루트 경로일 때만 도메인 매핑 처리
|
||||
if (req.path !== '/' && req.path !== '/index.html') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const host = req.hostname;
|
||||
|
||||
try {
|
||||
const { rows } = await db.query(`
|
||||
SELECT dm.site_id, dm.adsense_config_id
|
||||
FROM domain_mappings dm
|
||||
WHERE dm.domain = $1 AND dm.is_active = TRUE
|
||||
`, [host]);
|
||||
|
||||
if (rows.length === 0) return next();
|
||||
|
||||
const siteId = rows[0].site_id;
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT rendered_html FROM crawl_results
|
||||
WHERE site_id = $1 AND status = 'success'
|
||||
ORDER BY crawled_at DESC LIMIT 1
|
||||
`, [siteId]);
|
||||
|
||||
if (result.rows.length === 0 || !result.rows[0].rendered_html) {
|
||||
return res.status(404).send('<h1>아직 데이터가 없습니다</h1>');
|
||||
}
|
||||
|
||||
res.type('html').send(result.rows[0].rendered_html);
|
||||
} catch (err) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { router, domainRouter };
|
||||
@@ -0,0 +1,333 @@
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const https = require('https');
|
||||
const db = require('../db');
|
||||
|
||||
// SSL 인증서 무시 (자체 서명 등)
|
||||
const axiosInstance = axios.create({
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'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',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 사이트를 크롤링하고 DB에 저장
|
||||
*/
|
||||
async function crawlSite(siteId) {
|
||||
// 사이트 정보 조회
|
||||
const { rows } = await db.query('SELECT * FROM sites WHERE id = $1', [siteId]);
|
||||
if (rows.length === 0) throw new Error(`Site ${siteId} not found`);
|
||||
const site = rows[0];
|
||||
|
||||
await logCrawl(siteId, 'crawl_start', `크롤링 시작: ${site.url}`);
|
||||
|
||||
try {
|
||||
// 1. HTML 가져오기
|
||||
const response = await axiosInstance.get(site.url);
|
||||
const rawHtml = response.data;
|
||||
|
||||
// 2. 파싱 규칙에 따라 데이터 추출
|
||||
const parseRules = site.parse_rules || {};
|
||||
const parsedData = parseHtml(rawHtml, parseRules);
|
||||
|
||||
// 3. 렌더링용 HTML 생성
|
||||
const adsenseConfig = await getAdsenseConfig(site.adsense_config_id);
|
||||
const renderedHtml = renderPublicPage(site, parsedData, adsenseConfig);
|
||||
|
||||
// 4. DB 저장
|
||||
await db.query(
|
||||
`INSERT INTO crawl_results (site_id, raw_html, parsed_data, rendered_html, status)
|
||||
VALUES ($1, $2, $3, $4, 'success')`,
|
||||
[siteId, rawHtml, JSON.stringify(parsedData), renderedHtml]
|
||||
);
|
||||
|
||||
// 5. 사이트 최종 크롤링 시간 업데이트
|
||||
await db.query(
|
||||
'UPDATE sites SET last_crawled_at = NOW(), updated_at = NOW() WHERE id = $1',
|
||||
[siteId]
|
||||
);
|
||||
|
||||
await logCrawl(siteId, 'crawl_success', `크롤링 완료. ${parsedData.items?.length || 0}개 항목 추출`);
|
||||
|
||||
return { success: true, itemCount: parsedData.items?.length || 0 };
|
||||
|
||||
} catch (err) {
|
||||
// 에러 기록
|
||||
await db.query(
|
||||
`INSERT INTO crawl_results (site_id, status, error_message)
|
||||
VALUES ($1, 'error', $2)`,
|
||||
[siteId, err.message]
|
||||
);
|
||||
await logCrawl(siteId, 'crawl_error', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 파싱 - parse_rules에 따라 데이터 추출
|
||||
*
|
||||
* parse_rules 형식:
|
||||
* {
|
||||
* "container": "table.easy-table tbody tr", // 반복 항목 컨테이너 CSS 셀렉터
|
||||
* "fields": {
|
||||
* "rank": { "selector": "td:nth-child(1)", "type": "text" },
|
||||
* "name": { "selector": "td:nth-child(2)", "type": "text" },
|
||||
* "url": { "selector": "td:nth-child(3) a", "type": "attr", "attr": "href" },
|
||||
* "url_text": { "selector": "td:nth-child(3)", "type": "text" },
|
||||
* "features": { "selector": "td:nth-child(4)", "type": "text" }
|
||||
* },
|
||||
* "meta": {
|
||||
* "title": { "selector": "h1.entry-title", "type": "text" },
|
||||
* "date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
function parseHtml(html, rules) {
|
||||
const $ = cheerio.load(html);
|
||||
const result = { items: [], meta: {} };
|
||||
|
||||
// 메타 정보 추출
|
||||
if (rules.meta) {
|
||||
for (const [key, rule] of Object.entries(rules.meta)) {
|
||||
result.meta[key] = extractValue($, $(rule.selector).first(), rule);
|
||||
}
|
||||
}
|
||||
|
||||
// 항목 추출
|
||||
if (rules.container && rules.fields) {
|
||||
$(rules.container).each((idx, el) => {
|
||||
const item = {};
|
||||
let hasData = false;
|
||||
|
||||
for (const [key, rule] of Object.entries(rules.fields)) {
|
||||
const target = $(el).find(rule.selector).first();
|
||||
item[key] = extractValue($, target, rule);
|
||||
if (item[key]) hasData = true;
|
||||
}
|
||||
|
||||
// 비활성(취소선) 체크
|
||||
const rowHtml = $(el).html() || '';
|
||||
item._inactive = rowHtml.includes('<del>');
|
||||
|
||||
if (hasData) {
|
||||
item._index = idx + 1;
|
||||
result.items.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 규칙이 없으면 기본 정보만
|
||||
if (!rules.container) {
|
||||
result.meta.title = $('title').text().trim();
|
||||
result.meta.description = $('meta[name="description"]').attr('content') || '';
|
||||
result.meta.rawTextPreview = $('body').text().trim().substring(0, 500);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractValue($, el, rule) {
|
||||
if (!el || el.length === 0) return '';
|
||||
switch (rule.type) {
|
||||
case 'attr':
|
||||
return el.attr(rule.attr) || '';
|
||||
case 'html':
|
||||
return el.html() || '';
|
||||
case 'text':
|
||||
default:
|
||||
return el.text().trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 페이지용 HTML 렌더링
|
||||
*/
|
||||
function renderPublicPage(site, parsedData, adsenseConfig) {
|
||||
const items = parsedData.items || [];
|
||||
const meta = parsedData.meta || {};
|
||||
const ads = adsenseConfig || {};
|
||||
const now = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
|
||||
const activeItems = items.filter(i => !i._inactive);
|
||||
const inactiveItems = items.filter(i => i._inactive);
|
||||
|
||||
// 순위 카드 HTML 생성
|
||||
let cardsHtml = '';
|
||||
activeItems.forEach((item, idx) => {
|
||||
const rank = item.rank || item._index || (idx + 1);
|
||||
const rankClass = rank == 1 ? 'r1' : rank == 2 ? 'r2' : rank == 3 ? 'r3' : '';
|
||||
|
||||
// 별점 + 태그
|
||||
const stars = (item.features || '').match(/★/g);
|
||||
const starCount = stars ? stars.length : 0;
|
||||
const tagText = (item.features || '').replace(/★/g, '').trim();
|
||||
const starsHtml = starCount > 0 ? `<span class="stars">${'★'.repeat(starCount)}</span>` : '';
|
||||
const tagHtml = tagText ? `<span class="feature-tag">${escapeHtml(tagText)}</span>` : '';
|
||||
|
||||
cardsHtml += `
|
||||
<a href="${escapeHtml(item.url || item.url_text || '#')}" class="rank-card" target="_blank" rel="noopener noreferrer nofollow">
|
||||
<div class="rank-num ${rankClass}">${rank}</div>
|
||||
<div class="rank-body">
|
||||
<div class="rank-name">${escapeHtml(item.name || '')}</div>
|
||||
<div class="rank-url">${escapeHtml(item.url_text || item.url || '')}</div>
|
||||
${(starsHtml || tagHtml) ? `<div class="rank-features">${starsHtml}${tagHtml}</div>` : ''}
|
||||
</div>
|
||||
<span class="rank-arrow">›</span>
|
||||
</a>`;
|
||||
|
||||
// 5번째 뒤 중간 광고
|
||||
if (idx === 4 && ads.client_id) {
|
||||
cardsHtml += renderAdBlock(ads.client_id, ads.slots?.middle || '');
|
||||
}
|
||||
});
|
||||
|
||||
// 비활성 사이트
|
||||
let inactiveHtml = '';
|
||||
if (inactiveItems.length > 0) {
|
||||
inactiveHtml = `
|
||||
<div class="section-header" style="margin-top:2rem;">
|
||||
<h2 style="color:var(--text-muted);">접속 불가 사이트</h2>
|
||||
<span class="badge" style="background:var(--danger);">확인 필요</span>
|
||||
</div>
|
||||
<div class="rank-list">
|
||||
${inactiveItems.map(item => `
|
||||
<div class="rank-card inactive">
|
||||
<div class="rank-num">✗</div>
|
||||
<div class="rank-body">
|
||||
<div class="rank-name">${escapeHtml(item.name || '')}</div>
|
||||
<div class="rank-url">${escapeHtml(item.url_text || '')}</div>
|
||||
</div>
|
||||
<span class="rank-arrow" style="opacity:0.3;">›</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const adsenseScript = ads.client_id
|
||||
? `<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${escapeHtml(ads.client_id)}" crossorigin="anonymous"></script>`
|
||||
: '';
|
||||
|
||||
const topAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.top || '') : '';
|
||||
const bottomAd = ads.client_id ? renderAdBlock(ads.client_id, ads.slots?.bottom || '') : '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="${escapeHtml(site.description || '')}">
|
||||
<title>${escapeHtml(site.name || 'Torrent Rank')}</title>
|
||||
${adsenseScript}
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎯</text></svg>">
|
||||
<style>
|
||||
:root{--primary:#6c5ce7;--primary-light:#a29bfe;--bg:#0a0a1a;--bg-card:#12122a;--bg-card-hover:#1a1a3e;--text:#e0e0ee;--text-muted:#7878aa;--accent:#00cec9;--gold:#ffd700;--silver:#c0c0c0;--bronze:#cd7f32;--danger:#ff6b6b;--border:#1e1e44;--star:#f9ca24}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh}
|
||||
.header{background:linear-gradient(135deg,#0a0a2e 0%,#1a0a3e 50%,#0a1a3e 100%);padding:2.5rem 1rem 2rem;text-align:center;border-bottom:1px solid var(--border);position:relative;overflow:hidden}
|
||||
.header h1{font-size:1.8rem;font-weight:800;color:#fff;position:relative;z-index:1}
|
||||
.version-badge{display:inline-block;background:var(--accent);color:#000;font-size:.7rem;font-weight:700;padding:.2rem .6rem;border-radius:12px;margin-left:.5rem;vertical-align:middle}
|
||||
.sub-info{color:var(--text-muted);font-size:.82rem;margin-top:.6rem;position:relative;z-index:1}
|
||||
.stats-row{display:inline-flex;gap:1rem;margin-top:1rem;position:relative;z-index:1;flex-wrap:wrap;justify-content:center}
|
||||
.stat-chip{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.08);padding:.35rem .9rem;border-radius:20px;font-size:.75rem;color:var(--accent)}
|
||||
.container{max-width:960px;margin:0 auto;padding:1.5rem 1rem}
|
||||
.ad-box{background:var(--bg-card);border:1px dashed var(--border);border-radius:10px;padding:.8rem;margin:1.2rem 0;text-align:center;min-height:100px}
|
||||
.ad-box .ad-label{font-size:.65rem;color:var(--text-muted);margin-bottom:.3rem;text-transform:uppercase;letter-spacing:1px}
|
||||
.section-header{display:flex;align-items:center;gap:.6rem;margin:1.8rem 0 1rem;padding-bottom:.6rem;border-bottom:2px solid var(--border)}
|
||||
.section-header h2{font-size:1.15rem;font-weight:700;color:#fff}
|
||||
.badge{background:var(--primary);color:#fff;font-size:.7rem;padding:.15rem .5rem;border-radius:10px}
|
||||
.rank-list{display:flex;flex-direction:column;gap:.6rem}
|
||||
.rank-card{display:grid;grid-template-columns:48px 1fr auto;align-items:center;gap:1rem;background:var(--bg-card);border:1px solid var(--border);border-radius:14px;padding:1rem 1.2rem;transition:all .2s;text-decoration:none;color:inherit}
|
||||
.rank-card:hover{background:var(--bg-card-hover);border-color:var(--primary);transform:translateY(-2px);box-shadow:0 8px 25px rgba(108,92,231,.15)}
|
||||
.rank-card.inactive{opacity:.45;border-style:dashed}
|
||||
.rank-num{width:48px;height:48px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:1.1rem;background:var(--border);color:var(--text-muted);flex-shrink:0}
|
||||
.rank-num.r1{background:linear-gradient(135deg,#ffd700,#f0c800);color:#1a1a00;box-shadow:0 4px 15px rgba(255,215,0,.3)}
|
||||
.rank-num.r2{background:linear-gradient(135deg,#e0e0e0,#b0b0b0);color:#1a1a1a;box-shadow:0 4px 12px rgba(192,192,192,.2)}
|
||||
.rank-num.r3{background:linear-gradient(135deg,#cd7f32,#b06820);color:#fff;box-shadow:0 4px 12px rgba(205,127,50,.2)}
|
||||
.rank-body{min-width:0}
|
||||
.rank-name{font-size:1.05rem;font-weight:700;color:#fff;margin-bottom:.2rem}
|
||||
.rank-card.inactive .rank-name{text-decoration:line-through;color:var(--text-muted)}
|
||||
.rank-url{font-size:.78rem;color:var(--accent);word-break:break-all;opacity:.85}
|
||||
.rank-features{display:inline-flex;align-items:center;gap:.3rem;margin-top:.3rem}
|
||||
.feature-tag{background:rgba(108,92,231,.15);color:var(--primary-light);font-size:.7rem;padding:.15rem .5rem;border-radius:6px;font-weight:600}
|
||||
.stars{color:var(--star);font-size:.8rem;letter-spacing:1px}
|
||||
.rank-arrow{color:var(--text-muted);font-size:1.3rem;transition:all .2s;flex-shrink:0}
|
||||
.rank-card:hover .rank-arrow{color:var(--accent);transform:translateX(3px)}
|
||||
.notice-box{background:linear-gradient(135deg,rgba(255,107,107,.08),rgba(255,107,107,.03));border:1px solid rgba(255,107,107,.2);border-radius:12px;padding:1.2rem 1.5rem;margin:1.5rem 0;font-size:.85rem;line-height:1.7}
|
||||
.notice-box h3{color:var(--danger);font-size:.9rem;margin-bottom:.5rem}
|
||||
.notice-box p{color:var(--text-muted)}
|
||||
.footer{text-align:center;padding:2rem 1rem;margin-top:2rem;border-top:1px solid var(--border);color:var(--text-muted);font-size:.75rem}
|
||||
.footer a{color:var(--primary-light);text-decoration:none}
|
||||
@media(max-width:640px){.header h1{font-size:1.3rem}.version-badge{display:block;margin:.5rem auto 0;width:fit-content}.rank-card{grid-template-columns:40px 1fr auto;padding:.8rem;gap:.7rem}.rank-num{width:40px;height:40px;font-size:.95rem;border-radius:10px}.rank-name{font-size:.95rem}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>${escapeHtml(site.name)}<span class="version-badge">${escapeHtml(meta.title?.match(/ver\.?([\d.]+)/)?.[1] || now.split(' ')[0])}</span></h1>
|
||||
<p class="sub-info">업데이트: ${now}</p>
|
||||
<div class="stats-row">
|
||||
<span class="stat-chip">${activeItems.length}개 사이트</span>
|
||||
<span class="stat-chip">비회원제 (가입 불필요)</span>
|
||||
<span class="stat-chip">자동 갱신</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="container">
|
||||
${topAd}
|
||||
<div class="notice-box">
|
||||
<h3>⚠️ 이용 시 주의사항</h3>
|
||||
<p>토렌트 다운로드 시 동시에 업로드에도 참여하게 됩니다. 저작권이 있는 파일을 다운로드할 경우 법적 책임이 발생할 수 있으며, 압축파일(*.zip) 형태의 토렌트는 악성코드 포함 가능성이 있으니 주의하세요.</p>
|
||||
</div>
|
||||
<div class="section-header">
|
||||
<h2>추천 토렌트 사이트 순위</h2>
|
||||
<span class="badge">비회원제</span>
|
||||
</div>
|
||||
<div class="rank-list">
|
||||
${cardsHtml || '<div style="text-align:center;padding:3rem;color:var(--text-muted)"><p>아직 수집된 데이터가 없습니다.</p></div>'}
|
||||
</div>
|
||||
${inactiveHtml}
|
||||
${bottomAd}
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<p>© ${new Date().getFullYear()} ${escapeHtml(site.name)}</p>
|
||||
<p style="margin-top:.3rem;font-size:.7rem;">본 사이트는 정보 제공 목적이며, 불법 다운로드를 조장하지 않습니다.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderAdBlock(clientId, slotId) {
|
||||
if (!clientId) return '';
|
||||
return `
|
||||
<div class="ad-box">
|
||||
<div class="ad-label">Advertisement</div>
|
||||
<ins class="adsbygoogle" style="display:block" data-ad-client="${escapeHtml(clientId)}" data-ad-slot="${escapeHtml(slotId)}" data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async function getAdsenseConfig(configId) {
|
||||
if (!configId) return null;
|
||||
const { rows } = await db.query('SELECT * FROM adsense_configs WHERE id = $1 AND is_active = TRUE', [configId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function logCrawl(siteId, action, message) {
|
||||
await db.query(
|
||||
'INSERT INTO crawl_logs (site_id, action, message) VALUES ($1, $2, $3)',
|
||||
[siteId, action, message]
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { crawlSite, parseHtml };
|
||||
@@ -0,0 +1,88 @@
|
||||
const cron = require('node-cron');
|
||||
const db = require('../db');
|
||||
const { crawlSite } = require('./crawler');
|
||||
|
||||
// 활성 스케줄 저장 (siteId -> cronJob)
|
||||
const activeJobs = new Map();
|
||||
|
||||
/**
|
||||
* DB에서 스케줄 설정된 사이트들을 로드하고 크론 등록
|
||||
*/
|
||||
async function initScheduler() {
|
||||
console.log('[SCHEDULER] 스케줄러 초기화...');
|
||||
|
||||
const { rows } = await db.query(
|
||||
'SELECT id, name, cron_schedule FROM sites WHERE schedule_active = TRUE AND cron_schedule != \'\''
|
||||
);
|
||||
|
||||
for (const site of rows) {
|
||||
registerJob(site.id, site.name, site.cron_schedule);
|
||||
}
|
||||
|
||||
console.log(`[SCHEDULER] ${rows.length}개 스케줄 등록 완료`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 크론잡 등록
|
||||
*/
|
||||
function registerJob(siteId, siteName, cronExpression) {
|
||||
// 기존 잡 제거
|
||||
removeJob(siteId);
|
||||
|
||||
if (!cron.validate(cronExpression)) {
|
||||
console.error(`[SCHEDULER] 잘못된 크론 표현식: ${cronExpression} (site: ${siteName})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const job = cron.schedule(cronExpression, async () => {
|
||||
console.log(`[SCHEDULER] 자동 크롤링 시작: ${siteName} (ID: ${siteId})`);
|
||||
try {
|
||||
await crawlSite(siteId);
|
||||
console.log(`[SCHEDULER] 자동 크롤링 완료: ${siteName}`);
|
||||
} catch (err) {
|
||||
console.error(`[SCHEDULER] 자동 크롤링 실패: ${siteName}`, err.message);
|
||||
}
|
||||
}, {
|
||||
timezone: 'Asia/Seoul',
|
||||
});
|
||||
|
||||
activeJobs.set(siteId, job);
|
||||
console.log(`[SCHEDULER] 등록: ${siteName} (${cronExpression})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 크론잡 제거
|
||||
*/
|
||||
function removeJob(siteId) {
|
||||
if (activeJobs.has(siteId)) {
|
||||
activeJobs.get(siteId).stop();
|
||||
activeJobs.delete(siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 업데이트 (관리자 페이지에서 호출)
|
||||
*/
|
||||
async function updateSchedule(siteId, cronExpression, active) {
|
||||
await db.query(
|
||||
'UPDATE sites SET cron_schedule = $1, schedule_active = $2, updated_at = NOW() WHERE id = $3',
|
||||
[cronExpression, active, siteId]
|
||||
);
|
||||
|
||||
if (active && cronExpression) {
|
||||
const { rows } = await db.query('SELECT name FROM sites WHERE id = $1', [siteId]);
|
||||
registerJob(siteId, rows[0]?.name || '', cronExpression);
|
||||
} else {
|
||||
removeJob(siteId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활성 스케줄 목록
|
||||
*/
|
||||
function getActiveJobs() {
|
||||
return Array.from(activeJobs.keys());
|
||||
}
|
||||
|
||||
module.exports = { initScheduler, registerJob, removeJob, updateSchedule, getActiveJobs };
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
echo 컨테이너 중지 중...
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
echo 완료.
|
||||
pause
|
||||
@@ -0,0 +1,115 @@
|
||||
<%- include('layout', { page: 'adsense', pageTitle: 'AdSense 관리', body: `
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>AdSense 설정 목록</h2>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ AdSense 추가</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>이름</th><th>Client ID</th><th>상단 슬롯</th><th>중간 슬롯</th><th>하단 슬롯</th><th>상태</th><th>액션</th></tr></thead>
|
||||
<tbody id="ads-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="adsModal">
|
||||
<div class="modal">
|
||||
<h3 id="ads-modal-title">AdSense 추가</h3>
|
||||
<input type="hidden" id="ads-edit-id">
|
||||
<div class="form-group">
|
||||
<label>이름 (구분용)</label>
|
||||
<input id="ads-name" placeholder="예: 메인사이트 애드센스">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client ID (ca-pub-XXXX)</label>
|
||||
<input id="ads-client" placeholder="ca-pub-1234567890123456">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>상단 광고 슬롯 ID</label>
|
||||
<input id="ads-slot-top" placeholder="1234567890">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>중간 광고 슬롯 ID</label>
|
||||
<input id="ads-slot-mid" placeholder="1234567891">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>하단 광고 슬롯 ID</label>
|
||||
<input id="ads-slot-bot" placeholder="1234567892">
|
||||
</div>
|
||||
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
|
||||
<button class="btn btn-outline" onclick="closeModal()">취소</button>
|
||||
<button class="btn btn-primary" onclick="saveAds()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let adsList = [];
|
||||
|
||||
async function loadAds() {
|
||||
adsList = await api('GET', '/api/adsense');
|
||||
document.getElementById('ads-tbody').innerHTML = adsList.map(a => {
|
||||
const slots = a.slots || {};
|
||||
return '<tr><td>' + a.id + '</td><td><strong>' + a.name + '</strong></td><td class="text-muted">' + a.client_id + '</td>' +
|
||||
'<td>' + (slots.top || '-') + '</td><td>' + (slots.middle || '-') + '</td><td>' + (slots.bottom || '-') + '</td>' +
|
||||
'<td><span class="badge badge-' + (a.is_active ? 'success">활성' : 'danger">비활성') + '</span></td>' +
|
||||
'<td class="flex"><button class="btn btn-outline btn-sm" onclick="editAds(' + a.id + ')">수정</button>' +
|
||||
'<button class="btn btn-danger btn-sm" onclick="deleteAds(' + a.id + ')">삭제</button></td></tr>';
|
||||
}).join('') || '<tr><td colspan="8" class="text-muted" style="text-align:center;padding:2rem">AdSense 설정을 추가하세요</td></tr>';
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
document.getElementById('ads-modal-title').textContent = 'AdSense 추가';
|
||||
document.getElementById('ads-edit-id').value = '';
|
||||
['ads-name','ads-client','ads-slot-top','ads-slot-mid','ads-slot-bot'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('adsModal').classList.add('active');
|
||||
}
|
||||
|
||||
function editAds(id) {
|
||||
const a = adsList.find(x => x.id === id);
|
||||
if (!a) return;
|
||||
document.getElementById('ads-modal-title').textContent = 'AdSense 수정';
|
||||
document.getElementById('ads-edit-id').value = a.id;
|
||||
document.getElementById('ads-name').value = a.name;
|
||||
document.getElementById('ads-client').value = a.client_id;
|
||||
document.getElementById('ads-slot-top').value = a.slots?.top || '';
|
||||
document.getElementById('ads-slot-mid').value = a.slots?.middle || '';
|
||||
document.getElementById('ads-slot-bot').value = a.slots?.bottom || '';
|
||||
document.getElementById('adsModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() { document.getElementById('adsModal').classList.remove('active'); }
|
||||
|
||||
async function saveAds() {
|
||||
const data = {
|
||||
name: document.getElementById('ads-name').value,
|
||||
client_id: document.getElementById('ads-client').value,
|
||||
slots: {
|
||||
top: document.getElementById('ads-slot-top').value,
|
||||
middle: document.getElementById('ads-slot-mid').value,
|
||||
bottom: document.getElementById('ads-slot-bot').value,
|
||||
},
|
||||
is_active: true,
|
||||
};
|
||||
if (!data.name || !data.client_id) { toast('이름과 Client ID는 필수입니다', 'error'); return; }
|
||||
const editId = document.getElementById('ads-edit-id').value;
|
||||
if (editId) {
|
||||
await api('PUT', '/api/adsense/' + editId, data);
|
||||
toast('수정 완료');
|
||||
} else {
|
||||
await api('POST', '/api/adsense', data);
|
||||
toast('추가 완료');
|
||||
}
|
||||
closeModal(); loadAds();
|
||||
}
|
||||
|
||||
async function deleteAds(id) {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
await api('DELETE', '/api/adsense/' + id);
|
||||
toast('삭제 완료'); loadAds();
|
||||
}
|
||||
|
||||
loadAds();
|
||||
</script>
|
||||
` }) %>
|
||||
@@ -0,0 +1,56 @@
|
||||
<%- include('layout', { page: 'dashboard', pageTitle: '대시보드', body: `
|
||||
|
||||
<div class="stats-grid" id="stats">
|
||||
<div class="stat-card"><div class="number" id="stat-sites">-</div><div class="label">등록된 사이트</div></div>
|
||||
<div class="stat-card"><div class="number" id="stat-active">-</div><div class="label">스케줄 활성</div></div>
|
||||
<div class="stat-card"><div class="number" id="stat-crawls">-</div><div class="label">총 크롤링 횟수</div></div>
|
||||
<div class="stat-card"><div class="number" id="stat-adsense">-</div><div class="label">AdSense 설정</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>사이트 현황</h2>
|
||||
<a href="/admin/sites" class="btn btn-primary btn-sm">사이트 관리 →</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>사이트명</th><th>URL</th><th>스케줄</th><th>마지막 크롤링</th><th>상태</th><th>공개 URL</th></tr></thead>
|
||||
<tbody id="site-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>최근 로그</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>시간</th><th>사이트</th><th>액션</th><th>메시지</th></tr></thead>
|
||||
<tbody id="log-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadDashboard() {
|
||||
const [sites, adsense, logs] = await Promise.all([
|
||||
api('GET', '/api/sites'),
|
||||
api('GET', '/api/adsense'),
|
||||
api('GET', '/api/logs?limit=10'),
|
||||
]);
|
||||
|
||||
document.getElementById('stat-sites').textContent = sites.length;
|
||||
document.getElementById('stat-active').textContent = sites.filter(s => s.schedule_active).length;
|
||||
document.getElementById('stat-crawls').textContent = sites.reduce((a, s) => a + parseInt(s.crawl_count || 0), 0);
|
||||
document.getElementById('stat-adsense').textContent = adsense.length;
|
||||
|
||||
document.getElementById('site-table').innerHTML = sites.map(s => {
|
||||
const schedBadge = s.schedule_active
|
||||
? '<span class="badge badge-success">' + (s.cron_schedule || 'ON') + '</span>'
|
||||
: '<span class="badge badge-danger">OFF</span>';
|
||||
const slug = s.slug ? '<a href="/s/' + s.slug + '" target="_blank" style="color:var(--primary)">/s/' + s.slug + '</a>' : '-';
|
||||
return '<tr><td><strong>' + s.name + '</strong></td><td class="text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + s.url + '</td><td>' + schedBadge + '</td><td>' + timeAgo(s.last_crawled_at) + '</td><td><span class="badge badge-' + (s.status === 'active' ? 'success' : 'danger') + '">' + s.status + '</span></td><td>' + slug + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="6" class="text-muted" style="text-align:center;padding:2rem">등록된 사이트가 없습니다. <a href="/admin/sites" style="color:var(--primary)">사이트를 추가하세요</a></td></tr>';
|
||||
|
||||
document.getElementById('log-table').innerHTML = logs.map(l =>
|
||||
'<tr><td class="text-muted">' + timeAgo(l.created_at) + '</td><td>' + (l.site_name || '-') + '</td><td><span class="badge badge-info">' + l.action + '</span></td><td>' + (l.message || '').substring(0, 80) + '</td></tr>'
|
||||
).join('') || '<tr><td colspan="4" class="text-muted" style="text-align:center">로그가 없습니다</td></tr>';
|
||||
}
|
||||
loadDashboard();
|
||||
</script>
|
||||
` }) %>
|
||||
@@ -0,0 +1,82 @@
|
||||
<%- include('layout', { page: 'domains', pageTitle: '도메인 매핑', body: `
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>도메인 매핑</h2>
|
||||
<button class="btn btn-primary" onclick="openModal()">+ 도메인 추가</button>
|
||||
</div>
|
||||
<p class="text-muted" style="margin-bottom:1rem;font-size:.82rem">
|
||||
도메인을 특정 사이트에 연결하면, 해당 도메인으로 접속 시 크롤링 결과가 자동으로 표시됩니다.<br>
|
||||
슬러그 기반 접근도 가능합니다: <code>/s/{slug}</code>
|
||||
</p>
|
||||
<table>
|
||||
<thead><tr><th>도메인</th><th>연결 사이트</th><th>상태</th><th>등록일</th><th>액션</th></tr></thead>
|
||||
<tbody id="dom-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="domModal">
|
||||
<div class="modal">
|
||||
<h3>도메인 추가</h3>
|
||||
<div class="form-group">
|
||||
<label>도메인 (서브도메인 포함)</label>
|
||||
<input id="dom-domain" placeholder="rank.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>연결할 사이트</label>
|
||||
<select id="dom-site"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>AdSense 설정 (선택)</label>
|
||||
<select id="dom-adsense"><option value="">사이트 기본값 사용</option></select>
|
||||
</div>
|
||||
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
|
||||
<button class="btn btn-outline" onclick="document.getElementById('domModal').classList.remove('active')">취소</button>
|
||||
<button class="btn btn-primary" onclick="saveDomain()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadDomains() {
|
||||
const [domains, sites, adsense] = await Promise.all([
|
||||
api('GET', '/api/domains'),
|
||||
api('GET', '/api/sites'),
|
||||
api('GET', '/api/adsense'),
|
||||
]);
|
||||
|
||||
document.getElementById('dom-site').innerHTML = sites.map(s => '<option value="' + s.id + '">' + s.name + '</option>').join('');
|
||||
document.getElementById('dom-adsense').innerHTML = '<option value="">사이트 기본값</option>' + adsense.map(a => '<option value="' + a.id + '">' + a.name + '</option>').join('');
|
||||
|
||||
document.getElementById('dom-tbody').innerHTML = domains.map(d =>
|
||||
'<tr><td><strong>' + d.domain + '</strong></td><td>' + (d.site_name || '-') + '</td>' +
|
||||
'<td><span class="badge badge-' + (d.is_active ? 'success">활성' : 'danger">비활성') + '</span></td>' +
|
||||
'<td class="text-muted">' + timeAgo(d.created_at) + '</td>' +
|
||||
'<td><button class="btn btn-danger btn-sm" onclick="deleteDomain(' + d.id + ')">삭제</button></td></tr>'
|
||||
).join('') || '<tr><td colspan="5" class="text-muted" style="text-align:center;padding:2rem">도메인 매핑을 추가하세요</td></tr>';
|
||||
}
|
||||
|
||||
function openModal() { document.getElementById('domModal').classList.add('active'); }
|
||||
|
||||
async function saveDomain() {
|
||||
const data = {
|
||||
domain: document.getElementById('dom-domain').value,
|
||||
site_id: parseInt(document.getElementById('dom-site').value),
|
||||
adsense_config_id: document.getElementById('dom-adsense').value || null,
|
||||
};
|
||||
if (!data.domain) { toast('도메인을 입력하세요', 'error'); return; }
|
||||
await api('POST', '/api/domains', data);
|
||||
toast('도메인 추가 완료');
|
||||
document.getElementById('domModal').classList.remove('active');
|
||||
loadDomains();
|
||||
}
|
||||
|
||||
async function deleteDomain(id) {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
await api('DELETE', '/api/domains/' + id);
|
||||
toast('삭제 완료'); loadDomains();
|
||||
}
|
||||
|
||||
loadDomains();
|
||||
</script>
|
||||
` }) %>
|
||||
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Crawl Manager' %></title>
|
||||
<style>
|
||||
:root{--bg:#111827;--bg2:#1f2937;--bg3:#374151;--text:#f9fafb;--muted:#9ca3af;--primary:#6366f1;--primary-hover:#818cf8;--danger:#ef4444;--success:#22c55e;--warning:#f59e0b;--border:#374151;--radius:8px}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans KR',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex}
|
||||
|
||||
/* 사이드바 */
|
||||
.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);padding:1.5rem 0;flex-shrink:0;position:fixed;top:0;left:0;height:100vh;overflow-y:auto}
|
||||
.sidebar .logo{padding:0 1.2rem 1.5rem;font-size:1.1rem;font-weight:700;color:var(--primary);border-bottom:1px solid var(--border);margin-bottom:1rem}
|
||||
.sidebar nav a{display:flex;align-items:center;gap:.6rem;padding:.7rem 1.2rem;color:var(--muted);text-decoration:none;font-size:.88rem;transition:all .15s}
|
||||
.sidebar nav a:hover,.sidebar nav a.active{color:var(--text);background:var(--bg3)}
|
||||
.sidebar nav a.active{border-left:3px solid var(--primary)}
|
||||
|
||||
/* 메인 */
|
||||
.main{margin-left:220px;flex:1;min-height:100vh}
|
||||
.topbar{background:var(--bg2);border-bottom:1px solid var(--border);padding:.8rem 1.5rem;display:flex;justify-content:space-between;align-items:center}
|
||||
.topbar h1{font-size:1.1rem;font-weight:600}
|
||||
.content{padding:1.5rem}
|
||||
|
||||
/* 카드 */
|
||||
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:1.2rem;margin-bottom:1rem}
|
||||
.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;padding-bottom:.8rem;border-bottom:1px solid var(--border)}
|
||||
.card-header h2{font-size:1rem;font-weight:600}
|
||||
|
||||
/* 버튼 */
|
||||
.btn{padding:.5rem 1rem;border:none;border-radius:var(--radius);cursor:pointer;font-size:.85rem;font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:.4rem}
|
||||
.btn-primary{background:var(--primary);color:#fff}
|
||||
.btn-primary:hover{background:var(--primary-hover)}
|
||||
.btn-danger{background:var(--danger);color:#fff}
|
||||
.btn-danger:hover{opacity:.8}
|
||||
.btn-success{background:var(--success);color:#fff}
|
||||
.btn-warning{background:var(--warning);color:#000}
|
||||
.btn-sm{padding:.35rem .7rem;font-size:.78rem}
|
||||
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
|
||||
.btn-outline:hover{background:var(--bg3)}
|
||||
|
||||
/* 테이블 */
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:.6rem .8rem;text-align:left;border-bottom:1px solid var(--border);font-size:.85rem}
|
||||
th{color:var(--muted);font-weight:500;font-size:.78rem;text-transform:uppercase;letter-spacing:.5px}
|
||||
tr:hover td{background:rgba(255,255,255,.02)}
|
||||
|
||||
/* 폼 */
|
||||
.form-group{margin-bottom:1rem}
|
||||
.form-group label{display:block;font-size:.82rem;color:var(--muted);margin-bottom:.3rem;font-weight:500}
|
||||
.form-group input,.form-group textarea,.form-group select{width:100%;padding:.55rem .8rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:.88rem;font-family:inherit}
|
||||
.form-group input:focus,.form-group textarea:focus,.form-group select:focus{outline:none;border-color:var(--primary)}
|
||||
.form-group textarea{resize:vertical;min-height:80px;font-family:monospace}
|
||||
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
|
||||
|
||||
/* 뱃지 */
|
||||
.badge{display:inline-block;padding:.15rem .5rem;border-radius:10px;font-size:.72rem;font-weight:600}
|
||||
.badge-success{background:rgba(34,197,94,.15);color:var(--success)}
|
||||
.badge-danger{background:rgba(239,68,68,.15);color:var(--danger)}
|
||||
.badge-warning{background:rgba(245,158,11,.15);color:var(--warning)}
|
||||
.badge-info{background:rgba(99,102,241,.15);color:var(--primary-hover)}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center}
|
||||
.modal-overlay.active{display:flex}
|
||||
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:1.5rem;width:90%;max-width:600px;max-height:90vh;overflow-y:auto}
|
||||
.modal h3{margin-bottom:1rem;font-size:1rem}
|
||||
|
||||
/* 유틸 */
|
||||
.text-muted{color:var(--muted)}
|
||||
.text-success{color:var(--success)}
|
||||
.text-danger{color:var(--danger)}
|
||||
.mt-1{margin-top:.5rem}
|
||||
.mb-1{margin-bottom:.5rem}
|
||||
.flex{display:flex;gap:.5rem;align-items:center}
|
||||
.flex-between{display:flex;justify-content:space-between;align-items:center}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}
|
||||
.stat-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:1rem}
|
||||
.stat-card .number{font-size:1.8rem;font-weight:700}
|
||||
.stat-card .label{color:var(--muted);font-size:.8rem;margin-top:.2rem}
|
||||
|
||||
/* 토스트 */
|
||||
.toast{position:fixed;top:1rem;right:1rem;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:.8rem 1.2rem;z-index:200;display:none;font-size:.85rem;box-shadow:0 4px 20px rgba(0,0,0,.3)}
|
||||
.toast.show{display:block;animation:slideIn .3s}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
|
||||
/* 크론 프리셋 */
|
||||
.cron-presets{display:flex;flex-wrap:wrap;gap:.4rem;margin-top:.4rem}
|
||||
.cron-presets .preset{padding:.25rem .6rem;background:var(--bg3);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:.75rem;color:var(--muted)}
|
||||
.cron-presets .preset:hover{border-color:var(--primary);color:var(--text)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="logo">Crawl Manager</div>
|
||||
<nav>
|
||||
<a href="/admin" class="<%= typeof page !== 'undefined' && page === 'dashboard' ? 'active' : '' %>">📊 대시보드</a>
|
||||
<a href="/admin/sites" class="<%= typeof page !== 'undefined' && page === 'sites' ? 'active' : '' %>">🌐 사이트 관리</a>
|
||||
<a href="/admin/adsense" class="<%= typeof page !== 'undefined' && page === 'adsense' ? 'active' : '' %>">💰 AdSense 관리</a>
|
||||
<a href="/admin/domains" class="<%= typeof page !== 'undefined' && page === 'domains' ? 'active' : '' %>">🔗 도메인 매핑</a>
|
||||
<a href="/admin/logs" class="<%= typeof page !== 'undefined' && page === 'logs' ? 'active' : '' %>">📝 로그</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<h1><%= typeof pageTitle !== 'undefined' ? pageTitle : '' %></h1>
|
||||
<span class="text-muted" style="font-size:.8rem">Crawl Manager v1.0</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<%- body %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>
|
||||
function api(method, url, data) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (data) opts.body = JSON.stringify(data);
|
||||
return fetch(url, opts).then(r => r.json());
|
||||
}
|
||||
function toast(msg, type = 'success') {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.style.borderLeftColor = type === 'success' ? 'var(--success)' : type === 'error' ? 'var(--danger)' : 'var(--warning)';
|
||||
el.style.borderLeftWidth = '3px';
|
||||
el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 3000);
|
||||
}
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return '방금';
|
||||
if (m < 60) return m + '분 전';
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return h + '시간 전';
|
||||
return Math.floor(h / 24) + '일 전';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<%- include('layout', { page: 'logs', pageTitle: '크롤링 로그', body: `
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>최근 로그</h2>
|
||||
<button class="btn btn-outline btn-sm" onclick="loadLogs()">새로고침</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th style="width:160px">시간</th><th style="width:120px">사이트</th><th style="width:120px">액션</th><th>메시지</th></tr></thead>
|
||||
<tbody id="logs-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadLogs() {
|
||||
const logs = await api('GET', '/api/logs?limit=100');
|
||||
document.getElementById('logs-tbody').innerHTML = logs.map(l => {
|
||||
const actionClass = l.action.includes('error') ? 'danger' : l.action.includes('success') ? 'success' : 'info';
|
||||
return '<tr><td class="text-muted">' + new Date(l.created_at).toLocaleString('ko-KR') + '</td>' +
|
||||
'<td>' + (l.site_name || '-') + '</td>' +
|
||||
'<td><span class="badge badge-' + actionClass + '">' + l.action + '</span></td>' +
|
||||
'<td style="word-break:break-all">' + (l.message || '') + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="4" class="text-muted" style="text-align:center;padding:2rem">로그가 없습니다</td></tr>';
|
||||
}
|
||||
loadLogs();
|
||||
</script>
|
||||
` }) %>
|
||||
@@ -0,0 +1,135 @@
|
||||
<%- include('layout', { page: 'sites', pageTitle: '사이트 상세', body: `
|
||||
|
||||
<div id="site-info"></div>
|
||||
|
||||
<!-- 스케줄 설정 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>크롤링 스케줄</h2></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>크론 표현식</label>
|
||||
<input id="cron-expr" placeholder="0 6 * * *">
|
||||
<div class="cron-presets">
|
||||
<span class="preset" onclick="setCron('*/5 * * * *')">5분마다</span>
|
||||
<span class="preset" onclick="setCron('0 * * * *')">매시간</span>
|
||||
<span class="preset" onclick="setCron('0 */6 * * *')">6시간마다</span>
|
||||
<span class="preset" onclick="setCron('0 6 * * *')">매일 06시</span>
|
||||
<span class="preset" onclick="setCron('0 6,12,18 * * *')">하루 3회</span>
|
||||
<span class="preset" onclick="setCron('0 0 * * *')">매일 자정</span>
|
||||
<span class="preset" onclick="setCron('0 6 * * 1')">매주 월요일</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>스케줄 활성화</label>
|
||||
<div style="margin-top:.5rem">
|
||||
<label style="display:inline-flex;align-items:center;gap:.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="sched-active" style="width:auto">
|
||||
<span>활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="saveSchedule()">스케줄 저장</button>
|
||||
<button class="btn btn-success" onclick="crawlNow()" style="margin-left:.5rem">지금 크롤링</button>
|
||||
</div>
|
||||
|
||||
<!-- 크롤링 결과 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h2>크롤링 결과</h2></div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>시간</th><th>상태</th><th>항목 수</th><th>에러</th><th>액션</th></tr></thead>
|
||||
<tbody id="results-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 모달 -->
|
||||
<div class="modal-overlay" id="previewModal">
|
||||
<div class="modal" style="max-width:900px">
|
||||
<div class="flex-between mb-1">
|
||||
<h3>미리보기</h3>
|
||||
<button class="btn btn-outline btn-sm" onclick="document.getElementById('previewModal').classList.remove('active')">닫기</button>
|
||||
</div>
|
||||
<div id="preview-tabs" class="flex mb-1">
|
||||
<button class="btn btn-sm btn-primary" onclick="showTab('rendered')">렌더링</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="showTab('parsed')">파싱 데이터</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="showTab('raw')">원본 HTML</button>
|
||||
</div>
|
||||
<div id="tab-rendered"><iframe id="preview-iframe" style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);background:#fff"></iframe></div>
|
||||
<div id="tab-parsed" style="display:none"><pre id="preview-parsed" style="background:var(--bg);padding:1rem;border-radius:var(--radius);max-height:500px;overflow:auto;font-size:.78rem"></pre></div>
|
||||
<div id="tab-raw" style="display:none"><textarea id="preview-raw" readonly style="width:100%;height:500px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;font-size:.75rem;font-family:monospace"></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const siteId = ` + siteId + `;
|
||||
|
||||
async function loadDetail() {
|
||||
const site = await api('GET', '/api/sites/' + siteId);
|
||||
|
||||
document.getElementById('site-info').innerHTML =
|
||||
'<div class="card"><div class="flex-between">' +
|
||||
'<div><h2 style="margin-bottom:.3rem">' + site.name + '</h2><span class="text-muted">' + site.url + '</span>' +
|
||||
(site.slug ? '<br><a href="/s/' + site.slug + '" target="_blank" style="color:var(--primary);font-size:.85rem">공개 페이지: /s/' + site.slug + '</a>' : '') +
|
||||
'</div>' +
|
||||
'<a href="/admin/sites" class="btn btn-outline btn-sm">← 목록</a>' +
|
||||
'</div></div>';
|
||||
|
||||
document.getElementById('cron-expr').value = site.cron_schedule || '';
|
||||
document.getElementById('sched-active').checked = site.schedule_active;
|
||||
|
||||
const results = await api('GET', '/api/sites/' + siteId + '/results');
|
||||
document.getElementById('results-tbody').innerHTML = results.map(r =>
|
||||
'<tr>' +
|
||||
'<td>' + r.id + '</td>' +
|
||||
'<td>' + timeAgo(r.crawled_at) + '</td>' +
|
||||
'<td><span class="badge badge-' + (r.status === 'success' ? 'success' : 'danger') + '">' + r.status + '</span></td>' +
|
||||
'<td>' + (r.item_count || 0) + '개</td>' +
|
||||
'<td class="text-muted">' + (r.error_message || '-').substring(0, 50) + '</td>' +
|
||||
'<td><button class="btn btn-outline btn-sm" onclick="preview(' + r.id + ')">보기</button></td>' +
|
||||
'</tr>'
|
||||
).join('') || '<tr><td colspan="6" class="text-muted" style="text-align:center">크롤링 결과가 없습니다</td></tr>';
|
||||
}
|
||||
|
||||
function setCron(expr) {
|
||||
document.getElementById('cron-expr').value = expr;
|
||||
}
|
||||
|
||||
async function saveSchedule() {
|
||||
await api('PUT', '/api/sites/' + siteId + '/schedule', {
|
||||
cron_schedule: document.getElementById('cron-expr').value,
|
||||
schedule_active: document.getElementById('sched-active').checked,
|
||||
});
|
||||
toast('스케줄 저장 완료');
|
||||
}
|
||||
|
||||
async function crawlNow() {
|
||||
toast('크롤링 시작...', 'warning');
|
||||
try {
|
||||
const r = await api('POST', '/api/sites/' + siteId + '/crawl');
|
||||
if (r.error) throw new Error(r.error);
|
||||
toast('크롤링 완료! ' + (r.itemCount || 0) + '개 항목');
|
||||
loadDetail();
|
||||
} catch(e) {
|
||||
toast('크롤링 실패: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function preview(resultId) {
|
||||
const r = await api('GET', '/api/results/' + resultId);
|
||||
const iframe = document.getElementById('preview-iframe');
|
||||
iframe.srcdoc = r.rendered_html || '<p>렌더링 데이터 없음</p>';
|
||||
document.getElementById('preview-parsed').textContent = JSON.stringify(r.parsed_data, null, 2);
|
||||
document.getElementById('preview-raw').value = r.raw_html || '';
|
||||
document.getElementById('previewModal').classList.add('active');
|
||||
showTab('rendered');
|
||||
}
|
||||
|
||||
function showTab(name) {
|
||||
['rendered','parsed','raw'].forEach(t => {
|
||||
document.getElementById('tab-' + t).style.display = t === name ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
loadDetail();
|
||||
</script>
|
||||
` }) %>
|
||||
@@ -0,0 +1,183 @@
|
||||
<%- include('layout', { page: 'sites', pageTitle: '사이트 관리', body: `
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>크롤링 대상 사이트</h2>
|
||||
<button class="btn btn-primary" onclick="openAddModal()">+ 사이트 추가</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>사이트명</th><th>URL</th><th>슬러그</th><th>스케줄</th><th>마지막 크롤링</th><th>액션</th></tr></thead>
|
||||
<tbody id="sites-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 사이트 추가/수정 모달 -->
|
||||
<div class="modal-overlay" id="siteModal">
|
||||
<div class="modal">
|
||||
<h3 id="modal-title">사이트 추가</h3>
|
||||
<input type="hidden" id="edit-id">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>사이트명 *</label>
|
||||
<input id="f-name" placeholder="예: 토렌트 순위">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>슬러그 (공개 URL용)</label>
|
||||
<input id="f-slug" placeholder="예: torrent-rank">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>크롤링 URL *</label>
|
||||
<input id="f-url" placeholder="http://jaewook.net/archives/2613">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>설명</label>
|
||||
<input id="f-desc" placeholder="사이트 설명">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>파싱 규칙 (JSON) - 아래 예시를 참고하세요</label>
|
||||
<textarea id="f-rules" rows="12" style="font-size:.78rem">{
|
||||
"container": "table.easy-table tbody tr",
|
||||
"fields": {
|
||||
"rank": { "selector": "td:nth-child(1)", "type": "text" },
|
||||
"name": { "selector": "td:nth-child(2)", "type": "text" },
|
||||
"url": { "selector": "td:nth-child(3) a", "type": "attr", "attr": "href" },
|
||||
"url_text": { "selector": "td:nth-child(3)", "type": "text" },
|
||||
"features": { "selector": "td:nth-child(4)", "type": "text" }
|
||||
},
|
||||
"meta": {
|
||||
"title": { "selector": "h1.entry-title", "type": "text" },
|
||||
"date": { "selector": "time.entry-date", "type": "attr", "attr": "datetime" }
|
||||
}
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>AdSense 설정</label>
|
||||
<select id="f-adsense"><option value="">없음</option></select>
|
||||
</div>
|
||||
<div class="flex" style="justify-content:flex-end;gap:.5rem;margin-top:1rem">
|
||||
<button class="btn btn-outline" onclick="closeModal()">취소</button>
|
||||
<button class="btn btn-primary" onclick="saveSite()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let sites = [];
|
||||
|
||||
async function loadSites() {
|
||||
sites = await api('GET', '/api/sites');
|
||||
const adsenseList = await api('GET', '/api/adsense');
|
||||
|
||||
// AdSense 드롭다운
|
||||
const sel = document.getElementById('f-adsense');
|
||||
sel.innerHTML = '<option value="">없음</option>' + adsenseList.map(a =>
|
||||
'<option value="' + a.id + '">' + a.name + ' (' + a.client_id + ')</option>'
|
||||
).join('');
|
||||
|
||||
document.getElementById('sites-tbody').innerHTML = sites.map(s => {
|
||||
const sched = s.schedule_active
|
||||
? '<span class="badge badge-success">' + s.cron_schedule + '</span>'
|
||||
: '<span class="badge badge-danger">OFF</span>';
|
||||
return '<tr>' +
|
||||
'<td>' + s.id + '</td>' +
|
||||
'<td><a href="/admin/sites/' + s.id + '" style="color:var(--primary);font-weight:600">' + s.name + '</a></td>' +
|
||||
'<td class="text-muted" style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + s.url + '</td>' +
|
||||
'<td>' + (s.slug || '<span class="text-muted">-</span>') + '</td>' +
|
||||
'<td>' + sched + '</td>' +
|
||||
'<td>' + timeAgo(s.last_crawled_at) + '</td>' +
|
||||
'<td class="flex">' +
|
||||
'<button class="btn btn-success btn-sm" onclick="doCrawl(' + s.id + ',this)">크롤링</button>' +
|
||||
'<button class="btn btn-outline btn-sm" onclick="editSite(' + s.id + ')">수정</button>' +
|
||||
'<button class="btn btn-danger btn-sm" onclick="deleteSite(' + s.id + ')">삭제</button>' +
|
||||
'</td></tr>';
|
||||
}).join('') || '<tr><td colspan="7" style="text-align:center;padding:2rem" class="text-muted">사이트를 추가하세요</td></tr>';
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
document.getElementById('modal-title').textContent = '사이트 추가';
|
||||
document.getElementById('edit-id').value = '';
|
||||
document.getElementById('f-name').value = '';
|
||||
document.getElementById('f-url').value = '';
|
||||
document.getElementById('f-slug').value = '';
|
||||
document.getElementById('f-desc').value = '';
|
||||
document.getElementById('f-adsense').value = '';
|
||||
document.getElementById('siteModal').classList.add('active');
|
||||
}
|
||||
|
||||
function editSite(id) {
|
||||
const s = sites.find(x => x.id === id);
|
||||
if (!s) return;
|
||||
document.getElementById('modal-title').textContent = '사이트 수정';
|
||||
document.getElementById('edit-id').value = s.id;
|
||||
document.getElementById('f-name').value = s.name;
|
||||
document.getElementById('f-url').value = s.url;
|
||||
document.getElementById('f-slug').value = s.slug || '';
|
||||
document.getElementById('f-desc').value = s.description || '';
|
||||
document.getElementById('f-rules').value = JSON.stringify(s.parse_rules || {}, null, 2);
|
||||
document.getElementById('f-adsense').value = s.adsense_config_id || '';
|
||||
document.getElementById('siteModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('siteModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function saveSite() {
|
||||
let rules;
|
||||
try {
|
||||
rules = JSON.parse(document.getElementById('f-rules').value || '{}');
|
||||
} catch(e) {
|
||||
toast('파싱 규칙 JSON이 올바르지 않습니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: document.getElementById('f-name').value,
|
||||
url: document.getElementById('f-url').value,
|
||||
slug: document.getElementById('f-slug').value || null,
|
||||
description: document.getElementById('f-desc').value,
|
||||
parse_rules: rules,
|
||||
adsense_config_id: document.getElementById('f-adsense').value || null,
|
||||
};
|
||||
|
||||
if (!data.name || !data.url) { toast('사이트명과 URL은 필수입니다', 'error'); return; }
|
||||
|
||||
const editId = document.getElementById('edit-id').value;
|
||||
if (editId) {
|
||||
await api('PUT', '/api/sites/' + editId, data);
|
||||
toast('사이트가 수정되었습니다');
|
||||
} else {
|
||||
await api('POST', '/api/sites', data);
|
||||
toast('사이트가 추가되었습니다');
|
||||
}
|
||||
|
||||
closeModal();
|
||||
loadSites();
|
||||
}
|
||||
|
||||
async function deleteSite(id) {
|
||||
if (!confirm('정말 삭제하시겠습니까? 모든 크롤링 데이터가 삭제됩니다.')) return;
|
||||
await api('DELETE', '/api/sites/' + id);
|
||||
toast('삭제되었습니다');
|
||||
loadSites();
|
||||
}
|
||||
|
||||
async function doCrawl(id, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '크롤링 중...';
|
||||
try {
|
||||
const r = await api('POST', '/api/sites/' + id + '/crawl');
|
||||
if (r.error) throw new Error(r.error);
|
||||
toast('크롤링 완료! ' + (r.itemCount || 0) + '개 항목');
|
||||
} catch(e) {
|
||||
toast('크롤링 실패: ' + e.message, 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '크롤링';
|
||||
loadSites();
|
||||
}
|
||||
|
||||
loadSites();
|
||||
</script>
|
||||
` }) %>
|
||||
Reference in New Issue
Block a user