feat: 보안 강화 + 자동화 도구 3종 추가 (웹 크롤러·PPT·엑셀)
- lib/security.ts: escapeHtml, isValidEmail, sanitizeStr, checkRateLimit 유틸 추가 - next.config.ts: 보안 헤더 적용 (X-Frame-Options, HSTS, Permissions-Policy 등) - api/contact: XSS 방어, Rate Limit(5/min), 입력 길이 제한 - api/payment/confirm: 사용자 인증·소유권 검증, 타입 체크, 에러 메시지 정제 - api/admin/quotes: PUT 허용 필드 화이트리스트 적용 - api/saju/analyze: 로그인·결제 검증, 입력 크기 제한, gender 값 검증 - public/downloads/web_scraper_v1.0.py: requests+BS4+openpyxl 웹 크롤러 - public/downloads/ppt_automation_v1.0.py: python-pptx+openpyxl PPT 자동화 - app/services/automation/tools/scraper: 크롤러 상세 페이지 추가 - app/services/automation/tools/ppt: PPT 도구 상세 페이지 추가 - app/services/automation/page.tsx: scraper ready=true, email→PPT 교체 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,24 +16,50 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase.from('quotes').select('*').eq('id', id).single();
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 404 });
|
||||
if (error) return NextResponse.json({ error: '견적서를 찾을 수 없습니다' }, { status: 404 });
|
||||
return NextResponse.json({ quote: data });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const supabase = createAdminClient();
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── 허용 필드 화이트리스트 (시스템 필드 변조 방지) ───────────
|
||||
const ALLOWED_FIELDS = [
|
||||
'title', 'client_name', 'client_email', 'client_phone',
|
||||
'items', 'maintenance', 'notes', 'status',
|
||||
'valid_until', 'discount',
|
||||
] as const;
|
||||
|
||||
const sanitizedBody = Object.fromEntries(
|
||||
ALLOWED_FIELDS
|
||||
.filter((key) => key in body)
|
||||
.map((key) => [key, body[key]])
|
||||
);
|
||||
|
||||
if (Object.keys(sanitizedBody).length === 0) {
|
||||
return NextResponse.json({ error: '수정할 필드가 없습니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase
|
||||
.from('quotes')
|
||||
.update({ ...body, updated_at: new Date().toISOString() })
|
||||
.update({ ...sanitizedBody, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
if (error) {
|
||||
console.error('[Admin Quotes] PUT error:', error.message);
|
||||
return NextResponse.json({ error: '견적서 업데이트 실패' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ quote: data });
|
||||
}
|
||||
|
||||
@@ -42,6 +68,9 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.from('quotes').delete().eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
if (error) {
|
||||
console.error('[Admin Quotes] DELETE error:', error.message);
|
||||
return NextResponse.json({ error: '견적서 삭제 실패' }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -1,37 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { Resend } from 'resend';
|
||||
import {
|
||||
escapeHtml,
|
||||
isValidEmail,
|
||||
sanitizeStr,
|
||||
checkRateLimit,
|
||||
getClientIp,
|
||||
INPUT_LIMITS,
|
||||
} from '@/lib/security';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, phone, email, service, message } = body;
|
||||
// ── Rate Limit: IP당 1분 5회 ──────────────────────────────
|
||||
const ip = getClientIp(request);
|
||||
const rl = checkRateLimit(`contact:${ip}`, 60_000, 5);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 입력 검증
|
||||
const body = await request.json();
|
||||
|
||||
// ── 입력 정제 + 길이 제한 ─────────────────────────────────
|
||||
const name = sanitizeStr(body.name, INPUT_LIMITS.NAME);
|
||||
const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE);
|
||||
const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL);
|
||||
const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE);
|
||||
const message = sanitizeStr(body.message, INPUT_LIMITS.MESSAGE);
|
||||
|
||||
// ── 필수값 검증 ───────────────────────────────────────────
|
||||
if (!name || !email || !message) {
|
||||
return NextResponse.json(
|
||||
{ error: '필수 항목을 모두 입력해주세요.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: '올바른 이메일 형식이 아닙니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 이메일 발송
|
||||
const data = await resend.emails.send({
|
||||
from: 'onboarding@resend.dev', // Resend 기본 도메인
|
||||
to: ['bgg8988@gmail.com'], // 받는 이메일
|
||||
replyTo: email, // 문의자 이메일로 답장 가능
|
||||
subject: `[쟁승메이드] 새로운 문의: ${service || '문의'}`,
|
||||
// ── HTML 이스케이프 (XSS 방지) ────────────────────────────
|
||||
const safeSubject = escapeHtml(service || '문의');
|
||||
const safeName = escapeHtml(name);
|
||||
const safePhone = escapeHtml(phone || '미입력');
|
||||
const safeEmail = escapeHtml(email);
|
||||
const safeService = escapeHtml(service || '미선택');
|
||||
// message는 pre-wrap으로 렌더링되므로 반드시 이스케이프
|
||||
const safeMessage = escapeHtml(message);
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'onboarding@resend.dev',
|
||||
to: ['bgg8988@gmail.com'],
|
||||
replyTo: email,
|
||||
subject: `[쟁승메이드] 새로운 문의: ${safeSubject}`,
|
||||
html: `
|
||||
<h2>새로운 프로젝트 문의가 도착했습니다</h2>
|
||||
<hr />
|
||||
<p><strong>이름:</strong> ${name}</p>
|
||||
<p><strong>연락처:</strong> ${phone || '미입력'}</p>
|
||||
<p><strong>이메일:</strong> ${email}</p>
|
||||
<p><strong>서비스:</strong> ${service || '미선택'}</p>
|
||||
<p><strong>이름:</strong> ${safeName}</p>
|
||||
<p><strong>연락처:</strong> ${safePhone}</p>
|
||||
<p><strong>이메일:</strong> ${safeEmail}</p>
|
||||
<p><strong>서비스:</strong> ${safeService}</p>
|
||||
<hr />
|
||||
<h3>문의 내용:</h3>
|
||||
<p style="white-space: pre-wrap;">${message}</p>
|
||||
<p style="white-space: pre-wrap;">${safeMessage}</p>
|
||||
<hr />
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
이 메일은 jaengseung-made.com의 문의 폼에서 발송되었습니다.
|
||||
@@ -44,7 +85,8 @@ export async function POST(request: Request) {
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error);
|
||||
// 클라이언트에 내부 오류 상세 노출 금지
|
||||
console.error('[Contact] Email send error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '메일 전송에 실패했습니다. 다시 시도해주세요.' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/security';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { paymentKey, orderId, amount } = await request.json();
|
||||
|
||||
if (!paymentKey || !orderId || !amount) {
|
||||
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
|
||||
// ── Rate Limit: IP당 1분 10회 (결제 재시도 남용 방지) ─────
|
||||
const ip = getClientIp(request);
|
||||
const rl = checkRateLimit(`payment:${ip}`, 60_000, 10);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Supabase에서 order 확인
|
||||
const body = await request.json();
|
||||
const { paymentKey, orderId, amount } = body;
|
||||
|
||||
// ── 기본 파라미터 검증 ────────────────────────────────────
|
||||
if (!paymentKey || !orderId || amount === undefined) {
|
||||
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
|
||||
}
|
||||
// 타입 강제 검증
|
||||
if (
|
||||
typeof paymentKey !== 'string' || paymentKey.length > 200 ||
|
||||
typeof orderId !== 'string' || orderId.length > 200 ||
|
||||
typeof amount !== 'number' || amount <= 0 || !Number.isInteger(amount)
|
||||
) {
|
||||
return NextResponse.json({ error: '잘못된 파라미터 형식' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── 로그인 사용자 확인 ────────────────────────────────────
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ── DB에서 주문 확인 (금액 서버사이드 검증) ───────────────
|
||||
const { data: order, error: orderFetchError } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
@@ -20,24 +47,30 @@ export async function POST(request: NextRequest) {
|
||||
if (orderFetchError || !order) {
|
||||
return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 });
|
||||
}
|
||||
// 주문 소유자 검증 (다른 사용자 주문 처리 방지)
|
||||
if (order.user_id !== user.id) {
|
||||
return NextResponse.json({ error: '접근 권한이 없습니다' }, { status: 403 });
|
||||
}
|
||||
// 서버 DB 금액과 비교 (클라이언트 금액 위조 방어)
|
||||
if (order.amount !== amount) {
|
||||
return NextResponse.json({ error: '결제 금액 불일치' }, { status: 400 });
|
||||
console.warn(`[Payment] 금액 불일치 orderId=${orderId} db=${order.amount} req=${amount} user=${user.id}`);
|
||||
return NextResponse.json({ error: '결제 금액이 올바르지 않습니다' }, { status: 400 });
|
||||
}
|
||||
if (order.status === 'paid') {
|
||||
return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. 토스페이먼츠 서버 승인
|
||||
// dev: TOSS_SECRET_KEY=test_sk_* (테스트 결제)
|
||||
// prod: TOSS_SECRET_KEY=live_sk_* (실결제) — Vercel 환경변수에 설정
|
||||
const secretKey = process.env.TOSS_SECRET_KEY!;
|
||||
const isTestKey = secretKey.startsWith('test_');
|
||||
if (!isTestKey && process.env.NODE_ENV === 'development') {
|
||||
// 실수로 live 키를 dev에서 쓰는 것 방지
|
||||
// ── 토스페이먼츠 서버 승인 ────────────────────────────────
|
||||
const secretKey = process.env.TOSS_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
console.error('[Payment] TOSS_SECRET_KEY 미설정');
|
||||
return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 });
|
||||
}
|
||||
if (!secretKey.startsWith('test_') && process.env.NODE_ENV === 'development') {
|
||||
console.warn('[Payment] WARNING: live Toss key detected in development!');
|
||||
}
|
||||
const encoded = Buffer.from(`${secretKey}:`).toString('base64');
|
||||
|
||||
const encoded = Buffer.from(`${secretKey}:`).toString('base64');
|
||||
const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -48,42 +81,47 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!tossRes.ok) {
|
||||
const err = await tossRes.json();
|
||||
return NextResponse.json({ error: err.message || '토스 승인 실패' }, { status: 400 });
|
||||
const err = await tossRes.json().catch(() => ({}));
|
||||
// 내부 에러 코드는 서버 로그에만 기록
|
||||
console.error(`[Payment] Toss 승인 실패 orderId=${orderId} code=${err.code} msg=${err.message}`);
|
||||
return NextResponse.json(
|
||||
{ error: '결제 승인에 실패했습니다. 카드사 또는 고객센터에 문의해주세요.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const tossData = await tossRes.json();
|
||||
|
||||
// 3. orders 상태 paid로 업데이트
|
||||
// ── orders 상태 업데이트 ──────────────────────────────────
|
||||
const { error: updateError } = await supabase
|
||||
.from('orders')
|
||||
.update({ status: 'paid' })
|
||||
.eq('id', orderId);
|
||||
|
||||
if (updateError) {
|
||||
console.error('Order update error:', updateError);
|
||||
return NextResponse.json({ error: '주문 상태 업데이트 실패: ' + updateError.message }, { status: 500 });
|
||||
console.error('[Payment] Order update error:', updateError.message);
|
||||
return NextResponse.json({ error: '주문 상태 업데이트 실패' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 4. payments 레코드 생성
|
||||
// ── payments 레코드 생성 ──────────────────────────────────
|
||||
const { error: paymentError } = await supabase.from('payments').insert({
|
||||
user_id: order.user_id,
|
||||
order_id: orderId,
|
||||
product_name: order.metadata?.product_name ?? order.product_id,
|
||||
amount: order.amount,
|
||||
status: 'paid',
|
||||
pg_provider: 'toss',
|
||||
user_id: order.user_id,
|
||||
order_id: orderId,
|
||||
product_name: order.metadata?.product_name ?? order.product_id,
|
||||
amount: order.amount,
|
||||
status: 'paid',
|
||||
pg_provider: 'toss',
|
||||
pg_payment_key: paymentKey,
|
||||
});
|
||||
|
||||
if (paymentError) {
|
||||
console.error('Payment insert error:', paymentError);
|
||||
return NextResponse.json({ error: '결제 내역 저장 실패: ' + paymentError.message }, { status: 500 });
|
||||
console.error('[Payment] Payment insert error:', paymentError.message);
|
||||
return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: tossData });
|
||||
} catch (error: unknown) {
|
||||
console.error('Payment confirm error:', error);
|
||||
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
|
||||
console.error('[Payment] Unexpected error:', error);
|
||||
return NextResponse.json({ error: '서버 오류가 발생했습니다' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,16 +64,49 @@ const MODELS = [
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { saju, daeun, daeunList, gender, engineData } = await request.json();
|
||||
// ── 결제 사용자 인증 (Gemini API 무단 호출 방지) ──────────
|
||||
const { createClient } = await import('@/lib/supabase/server');
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (user) {
|
||||
// 로그인된 경우: saju_detail 결제 여부 확인
|
||||
const { data: paidOrder } = await supabase
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('product_id', 'saju_detail')
|
||||
.eq('status', 'paid')
|
||||
.maybeSingle();
|
||||
|
||||
if (!paidOrder) {
|
||||
return NextResponse.json({ error: '사주 리포트를 구매한 사용자만 이용할 수 있습니다' }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
// 비로그인 사용자는 AI 호출 불가
|
||||
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ── 입력 길이 검증 (DoS / 프롬프트 인젝션 기초 방어) ──────
|
||||
const raw = await request.json();
|
||||
if (JSON.stringify(raw).length > 50_000) {
|
||||
return NextResponse.json({ error: '요청 데이터가 너무 큽니다' }, { status: 400 });
|
||||
}
|
||||
const { saju, daeun, daeunList, gender, engineData } = raw;
|
||||
|
||||
// gender 값 제한
|
||||
if (gender !== 'male' && gender !== 'female') {
|
||||
return NextResponse.json({ error: '잘못된 성별 값' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 종합 분석 수행
|
||||
let analysis;
|
||||
try {
|
||||
analysis = performFullAnalysis(saju);
|
||||
} catch (analysisError: any) {
|
||||
console.error('[사주] 분석 계산 오류:', analysisError.message);
|
||||
console.error('[사주] 분석 계산 오류');
|
||||
return NextResponse.json(
|
||||
{ error: '사주 분석 계산 중 오류: ' + analysisError.message },
|
||||
{ error: '사주 분석 중 오류가 발생했습니다' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ const tools = [
|
||||
{
|
||||
id: 'scraper',
|
||||
title: '웹 스크래핑 도구',
|
||||
subtitle: 'Web Scraper v0.9 (베타)',
|
||||
subtitle: 'Web Scraper v1.0',
|
||||
desc: '공공데이터·쇼핑몰 가격·뉴스를 자동 수집해 엑셀로 저장하는 Python 기반 수집 도구.',
|
||||
tags: ['Python', 'BeautifulSoup', 'Excel 출력'],
|
||||
tags: ['Python', 'BeautifulSoup', 'Excel 출력', '무료'],
|
||||
color: '#2563eb',
|
||||
bgColor: '#eff6ff',
|
||||
borderColor: '#bfdbfe',
|
||||
@@ -37,24 +37,24 @@ const tools = [
|
||||
</svg>
|
||||
),
|
||||
href: '/services/automation/tools/scraper',
|
||||
ready: false,
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: '이메일 자동 발송 도구',
|
||||
subtitle: 'Email Scheduler (준비중)',
|
||||
desc: '조건 설정 후 일정 시간에 자동으로 이메일을 발송. 엑셀 수신자 목록 연동 지원.',
|
||||
tags: ['Python', 'SMTP', '스케줄링'],
|
||||
id: 'ppt',
|
||||
title: 'PPT 제작 자동화 도구',
|
||||
subtitle: 'PPT Automation v1.0',
|
||||
desc: '엑셀 데이터를 읽어 표지·내용·마무리 슬라이드를 자동 생성하는 Python 기반 PPT 도구.',
|
||||
tags: ['Python', 'python-pptx', 'openpyxl', '무료'],
|
||||
color: '#7c3aed',
|
||||
bgColor: '#f5f3ff',
|
||||
borderColor: '#ddd6fe',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-7 h-7">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</svg>
|
||||
),
|
||||
href: '/services/automation/tools/email',
|
||||
ready: false,
|
||||
href: '/services/automation/tools/ppt',
|
||||
ready: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
365
app/services/automation/tools/ppt/page.tsx
Normal file
365
app/services/automation/tools/ppt/page.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</svg>
|
||||
),
|
||||
title: '표지 · 내용 · 마무리 자동 생성',
|
||||
desc: '표지(제목/날짜), 내용 슬라이드(불릿 포인트), 마무리 슬라이드까지 3가지 레이아웃을 자동으로 구성합니다.',
|
||||
color: 'text-orange-600',
|
||||
bg: 'bg-orange-50',
|
||||
border: 'border-orange-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75.125v-1.125c0-.621.504-1.125 1.125-1.125h1.5m0 0v1.25m0-1.25c0-.621.504-1.125 1.125-1.125h1.5m0 0V7.875m0 0c0-.621.504-1.125 1.125-1.125h8.25c.621 0 1.125.504 1.125 1.125v8.25m0 0v1.125m0-1.125c0 .621-.504 1.125-1.125 1.125H6m12-8.25v8.25" />
|
||||
</svg>
|
||||
),
|
||||
title: '엑셀에서 데이터 일괄 생성',
|
||||
desc: 'data.xlsx 파일의 A열(제목), B~열(불릿 내용)을 읽어 슬라이드를 자동 생성합니다. 수십 장도 한 번에 처리.',
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50',
|
||||
border: 'border-emerald-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88" />
|
||||
</svg>
|
||||
),
|
||||
title: '색상 테마 커스터마이징',
|
||||
desc: '상단 설정 영역에서 PRIMARY, SECONDARY, ACCENT 색상을 RGB로 변경하면 전체 슬라이드에 즉시 반영됩니다.',
|
||||
color: 'text-violet-600',
|
||||
bg: 'bg-violet-50',
|
||||
border: 'border-violet-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
</svg>
|
||||
),
|
||||
title: '슬라이드 번호 자동 추가',
|
||||
desc: '각 내용 슬라이드 우측 상단에 슬라이드 번호(01, 02...)가 자동으로 표시됩니다. 따로 설정할 필요 없음.',
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
|
||||
</svg>
|
||||
),
|
||||
title: '16:9 비율 · 맑은 고딕 폰트',
|
||||
desc: '발표 표준 비율인 16:9(13.33×7.5인치)로 설정되며, 한글 가독성이 좋은 맑은 고딕 폰트를 기본 적용합니다.',
|
||||
color: 'text-cyan-600',
|
||||
bg: 'bg-cyan-50',
|
||||
border: 'border-cyan-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
),
|
||||
title: '예시 데이터 자동 실행',
|
||||
desc: 'data.xlsx 파일이 없어도 내장 예시 데이터로 바로 실행됩니다. 처음 사용할 때 결과를 즉시 확인 가능.',
|
||||
color: 'text-rose-600',
|
||||
bg: 'bg-rose-50',
|
||||
border: 'border-rose-200',
|
||||
},
|
||||
];
|
||||
|
||||
const howToUse = [
|
||||
{
|
||||
step: '01',
|
||||
title: '패키지 설치',
|
||||
desc: '터미널에서 필요한 Python 패키지를 설치합니다.',
|
||||
code: 'pip install python-pptx openpyxl',
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: '설정 수정',
|
||||
desc: '스크립트 상단 설정 영역에서 제목, 날짜, 색상 테마를 수정합니다.',
|
||||
code: 'TITLE_TEXT = "발표 제목"\nCOLOR_PRIMARY = RGBColor(0x1D, 0x4E, 0xD8)',
|
||||
color: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: '엑셀 데이터 준비',
|
||||
desc: 'data.xlsx를 만들어 A열=슬라이드 제목, B~열=불릿 내용을 입력합니다. (없으면 예시 데이터로 실행)',
|
||||
code: 'A열: 슬라이드 제목\nB~열: 불릿 포인트 내용',
|
||||
color: 'bg-violet-500',
|
||||
},
|
||||
{
|
||||
step: '04',
|
||||
title: '실행 후 확인',
|
||||
desc: '터미널에서 스크립트를 실행하면 같은 폴더에 PPT 파일이 자동 저장됩니다.',
|
||||
code: 'python ppt_automation_v1.0.py',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: '엑셀 파일이 없어도 실행되나요?',
|
||||
a: 'data.xlsx 파일이 없으면 내장 예시 데이터로 자동 실행됩니다. 먼저 결과를 확인한 뒤 자신의 데이터로 교체하면 됩니다.',
|
||||
},
|
||||
{
|
||||
q: '슬라이드 수에 제한이 있나요?',
|
||||
a: '제한 없습니다. 엑셀에 입력한 행 수만큼 슬라이드가 생성됩니다. 단, 슬라이드당 불릿 포인트는 최대 8개입니다.',
|
||||
},
|
||||
{
|
||||
q: '챕터 구분 슬라이드도 넣을 수 있나요?',
|
||||
a: 'create_divider_slide() 함수가 포함되어 있습니다. main() 함수에서 원하는 위치에 호출하면 챕터 구분 슬라이드를 추가할 수 있습니다.',
|
||||
},
|
||||
{
|
||||
q: '맥(Mac)에서도 사용할 수 있나요?',
|
||||
a: '맥에서도 동일하게 사용 가능합니다. 단, 맥에는 맑은 고딕 폰트가 없으므로 FONT_NAME을 "AppleGothic" 또는 "Nanum Gothic"으로 변경하세요.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function PptToolPage() {
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff]">
|
||||
|
||||
{/* ─── Hero ─── */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-[#1a0a3d] via-[#2d1560] to-[#1a0a3d] px-6 py-14 lg:px-12">
|
||||
<div className="absolute inset-0 opacity-[0.05]">
|
||||
<svg width="100%" height="100%" viewBox="0 0 800 300" preserveAspectRatio="xMidYMid slice">
|
||||
<rect x="50" y="40" width="200" height="130" rx="6" fill="none" stroke="#c084fc" strokeWidth="1.5"/>
|
||||
<line x1="70" y1="70" x2="230" y2="70" stroke="#c084fc" strokeWidth="1"/>
|
||||
<line x1="70" y1="90" x2="200" y2="90" stroke="#c084fc" strokeWidth="1"/>
|
||||
<line x1="70" y1="110" x2="210" y2="110" stroke="#c084fc" strokeWidth="1"/>
|
||||
<rect x="320" y="60" width="200" height="130" rx="6" fill="none" stroke="#c084fc" strokeWidth="1.5"/>
|
||||
<rect x="340" y="75" width="160" height="20" rx="3" fill="#c084fc" fillOpacity="0.2"/>
|
||||
<line x1="340" y1="110" x2="500" y2="110" stroke="#c084fc" strokeWidth="1"/>
|
||||
<line x1="340" y1="130" x2="480" y2="130" stroke="#c084fc" strokeWidth="1"/>
|
||||
<rect x="590" y="40" width="160" height="130" rx="6" fill="none" stroke="#c084fc" strokeWidth="1.5"/>
|
||||
<line x1="610" y1="100" x2="730" y2="100" stroke="#c084fc" strokeWidth="1.5"/>
|
||||
<line x1="660" y1="110" x2="660" y2="160" stroke="#c084fc" strokeWidth="1"/>
|
||||
<path d="M610 160 L660 110 L710 145 L730 120" fill="none" stroke="#c084fc" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-3xl mx-auto text-center">
|
||||
<Link href="/services/automation" className="inline-flex items-center gap-1.5 text-purple-300/60 hover:text-purple-300 text-sm mb-6 transition">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
업무 자동화로
|
||||
</Link>
|
||||
|
||||
<div className="w-16 h-16 mx-auto rounded-2xl bg-purple-400/15 border border-purple-400/25 flex items-center justify-center mb-5">
|
||||
<svg className="w-9 h-9 text-purple-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="text-purple-400/70 text-xs font-bold uppercase tracking-widest mb-2">PPT AUTOMATION · 프레젠테이션 자동화</p>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold text-white mb-4 tracking-tight leading-tight">
|
||||
PPT 제작을<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">코드로 자동화</span>
|
||||
</h1>
|
||||
<p className="text-purple-100/50 text-base md:text-lg leading-relaxed max-w-xl mx-auto mb-6">
|
||||
엑셀 데이터만 준비하면 표지·내용·마무리 슬라이드를 자동 생성.<br />
|
||||
python-pptx 기반으로 디자인까지 자동 적용됩니다.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<a
|
||||
href="/downloads/ppt_automation_v1.0.py"
|
||||
download
|
||||
className="inline-flex items-center gap-2 bg-purple-400 hover:bg-purple-300 text-[#1a0a3d] px-8 py-3.5 rounded-xl font-extrabold text-sm transition-all shadow-lg shadow-purple-900/30"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
무료 다운로드 (Python .py)
|
||||
</a>
|
||||
<Link href="/freelance?service=PPT+자동화+맞춤+개발"
|
||||
className="inline-flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white border border-white/20 px-6 py-3.5 rounded-xl font-bold text-sm transition-all">
|
||||
맞춤 개발 문의 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 다운로드 카드 ─── */}
|
||||
<div className="px-6 py-10 lg:px-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-white rounded-2xl border-2 border-purple-200 p-6 flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
<div className="w-14 h-14 rounded-2xl bg-purple-50 border border-purple-200 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-7 h-7 text-purple-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-purple-600 text-xs font-bold">PPT AUTOMATION v1.0</span>
|
||||
<span className="bg-purple-100 text-purple-700 text-[10px] font-bold px-2 py-0.5 rounded-full border border-purple-200">무료</span>
|
||||
</div>
|
||||
<div className="font-extrabold text-[#04102b] text-lg mb-1">PPT 제작 자동화 도구</div>
|
||||
<p className="text-slate-500 text-sm">python-pptx 기반 · 엑셀 연동 · 표지/내용/마무리 자동 생성 · 색상 테마 커스터마이징</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{['Python 3.8+', 'python-pptx', 'openpyxl', '한글 지원', '엑셀 연동'].map((tag) => (
|
||||
<span key={tag} className="text-[10px] font-bold px-2 py-0.5 rounded-full border border-purple-200 text-purple-600 bg-purple-50">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/downloads/ppt_automation_v1.0.py"
|
||||
download
|
||||
className="flex-shrink-0 inline-flex items-center gap-2 bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-xl font-bold text-sm transition"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
다운로드
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 기능 ─── */}
|
||||
<div className="px-6 pb-12 lg:px-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-purple-600 text-xs font-bold uppercase tracking-widest mb-2">FEATURES</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b]">주요 기능</h2>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className={`bg-white rounded-2xl border-2 ${f.border} p-5`}>
|
||||
<div className={`w-9 h-9 rounded-xl ${f.bg} flex items-center justify-center mb-3 ${f.color}`}>
|
||||
{f.icon}
|
||||
</div>
|
||||
<h3 className="font-bold text-[#04102b] text-sm mb-2">{f.title}</h3>
|
||||
<p className="text-slate-500 text-xs leading-relaxed">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 사용법 ─── */}
|
||||
<div className="px-6 pb-12 lg:px-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-purple-600 text-xs font-bold uppercase tracking-widest mb-2">HOW TO USE</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b]">사용 방법</h2>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{howToUse.map((h) => (
|
||||
<div key={h.step} className="bg-white rounded-2xl border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${h.color} flex items-center justify-center text-white text-xs font-extrabold flex-shrink-0`}>
|
||||
{h.step}
|
||||
</div>
|
||||
<h3 className="font-bold text-[#04102b] text-sm">{h.title}</h3>
|
||||
</div>
|
||||
<p className="text-slate-500 text-xs leading-relaxed mb-3">{h.desc}</p>
|
||||
<div className="bg-[#1a0a3d] rounded-xl px-4 py-3 font-mono text-[11px] text-purple-200 whitespace-pre leading-relaxed">
|
||||
{h.code}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 코드 미리보기 ─── */}
|
||||
<div className="px-6 pb-12 lg:px-12">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-purple-600 text-xs font-bold uppercase tracking-widest mb-2">PREVIEW</p>
|
||||
<h2 className="text-2xl font-extrabold text-[#04102b]">설정 영역 미리보기</h2>
|
||||
<p className="text-slate-500 text-sm mt-1">이 부분만 수정하면 원하는 PPT가 완성됩니다</p>
|
||||
</div>
|
||||
<div className="bg-[#1a0a3d] rounded-2xl overflow-hidden border border-purple-900/40">
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b border-purple-900/40">
|
||||
<div className="w-3 h-3 rounded-full bg-rose-400/70" />
|
||||
<div className="w-3 h-3 rounded-full bg-amber-400/70" />
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-400/70" />
|
||||
<span className="ml-2 text-purple-300/50 text-xs font-mono">ppt_automation_v1.0.py</span>
|
||||
</div>
|
||||
<pre className="px-5 py-5 text-xs font-mono text-purple-100 leading-6 overflow-x-auto">{`<span class="text-purple-400"># ── 설정 (이 부분을 수정하세요) ──────────────</span>
|
||||
|
||||
DATA_FILE = <span class="text-amber-300">"data.xlsx"</span> <span class="text-slate-400"># 입력 엑셀 파일</span>
|
||||
OUTPUT_FILE = f<span class="text-amber-300">"발표자료_{datetime}.pptx"</span>
|
||||
|
||||
<span class="text-slate-400"># 표지 정보</span>
|
||||
TITLE_TEXT = <span class="text-amber-300">"발표 제목을 입력하세요"</span>
|
||||
SUBTITLE_TEXT = <span class="text-amber-300">"부제목 또는 발표자 이름"</span>
|
||||
DATE_TEXT = <span class="text-amber-300">"2025년 01월 01일"</span>
|
||||
|
||||
<span class="text-slate-400"># 색상 테마 (RGB 값으로 변경)</span>
|
||||
COLOR_PRIMARY = RGBColor(<span class="text-emerald-400">0x1D, 0x4E, 0xD8</span>) <span class="text-slate-400"># 파란색</span>
|
||||
COLOR_SECONDARY = RGBColor(<span class="text-emerald-400">0x0F, 0x17, 0x2A</span>) <span class="text-slate-400"># 다크 네이비</span>
|
||||
COLOR_ACCENT = RGBColor(<span class="text-emerald-400">0x60, 0xA5, 0xFA</span>) <span class="text-slate-400"># 라이트 블루</span>
|
||||
|
||||
FONT_NAME = <span class="text-amber-300">"맑은 고딕"</span> <span class="text-slate-400"># 한글 폰트</span>`}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-center text-slate-400 text-xs mt-3">
|
||||
* 코드 미리보기는 실제 파일의 일부입니다. 다운로드 후 설정 영역 전체를 수정해서 사용하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── FAQ ─── */}
|
||||
<div className="px-6 pb-12 lg:px-12">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-purple-600 text-xs font-bold uppercase tracking-widest mb-2">FAQ</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b]">자주 묻는 질문</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq) => (
|
||||
<div key={faq.q} className="bg-white rounded-2xl border border-slate-200 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-purple-600 font-extrabold text-sm flex-shrink-0 mt-0.5">Q.</span>
|
||||
<div>
|
||||
<div className="font-bold text-[#04102b] text-sm mb-1.5">{faq.q}</div>
|
||||
<div className="text-slate-500 text-xs leading-relaxed">{faq.a}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── CTA ─── */}
|
||||
<div className="px-6 pb-12 lg:px-12">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-gradient-to-r from-[#1a0a3d] to-[#2d1560] rounded-2xl border border-purple-400/20 p-8 text-center">
|
||||
<p className="text-purple-400 text-xs font-bold uppercase tracking-widest mb-2">CUSTOM DEVELOPMENT</p>
|
||||
<h3 className="text-white text-2xl font-extrabold mb-2">더 복잡한 PPT 자동화가 필요하신가요?</h3>
|
||||
<p className="text-purple-100/40 text-sm mb-6">
|
||||
이미지 삽입, 차트 자동 생성, 브랜드 템플릿 적용 등<br />
|
||||
맞춤 PPT 자동화를 개발해드립니다.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<a
|
||||
href="/downloads/ppt_automation_v1.0.py"
|
||||
download
|
||||
className="inline-flex items-center gap-2 bg-purple-400 hover:bg-purple-300 text-[#1a0a3d] px-8 py-3 rounded-xl font-extrabold text-sm transition-all shadow-lg shadow-purple-900/30"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
||||
무료 버전 다운로드
|
||||
</a>
|
||||
<Link href="/freelance?service=PPT+자동화+맞춤+개발"
|
||||
className="inline-flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white border border-white/20 px-6 py-3 rounded-xl font-bold text-sm transition-all">
|
||||
맞춤 개발 문의 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
app/services/automation/tools/scraper/page.tsx
Normal file
284
app/services/automation/tools/scraper/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
),
|
||||
title: '웹 페이지 데이터 자동 수집',
|
||||
desc: '공공데이터, 쇼핑몰 가격, 뉴스 기사 등 원하는 페이지의 데이터를 자동으로 수집합니다.',
|
||||
color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
),
|
||||
title: '엑셀 자동 저장',
|
||||
desc: '수집한 데이터를 열 서식, 헤더 스타일이 적용된 엑셀 파일로 자동 저장합니다.',
|
||||
color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
||||
</svg>
|
||||
),
|
||||
title: '페이지네이션 자동 탐색',
|
||||
desc: '다음 페이지 링크를 자동으로 찾아 여러 페이지의 데이터를 연속으로 수집합니다.',
|
||||
color: 'text-violet-600', bg: 'bg-violet-50', border: 'border-violet-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
),
|
||||
title: '재시도 로직 내장',
|
||||
desc: '네트워크 오류나 일시적 접속 실패 시 자동으로 재시도합니다. 수집 실패 최소화.',
|
||||
color: 'text-orange-600', bg: 'bg-orange-50', border: 'border-orange-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
title: '요청 간격 자동 조절',
|
||||
desc: '서버에 부하를 주지 않도록 요청 간격을 자동으로 조절합니다. 차단 위험 최소화.',
|
||||
color: 'text-cyan-600', bg: 'bg-cyan-50', border: 'border-cyan-200',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
),
|
||||
title: '로그 파일 자동 저장',
|
||||
desc: '수집 과정 전체를 로그로 남겨 나중에 어떤 URL에서 몇 건을 수집했는지 확인 가능합니다.',
|
||||
color: 'text-rose-600', bg: 'bg-rose-50', border: 'border-rose-200',
|
||||
},
|
||||
];
|
||||
|
||||
const howToUse = [
|
||||
{ step: '01', title: 'Python 설치', desc: 'python.org에서 Python 3.10 이상을 설치하세요. "Add to PATH" 체크 필수.' },
|
||||
{ step: '02', title: '패키지 설치', desc: '터미널에서 pip install requests beautifulsoup4 openpyxl lxml 실행.' },
|
||||
{ step: '03', title: 'URL 설정', desc: '파일 상단 TARGET_URL에 크롤링할 주소를 입력하세요.' },
|
||||
{ step: '04', title: '실행', desc: 'python web_scraper_v1.0.py 실행 → 같은 폴더에 엑셀 파일이 생성됩니다.' },
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: '크롤링이 법적으로 문제없나요?',
|
||||
a: '공개된 정보 수집 자체는 일반적으로 허용되지만, 사이트의 robots.txt와 이용약관을 반드시 확인하세요. 로그인이 필요한 페이지, 개인정보, 저작권 데이터 수집은 법적 문제가 생길 수 있습니다.',
|
||||
},
|
||||
{
|
||||
q: '자바스크립트로 렌더링되는 사이트도 되나요?',
|
||||
a: 'requests + BeautifulSoup은 정적 HTML만 수집합니다. JS 렌더링 사이트(React, Vue 등)는 Selenium/Playwright가 필요하며, 맞춤 개발 서비스로 문의 주시면 구현해 드립니다.',
|
||||
},
|
||||
{
|
||||
q: '원하는 항목만 골라서 수집할 수 있나요?',
|
||||
a: '파일 내 extract_data 함수를 수정하면 됩니다. HTML 선택자(CSS Selector)로 원하는 요소만 지정할 수 있으며, 코드 내 주석에 예시가 포함되어 있습니다.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ScraperToolPage() {
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff]">
|
||||
|
||||
{/* Hero */}
|
||||
<div className="bg-gradient-to-br from-[#1e3a8a] via-[#1d4ed8] to-[#1e3a8a] px-6 py-12 lg:px-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Link href="/services/automation"
|
||||
className="inline-flex items-center gap-1.5 text-blue-300/60 hover:text-blue-300 text-sm mb-6 transition">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
업무 자동화 서비스로 돌아가기
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-6">
|
||||
<div className="w-20 h-20 rounded-2xl bg-blue-400/15 border border-blue-400/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-10 h-10 text-blue-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-blue-400 text-xs font-bold uppercase tracking-widest">FREE TOOL</span>
|
||||
<span className="bg-blue-400/20 border border-blue-400/40 text-blue-300 text-[10px] font-bold px-2 py-0.5 rounded-full">v1.0</span>
|
||||
<span className="bg-white/10 text-white/50 text-[10px] font-bold px-2 py-0.5 rounded-full">Python · BeautifulSoup</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-extrabold text-white mb-2 leading-tight">
|
||||
웹 크롤링 자동화 도구<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-300 to-cyan-300">
|
||||
Web Scraper
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-blue-100/50 text-sm leading-relaxed">
|
||||
공공데이터, 가격 비교, 뉴스 수집까지 — 원하는 웹 페이지의 데이터를 자동으로 수집해<br />
|
||||
엑셀 파일로 저장합니다. Python 기초 지식만 있으면 바로 사용 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 inline-grid grid-cols-3 gap-px bg-blue-400/10 border border-blue-400/20 rounded-2xl overflow-hidden">
|
||||
{[
|
||||
{ v: '6가지', l: '핵심 기능' },
|
||||
{ v: '무료', l: '완전 무료' },
|
||||
{ v: 'Python 3.10+', l: '지원 버전' },
|
||||
].map((s) => (
|
||||
<div key={s.l} className="bg-[#1e3a8a]/60 px-5 py-3 text-center">
|
||||
<div className="text-white font-extrabold text-base">{s.v}</div>
|
||||
<div className="text-blue-400/50 text-xs mt-0.5">{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-10 lg:px-12">
|
||||
<div className="max-w-4xl mx-auto space-y-10">
|
||||
|
||||
{/* 다운로드 카드 */}
|
||||
<div className="bg-white rounded-2xl border-2 border-blue-200 p-6 flex flex-col sm:flex-row items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-blue-700 text-xs font-bold uppercase tracking-widest mb-1">DOWNLOAD</div>
|
||||
<div className="font-extrabold text-[#04102b] text-lg mb-1">web_scraper_v1.0.py</div>
|
||||
<div className="text-slate-500 text-xs mb-3">크기: 약 8KB · Python 스크립트 · 상업적 이용 가능</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['Python 3.10+', '페이지네이션', '재시도 로직', '엑셀 자동 저장', '로그 저장'].map((t) => (
|
||||
<span key={t} className="text-[10px] font-bold px-2 py-0.5 rounded-full border border-blue-200 text-blue-700 bg-blue-50">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full sm:w-auto">
|
||||
<a
|
||||
href="/downloads/web_scraper_v1.0.py"
|
||||
download
|
||||
className="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-xl font-extrabold text-sm transition-all shadow-lg shadow-blue-900/20 w-full sm:w-48"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
무료 다운로드
|
||||
</a>
|
||||
<p className="text-[10px] text-slate-400 text-center">로그인 없이 즉시 다운로드</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기능 목록 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-extrabold text-[#04102b] mb-5">포함된 기능</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className={`rounded-xl border p-4 ${f.bg} ${f.border}`}>
|
||||
<div className={`${f.color} mb-3`}>{f.icon}</div>
|
||||
<div className={`text-xs font-bold mb-1 ${f.color}`}>{f.title}</div>
|
||||
<p className="text-slate-600 text-xs leading-relaxed">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용 방법 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-extrabold text-[#04102b] mb-5">사용 방법</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{howToUse.map((h) => (
|
||||
<div key={h.step} className="bg-white rounded-xl border border-[#dbe8ff] p-5 flex gap-4">
|
||||
<div className="text-blue-600 text-2xl font-black leading-none flex-shrink-0">{h.step}</div>
|
||||
<div>
|
||||
<div className="font-bold text-[#04102b] text-sm mb-1">{h.title}</div>
|
||||
<p className="text-slate-500 text-xs leading-relaxed">{h.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 코드 예시 */}
|
||||
<div className="bg-[#0f172a] rounded-2xl p-6 overflow-x-auto">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-xs font-bold text-blue-400 uppercase tracking-widest">CODE PREVIEW</span>
|
||||
<span className="text-slate-600 text-xs">extract_data 함수 수정 예시</span>
|
||||
</div>
|
||||
<pre className="text-sm text-slate-300 leading-relaxed font-mono whitespace-pre">{`def extract_data(soup, page_url):
|
||||
items = []
|
||||
# 상품 목록 수집 예시
|
||||
for item in soup.select(".product-item"):
|
||||
name = item.select_one(".name")
|
||||
price = item.select_one(".price")
|
||||
items.append({
|
||||
"상품명": name.get_text(strip=True),
|
||||
"가격": price.get_text(strip=True),
|
||||
"URL": page_url,
|
||||
})
|
||||
return items`}</pre>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div>
|
||||
<h2 className="text-xl font-extrabold text-[#04102b] mb-5">자주 묻는 질문</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-[#dbe8ff] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === i ? null : i)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 text-left"
|
||||
>
|
||||
<span className="font-bold text-[#04102b] text-sm">{faq.q}</span>
|
||||
<svg className={`w-4 h-4 text-slate-400 transition-transform ${openFaq === i ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{openFaq === i && (
|
||||
<div className="px-5 pb-4 text-slate-500 text-sm leading-relaxed border-t border-[#dbe8ff] pt-3">
|
||||
{faq.a}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="bg-gradient-to-r from-[#1e3a8a] to-[#1d4ed8] rounded-2xl p-8 text-center">
|
||||
<p className="text-blue-300 text-xs font-bold uppercase tracking-widest mb-2">CUSTOM DEVELOPMENT</p>
|
||||
<h3 className="text-white text-xl font-extrabold mb-2">더 복잡한 크롤링이 필요하다면?</h3>
|
||||
<p className="text-blue-100/50 text-sm mb-6 leading-relaxed">
|
||||
JS 렌더링 사이트, 로그인 필요, 대용량 수집, 자동 스케줄링까지<br />
|
||||
맞춤 개발로 정확히 원하는 데이터를 가져옵니다.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<a
|
||||
href="/downloads/web_scraper_v1.0.py"
|
||||
download
|
||||
className="inline-flex items-center justify-center gap-2 bg-blue-400 hover:bg-blue-300 text-[#1e3a8a] px-6 py-3 rounded-xl font-extrabold text-sm transition-all"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
무료 다운로드
|
||||
</a>
|
||||
<Link href="/freelance?service=업무+자동화"
|
||||
className="inline-flex items-center justify-center gap-2 bg-white/10 hover:bg-white/20 text-white border border-white/20 px-6 py-3 rounded-xl font-extrabold text-sm transition-all">
|
||||
맞춤 크롤러 개발 문의 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user