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:
2026-03-23 07:25:46 +09:00
parent 273da6b7b3
commit df22691d50
11 changed files with 1626 additions and 66 deletions

View File

@@ -16,24 +16,50 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
const { id } = await params; const { id } = await params;
const supabase = createAdminClient(); const supabase = createAdminClient();
const { data, error } = await supabase.from('quotes').select('*').eq('id', id).single(); 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 }); return NextResponse.json({ quote: data });
} }
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params; 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 const { data, error } = await supabase
.from('quotes') .from('quotes')
.update({ ...body, updated_at: new Date().toISOString() }) .update({ ...sanitizedBody, updated_at: new Date().toISOString() })
.eq('id', id) .eq('id', id)
.select() .select()
.single(); .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 }); return NextResponse.json({ quote: data });
} }
@@ -42,6 +68,9 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
const { id } = await params; const { id } = await params;
const supabase = createAdminClient(); const supabase = createAdminClient();
const { error } = await supabase.from('quotes').delete().eq('id', id); 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 }); return NextResponse.json({ success: true });
} }

View File

@@ -1,37 +1,78 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { Resend } from 'resend'; import { Resend } from 'resend';
import {
escapeHtml,
isValidEmail,
sanitizeStr,
checkRateLimit,
getClientIp,
INPUT_LIMITS,
} from '@/lib/security';
const resend = new Resend(process.env.RESEND_API_KEY); const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); // ── Rate Limit: IP당 1분 5회 ──────────────────────────────
const { name, phone, email, service, message } = body; 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) { if (!name || !email || !message) {
return NextResponse.json( return NextResponse.json(
{ error: '필수 항목을 모두 입력해주세요.' }, { error: '필수 항목을 모두 입력해주세요.' },
{ status: 400 } { status: 400 }
); );
} }
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: '올바른 이메일 형식이 아닙니다.' },
{ status: 400 }
);
}
// 이메일 발송 // ── HTML 이스케이프 (XSS 방지) ────────────────────────────
const data = await resend.emails.send({ const safeSubject = escapeHtml(service || '문의');
from: 'onboarding@resend.dev', // Resend 기본 도메인 const safeName = escapeHtml(name);
to: ['bgg8988@gmail.com'], // 받는 이메일 const safePhone = escapeHtml(phone || '미입력');
replyTo: email, // 문의자 이메일로 답장 가능 const safeEmail = escapeHtml(email);
subject: `[쟁승메이드] 새로운 문의: ${service || '문의'}`, 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: ` html: `
<h2>새로운 프로젝트 문의가 도착했습니다</h2> <h2>새로운 프로젝트 문의가 도착했습니다</h2>
<hr /> <hr />
<p><strong>이름:</strong> ${name}</p> <p><strong>이름:</strong> ${safeName}</p>
<p><strong>연락처:</strong> ${phone || '미입력'}</p> <p><strong>연락처:</strong> ${safePhone}</p>
<p><strong>이메일:</strong> ${email}</p> <p><strong>이메일:</strong> ${safeEmail}</p>
<p><strong>서비스:</strong> ${service || '미선택'}</p> <p><strong>서비스:</strong> ${safeService}</p>
<hr /> <hr />
<h3>문의 내용:</h3> <h3>문의 내용:</h3>
<p style="white-space: pre-wrap;">${message}</p> <p style="white-space: pre-wrap;">${safeMessage}</p>
<hr /> <hr />
<p style="color: #666; font-size: 12px;"> <p style="color: #666; font-size: 12px;">
이 메일은 jaengseung-made.com의 문의 폼에서 발송되었습니다. 이 메일은 jaengseung-made.com의 문의 폼에서 발송되었습니다.
@@ -44,7 +85,8 @@ export async function POST(request: Request) {
{ status: 200 } { status: 200 }
); );
} catch (error) { } catch (error) {
console.error('Email send error:', error); // 클라이언트에 내부 오류 상세 노출 금지
console.error('[Contact] Email send error:', error);
return NextResponse.json( return NextResponse.json(
{ error: '메일 전송에 실패했습니다. 다시 시도해주세요.' }, { error: '메일 전송에 실패했습니다. 다시 시도해주세요.' },
{ status: 500 } { status: 500 }

View File

@@ -1,16 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { checkRateLimit, getClientIp } from '@/lib/security';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { paymentKey, orderId, amount } = await request.json(); // ── Rate Limit: IP당 1분 10회 (결제 재시도 남용 방지) ─────
const ip = getClientIp(request);
if (!paymentKey || !orderId || !amount) { const rl = checkRateLimit(`payment:${ip}`, 60_000, 10);
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 }); 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 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 const { data: order, error: orderFetchError } = await supabase
.from('orders') .from('orders')
.select('*') .select('*')
@@ -20,24 +47,30 @@ export async function POST(request: NextRequest) {
if (orderFetchError || !order) { if (orderFetchError || !order) {
return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 }); return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 });
} }
// 주문 소유자 검증 (다른 사용자 주문 처리 방지)
if (order.user_id !== user.id) {
return NextResponse.json({ error: '접근 권한이 없습니다' }, { status: 403 });
}
// 서버 DB 금액과 비교 (클라이언트 금액 위조 방어)
if (order.amount !== amount) { 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') { if (order.status === 'paid') {
return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 }); return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
} }
// 2. 토스페이먼츠 서버 승인 // ── 토스페이먼츠 서버 승인 ────────────────────────────────
// dev: TOSS_SECRET_KEY=test_sk_* (테스트 결제) const secretKey = process.env.TOSS_SECRET_KEY;
// prod: TOSS_SECRET_KEY=live_sk_* (실결제) — Vercel 환경변수에 설정 if (!secretKey) {
const secretKey = process.env.TOSS_SECRET_KEY!; console.error('[Payment] TOSS_SECRET_KEY 미설정');
const isTestKey = secretKey.startsWith('test_'); return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 });
if (!isTestKey && process.env.NODE_ENV === 'development') { }
// 실수로 live 키를 dev에서 쓰는 것 방지 if (!secretKey.startsWith('test_') && process.env.NODE_ENV === 'development') {
console.warn('[Payment] WARNING: live Toss key detected in 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', { const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -48,42 +81,47 @@ export async function POST(request: NextRequest) {
}); });
if (!tossRes.ok) { if (!tossRes.ok) {
const err = await tossRes.json(); const err = await tossRes.json().catch(() => ({}));
return NextResponse.json({ error: err.message || '토스 승인 실패' }, { status: 400 }); // 내부 에러 코드는 서버 로그에만 기록
console.error(`[Payment] Toss 승인 실패 orderId=${orderId} code=${err.code} msg=${err.message}`);
return NextResponse.json(
{ error: '결제 승인에 실패했습니다. 카드사 또는 고객센터에 문의해주세요.' },
{ status: 400 }
);
} }
const tossData = await tossRes.json(); const tossData = await tossRes.json();
// 3. orders 상태 paid로 업데이트 // ── orders 상태 업데이트 ──────────────────────────────────
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('orders') .from('orders')
.update({ status: 'paid' }) .update({ status: 'paid' })
.eq('id', orderId); .eq('id', orderId);
if (updateError) { if (updateError) {
console.error('Order update error:', updateError); console.error('[Payment] Order update error:', updateError.message);
return NextResponse.json({ error: '주문 상태 업데이트 실패: ' + updateError.message }, { status: 500 }); return NextResponse.json({ error: '주문 상태 업데이트 실패' }, { status: 500 });
} }
// 4. payments 레코드 생성 // ── payments 레코드 생성 ──────────────────────────────────
const { error: paymentError } = await supabase.from('payments').insert({ const { error: paymentError } = await supabase.from('payments').insert({
user_id: order.user_id, user_id: order.user_id,
order_id: orderId, order_id: orderId,
product_name: order.metadata?.product_name ?? order.product_id, product_name: order.metadata?.product_name ?? order.product_id,
amount: order.amount, amount: order.amount,
status: 'paid', status: 'paid',
pg_provider: 'toss', pg_provider: 'toss',
pg_payment_key: paymentKey, pg_payment_key: paymentKey,
}); });
if (paymentError) { if (paymentError) {
console.error('Payment insert error:', paymentError); console.error('[Payment] Payment insert error:', paymentError.message);
return NextResponse.json({ error: '결제 내역 저장 실패: ' + paymentError.message }, { status: 500 }); return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 });
} }
return NextResponse.json({ success: true, data: tossData }); return NextResponse.json({ success: true, data: tossData });
} catch (error: unknown) { } catch (error: unknown) {
console.error('Payment confirm error:', error); console.error('[Payment] Unexpected error:', error);
return NextResponse.json({ error: '서버 오류' }, { status: 500 }); return NextResponse.json({ error: '서버 오류가 발생했습니다' }, { status: 500 });
} }
} }

View File

@@ -64,16 +64,49 @@ const MODELS = [
export async function POST(request: Request) { export async function POST(request: Request) {
try { 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; let analysis;
try { try {
analysis = performFullAnalysis(saju); analysis = performFullAnalysis(saju);
} catch (analysisError: any) { } catch (analysisError: any) {
console.error('[사주] 분석 계산 오류:', analysisError.message); console.error('[사주] 분석 계산 오류');
return NextResponse.json( return NextResponse.json(
{ error: '사주 분석 계산 중 오류: ' + analysisError.message }, { error: '사주 분석 중 오류가 발생했습니다' },
{ status: 500 } { status: 500 }
); );
} }

View File

@@ -25,9 +25,9 @@ const tools = [
{ {
id: 'scraper', id: 'scraper',
title: '웹 스크래핑 도구', title: '웹 스크래핑 도구',
subtitle: 'Web Scraper v0.9 (베타)', subtitle: 'Web Scraper v1.0',
desc: '공공데이터·쇼핑몰 가격·뉴스를 자동 수집해 엑셀로 저장하는 Python 기반 수집 도구.', desc: '공공데이터·쇼핑몰 가격·뉴스를 자동 수집해 엑셀로 저장하는 Python 기반 수집 도구.',
tags: ['Python', 'BeautifulSoup', 'Excel 출력'], tags: ['Python', 'BeautifulSoup', 'Excel 출력', '무료'],
color: '#2563eb', color: '#2563eb',
bgColor: '#eff6ff', bgColor: '#eff6ff',
borderColor: '#bfdbfe', borderColor: '#bfdbfe',
@@ -37,24 +37,24 @@ const tools = [
</svg> </svg>
), ),
href: '/services/automation/tools/scraper', href: '/services/automation/tools/scraper',
ready: false, ready: true,
}, },
{ {
id: 'email', id: 'ppt',
title: '이메일 자동 발송 도구', title: 'PPT 제작 자동화 도구',
subtitle: 'Email Scheduler (준비중)', subtitle: 'PPT Automation v1.0',
desc: '조건 설정 후 일정 시간에 자동으로 이메일을 발송. 엑셀 수신자 목록 연동 지원.', desc: '엑셀 데이터를 읽어 표지·내용·마무리 슬라이드를 자동 생성하는 Python 기반 PPT 도구.',
tags: ['Python', 'SMTP', '스케줄링'], tags: ['Python', 'python-pptx', 'openpyxl', '무료'],
color: '#7c3aed', color: '#7c3aed',
bgColor: '#f5f3ff', bgColor: '#f5f3ff',
borderColor: '#ddd6fe', borderColor: '#ddd6fe',
icon: ( icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} className="w-7 h-7"> <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> </svg>
), ),
href: '/services/automation/tools/email', href: '/services/automation/tools/ppt',
ready: false, ready: true,
}, },
]; ];

View 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>
);
}

View 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>
);
}

95
lib/security.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* lib/security.ts — 공통 보안 유틸리티
* XSS 방지, 입력 검증, Rate Limiting
*/
// ── HTML 이스케이프 (이메일 템플릿 XSS 방지) ──────────────────────
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// ── 입력 검증 ─────────────────────────────────────────────────────
export function isValidEmail(email: string): boolean {
return /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,}$/.test(email);
}
export function isValidPhone(phone: string): boolean {
const digits = phone.replace(/[\s\-]/g, '');
return /^0\d{9,10}$/.test(digits);
}
/** 문자열을 maxLen으로 자르고 트림 */
export function sanitizeStr(str: unknown, maxLen: number): string {
if (typeof str !== 'string') return '';
return str.slice(0, maxLen).trim();
}
// ── 입력 길이 상수 ────────────────────────────────────────────────
export const INPUT_LIMITS = {
NAME: 50,
PHONE: 20,
EMAIL: 100,
SERVICE: 100,
MESSAGE: 3000,
} as const;
// ── In-memory Rate Limiter ────────────────────────────────────────
// Vercel serverless 환경에서 인스턴스별 기본 보호용
// 더 강력한 보호가 필요하면 Upstash Redis + @upstash/ratelimit 사용
interface RLEntry { count: number; resetAt: number }
const rlMap = new Map<string, RLEntry>();
// 메모리 누수 방지: 10분마다 만료된 엔트리 정리
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rlMap.entries()) {
if (now > entry.resetAt) rlMap.delete(key);
}
}, 10 * 60 * 1000);
export interface RateLimitResult {
allowed: boolean;
remaining: number;
retryAfterMs: number;
}
/**
* @param key 식별자 (IP + endpoint 조합 권장)
* @param windowMs 시간 창 (ms)
* @param max 창 내 최대 허용 횟수
*/
export function checkRateLimit(
key: string,
windowMs: number,
max: number,
): RateLimitResult {
const now = Date.now();
const entry = rlMap.get(key);
if (!entry || now > entry.resetAt) {
rlMap.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, remaining: max - 1, retryAfterMs: 0 };
}
if (entry.count >= max) {
return { allowed: false, remaining: 0, retryAfterMs: entry.resetAt - now };
}
entry.count++;
return { allowed: true, remaining: max - entry.count, retryAfterMs: 0 };
}
/** Request에서 클라이언트 IP 추출 (Vercel 헤더 우선) */
export function getClientIp(request: Request): string {
const headers = request.headers as Headers;
return (
headers.get('x-real-ip') ??
headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
'unknown'
);
}

View File

@@ -1,7 +1,42 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ async headers() {
return [
{
source: "/:path*",
headers: [
// 클릭재킹 방지
{ key: "X-Frame-Options", value: "DENY" },
// MIME 스니핑 방지
{ key: "X-Content-Type-Options", value: "nosniff" },
// Referrer 정책
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// XSS 필터 (레거시 브라우저)
{ key: "X-XSS-Protection", value: "1; mode=block" },
// HTTPS 강제 (Vercel은 자동 HTTPS이므로 안전)
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
// 권한 정책
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
],
},
// API 엔드포인트: 캐시 금지 + CORS 차단
{
source: "/api/:path*",
headers: [
{ key: "Cache-Control", value: "no-store, max-age=0" },
// 동일 출처 요청만 허용 (외부 도메인 API 직접 호출 차단)
{ key: "X-Frame-Options", value: "DENY" },
],
},
];
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
================================================================================
PPT 제작 자동화 도구 v1.0
Made by 쟁승메이드 | jaengseung-made.com
================================================================================
필요 패키지 설치:
pip install python-pptx openpyxl
사용법:
1. 아래 ── 설정 ── 영역에서 옵션을 수정하세요.
2. data.xlsx 파일을 준비하세요 (형식: A열=슬라이드 제목, B~열=불릿 내용).
→ 파일이 없으면 예시 데이터로 자동 실행됩니다.
3. 터미널에서 실행: python ppt_automation_v1.0.py
4. 같은 폴더에 PPT 파일이 저장됩니다.
지원 기능:
- 표지 / 내용 / 마무리 슬라이드 자동 생성
- 엑셀에서 데이터 읽어 슬라이드 일괄 생성
- 16:9 비율, 맞은 고딕 폰트
- 색상 테마 커스터마이징 가능
- 슬라이드 번호 자동 추가
맞춤 개발이 필요하다면: jaengseung-made.com/freelance
================================================================================
"""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
import openpyxl
from datetime import datetime
import logging
import sys
import os
# ── 설정 (이 부분을 수정하세요) ───────────────────────────────────────────────
DATA_FILE = "data.xlsx" # 입력 엑셀 파일 (없으면 예시 데이터 사용)
OUTPUT_FILE = f"발표자료_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pptx"
# 표지 정보
TITLE_TEXT = "발표 제목을 입력하세요"
SUBTITLE_TEXT = "부제목 또는 발표자 이름"
DATE_TEXT = datetime.now().strftime("%Y년 %m월 %d")
CONTACT_TEXT = "jaengseung-made.com | 문의: bgg8988@gmail.com"
# 슬라이드 크기 (16:9)
SLIDE_W = Inches(13.33)
SLIDE_H = Inches(7.5)
# ── 색상 테마 ─────────────────────────────────────────────────────────────────
# 원하는 색상으로 변경하세요 (RGB)
COLOR_PRIMARY = RGBColor(0x1D, 0x4E, 0xD8) # 파란색 (헤더, 강조)
COLOR_SECONDARY = RGBColor(0x0F, 0x17, 0x2A) # 다크 네이비 (표지 배경)
COLOR_ACCENT = RGBColor(0x60, 0xA5, 0xFA) # 라이트 블루 (서브 강조)
COLOR_TEXT = RGBColor(0x1E, 0x29, 0x3B) # 진한 슬레이트 (본문)
COLOR_WHITE = RGBColor(0xFF, 0xFF, 0xFF)
COLOR_BG = RGBColor(0xF1, 0xF5, 0xF9) # 연한 배경
COLOR_BULLET = RGBColor(0x1D, 0x4E, 0xD8) # 불릿 색상
FONT_NAME = "맑은 고딕" # 한글 폰트 (시스템에 설치된 폰트명)
# ────────────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)
# ── 헬퍼 함수 ─────────────────────────────────────────────────────────────────
def rgb(r: int, g: int, b: int) -> RGBColor:
return RGBColor(r, g, b)
def add_rect(slide, left, top, width, height,
fill: RGBColor | None = None,
line: RGBColor | None = None):
"""사각형 도형 추가"""
shape = slide.shapes.add_shape(
1, # MSO_SHAPE_TYPE.RECTANGLE
left, top, width, height,
)
if fill:
shape.fill.solid()
shape.fill.fore_color.rgb = fill
else:
shape.fill.background()
if line:
shape.line.color.rgb = line
else:
shape.line.fill.background()
return shape
def add_text(slide, text: str,
left, top, width, height,
size: int = 18,
bold: bool = False,
color: RGBColor = COLOR_TEXT,
align=PP_ALIGN.LEFT,
italic: bool = False) -> None:
"""텍스트 박스 추가"""
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
para = tf.paragraphs[0]
para.alignment = align
run = para.add_run()
run.text = text
run.font.size = Pt(size)
run.font.bold = bold
run.font.italic = italic
run.font.name = FONT_NAME
run.font.color.rgb = color
# ── 슬라이드 생성 함수 ────────────────────────────────────────────────────────
def create_title_slide(prs: Presentation, title: str, subtitle: str, date: str) -> None:
"""표지 슬라이드"""
slide = prs.slides.add_slide(prs.slide_layouts[6]) # 빈 레이아웃
# 배경
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=COLOR_SECONDARY)
# 왼쪽 강조 세로선
add_rect(slide,
left=Inches(1.2), top=Inches(2.2),
width=Inches(0.06), height=Inches(3.0),
fill=COLOR_ACCENT)
# 타이틀
add_text(slide, title,
left=Inches(1.5), top=Inches(2.2),
width=Inches(10.5), height=Inches(1.8),
size=42, bold=True, color=COLOR_WHITE)
# 서브타이틀
add_text(slide, subtitle,
left=Inches(1.5), top=Inches(4.1),
width=Inches(10.5), height=Inches(0.9),
size=22, color=COLOR_ACCENT, italic=True)
# 구분선
add_rect(slide,
left=Inches(1.5), top=Inches(5.2),
width=Inches(10.5), height=Inches(0.015),
fill=rgb(0x1E, 0x40, 0xAF))
# 날짜
add_text(slide, date,
left=Inches(1.5), top=Inches(5.4),
width=Inches(6), height=Inches(0.6),
size=13, color=rgb(0x94, 0xA3, 0xB8))
logger.info(" 📋 표지 슬라이드 생성")
def create_content_slide(prs: Presentation,
title: str,
bullets: list[str],
slide_num: int) -> None:
"""내용 슬라이드"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
# 배경
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=COLOR_BG)
# 상단 헤더
add_rect(slide, 0, 0, SLIDE_W, Inches(1.2), fill=COLOR_PRIMARY)
# 슬라이드 번호 (우상단)
add_text(slide, f"{slide_num:02d}",
left=Inches(11.8), top=Inches(0.18),
width=Inches(1.3), height=Inches(0.85),
size=30, bold=True, color=COLOR_ACCENT,
align=PP_ALIGN.RIGHT)
# 제목
add_text(slide, title,
left=Inches(0.7), top=Inches(0.22),
width=Inches(10.8), height=Inches(0.8),
size=24, bold=True, color=COLOR_WHITE)
# 흰색 콘텐츠 박스
add_rect(slide,
left=Inches(0.7), top=Inches(1.4),
width=Inches(11.9), height=Inches(5.7),
fill=COLOR_WHITE)
# 불릿 포인트
MAX_BULLETS = 8
for i, bullet in enumerate(bullets[:MAX_BULLETS]):
y = Inches(1.75 + i * 0.65)
# 불릿 마크 (작은 사각형)
add_rect(slide,
left=Inches(0.95), top=y + Inches(0.22),
width=Inches(0.12), height=Inches(0.12),
fill=COLOR_BULLET)
# 불릿 텍스트
add_text(slide, bullet,
left=Inches(1.25), top=y,
width=Inches(11.1), height=Inches(0.62),
size=16, color=COLOR_TEXT)
# 하단 라인
add_rect(slide,
left=Inches(0.7), top=Inches(7.0),
width=Inches(11.9), height=Inches(0.015),
fill=COLOR_PRIMARY)
logger.info(f" 📄 슬라이드 {slide_num} 생성: {title}")
def create_divider_slide(prs: Presentation, chapter: str, label: str = "") -> None:
"""챕터 구분 슬라이드 (선택사항)"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=COLOR_PRIMARY)
add_rect(slide,
left=0, top=Inches(3.5),
width=SLIDE_W, height=Inches(0.03),
fill=COLOR_ACCENT)
if label:
add_text(slide, label,
left=Inches(0), top=Inches(2.5),
width=SLIDE_W, height=Inches(0.6),
size=14, color=COLOR_ACCENT, align=PP_ALIGN.CENTER)
add_text(slide, chapter,
left=Inches(0), top=Inches(3.0),
width=SLIDE_W, height=Inches(1.2),
size=38, bold=True, color=COLOR_WHITE, align=PP_ALIGN.CENTER)
def create_closing_slide(prs: Presentation, contact: str) -> None:
"""마무리 슬라이드"""
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_rect(slide, 0, 0, SLIDE_W, SLIDE_H, fill=COLOR_SECONDARY)
add_rect(slide,
left=0, top=Inches(3.55),
width=SLIDE_W, height=Inches(0.03),
fill=COLOR_ACCENT)
add_text(slide, "감사합니다",
left=Inches(0), top=Inches(2.5),
width=SLIDE_W, height=Inches(1.0),
size=52, bold=True, color=COLOR_WHITE, align=PP_ALIGN.CENTER)
add_text(slide, contact,
left=Inches(0), top=Inches(3.9),
width=SLIDE_W, height=Inches(0.7),
size=16, color=COLOR_ACCENT, align=PP_ALIGN.CENTER)
logger.info(" 🎬 마무리 슬라이드 생성")
# ── 데이터 로드 ───────────────────────────────────────────────────────────────
EXAMPLE_DATA = [
{
"title": "시장 현황 분석",
"bullets": [
"2024년 국내 시장 규모: 1조 2,800억 원 (전년비 +18.3%)",
"상위 3개사 점유율 합계: 61.4% — 과점 구조 지속",
"B2B 부문 성장률: B2C 대비 2.3배 높은 성장세",
"주요 고객층: 중소기업 및 스타트업 비중 확대 중",
],
},
{
"title": "핵심 문제 정의",
"bullets": [
"운영 비용 연평균 15.2% 상승 → 수익성 압박",
"고객 이탈률 22% — 업계 평균(14%)보다 높음",
"내부 반복 업무에 월 평균 220시간 소요 (비효율)",
"경쟁사 대비 디지털 전환 12개월 지연 상태",
],
},
{
"title": "제안 솔루션",
"bullets": [
"Phase 1: 업무 자동화 도입 — 반복 업무 70% 자동화",
"Phase 2: 고객 데이터 플랫폼(CDP) 구축 — 이탈 예측",
"Phase 3: 실시간 대시보드 도입 — 의사결정 속도 향상",
"예상 ROI: 투자 대비 320% (12개월 기준)",
],
},
{
"title": "추진 일정 및 기대 효과",
"bullets": [
"1단계 (1~2개월): 현황 분석 및 시스템 설계",
"2단계 (3~4개월): 파일럿 운영 및 피드백 수집",
"3단계 (5~6개월): 전사 확대 및 고도화",
"연간 비용 절감 목표: 약 4.5억 원",
"고객 이탈률 목표: 22% → 12% 이하",
],
},
]
def load_from_excel(filepath: str) -> list[dict]:
"""엑셀 파일에서 슬라이드 데이터 로드 (A열=제목, B열~=불릿)"""
if not os.path.exists(filepath):
logger.warning(f"⚠️ '{filepath}' 파일 없음 → 예시 데이터로 실행합니다.")
return EXAMPLE_DATA
wb = openpyxl.load_workbook(filepath)
ws = wb.active
slides = []
for row in ws.iter_rows(min_row=2, values_only=True):
title = str(row[0] or "").strip()
if not title:
continue
bullets = [str(c).strip() for c in row[1:] if c and str(c).strip()]
slides.append({"title": title, "bullets": bullets})
logger.info(f"엑셀 로드 완료: {len(slides)}개 슬라이드 데이터")
return slides
# ── 메인 ──────────────────────────────────────────────────────────────────────
def main():
logger.info("=" * 60)
logger.info(" PPT 제작 자동화 도구 v1.0 | 쟁승메이드")
logger.info("=" * 60)
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
# 표지
create_title_slide(prs, TITLE_TEXT, SUBTITLE_TEXT, DATE_TEXT)
# 내용 슬라이드
slides_data = load_from_excel(DATA_FILE)
for i, slide in enumerate(slides_data, start=1):
create_content_slide(prs, slide["title"], slide["bullets"], slide_num=i)
# 마무리
create_closing_slide(prs, CONTACT_TEXT)
prs.save(OUTPUT_FILE)
total = len(slides_data) + 2 # 표지 + 내용 + 마무리
logger.info(f"\n✅ 저장 완료: {OUTPUT_FILE} ({total}슬라이드)")
logger.info("\n맞춤 PPT 자동화가 필요하다면: jaengseung-made.com")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
================================================================================
웹 크롤링 자동화 도구 v1.0
Made by 쟁승메이드 | jaengseung-made.com
================================================================================
필요 패키지 설치:
pip install requests beautifulsoup4 openpyxl lxml
사용법:
1. 아래 ── 설정 ── 영역에서 TARGET_URL과 옵션을 수정하세요.
2. 터미널에서 실행: python web_scraper_v1.0.py
3. 같은 폴더에 엑셀 결과 파일이 저장됩니다.
지원 기능:
- 단일/다중 페이지 크롤링 (페이지네이션 자동 탐색)
- 재시도 로직 (네트워크 오류 자동 재시도)
- 엑셀 저장 (서식 포함)
- 요청 간격 조절 (서버 부하 방지)
- 로그 출력 및 파일 저장
맞춤 개발이 필요하다면: jaengseung-made.com/freelance
================================================================================
"""
import requests
from bs4 import BeautifulSoup
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from datetime import datetime
import time
import logging
import sys
import os
# ── 설정 (이 부분을 수정하세요) ───────────────────────────────────────────────
TARGET_URL = "https://example.com" # 크롤링할 URL
DELAY_SECONDS = 1.5 # 요청 간격 (서버 부하 방지, 최소 1.0 권장)
MAX_PAGES = 5 # 최대 크롤링 페이지 수 (1 = 단일 페이지)
OUTPUT_FILE = f"크롤링결과_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
LOG_TO_FILE = True # True: 로그 파일도 저장
# 페이지네이션 설정 (다음 페이지 링크 선택자 — 사이트마다 다름)
NEXT_PAGE_SELECTOR = "a.next, a[rel='next'], .pagination .next a"
# 데이터 추출 설정 (아래 extract_data 함수에서 상세 수정)
# 기본: 페이지 내 모든 링크 수집 (예시용)
# ────────────────────────────────────────────────────────────────────────────
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": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
}
def setup_logger() -> logging.Logger:
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
if LOG_TO_FILE:
log_name = f"scraper_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
handlers.append(logging.FileHandler(log_name, encoding="utf-8"))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=handlers,
)
return logging.getLogger(__name__)
logger = setup_logger()
def fetch_page(url: str, retries: int = 3) -> BeautifulSoup | None:
"""페이지 가져오기 (재시도 포함)"""
for attempt in range(retries):
try:
resp = requests.get(url, headers=HEADERS, timeout=15)
resp.raise_for_status()
resp.encoding = resp.apparent_encoding or "utf-8"
logger.info(f"✅ 페이지 로드 성공: {url}")
return BeautifulSoup(resp.text, "lxml")
except requests.exceptions.HTTPError as e:
logger.warning(f"HTTP 오류 [{attempt+1}/{retries}]: {e}")
except requests.exceptions.ConnectionError:
logger.warning(f"연결 오류 [{attempt+1}/{retries}]: {url}")
except requests.exceptions.Timeout:
logger.warning(f"시간 초과 [{attempt+1}/{retries}]: {url}")
except Exception as e:
logger.warning(f"알 수 없는 오류 [{attempt+1}/{retries}]: {e}")
if attempt < retries - 1:
wait = DELAY_SECONDS * (attempt + 2)
logger.info(f"{wait:.1f}초 후 재시도...")
time.sleep(wait)
logger.error(f"❌ 페이지 로드 실패: {url}")
return None
def extract_data(soup: BeautifulSoup, page_url: str) -> list[dict]:
"""
========================================================================
페이지에서 데이터를 추출합니다.
🔧 이 함수를 목적에 맞게 수정하세요!
예시 1 — 제품 목록 수집:
for item in soup.select(".product-item"):
name = item.select_one(".product-name")
price = item.select_one(".product-price")
items.append({
"상품명": name.get_text(strip=True) if name else "",
"가격": price.get_text(strip=True) if price else "",
"수집URL": page_url,
})
예시 2 — 뉴스 기사 수집:
for article in soup.select("article, .news-item"):
title = article.select_one("h2, h3, .title")
date = article.select_one(".date, time")
items.append({
"제목": title.get_text(strip=True) if title else "",
"날짜": date.get_text(strip=True) if date else "",
})
========================================================================
"""
items = []
collected_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# ── 기본 예시: 모든 링크 텍스트 수집 ──────────────────────
for link in soup.find_all("a", href=True):
text = link.get_text(strip=True)
href = link["href"]
# 너무 짧거나 빈 텍스트 제외
if text and len(text) >= 3:
items.append({
"링크 텍스트": text[:200],
"URL": href[:500],
"출처 페이지": page_url,
"수집 시간": collected_at,
})
return items
def get_next_page_url(soup: BeautifulSoup, current_url: str) -> str | None:
"""다음 페이지 URL 반환 (없으면 None)"""
next_link = soup.select_one(NEXT_PAGE_SELECTOR)
if not next_link:
return None
href = next_link.get("href", "")
if not href:
return None
# 상대 URL → 절대 URL 변환
if href.startswith("http"):
return href
from urllib.parse import urljoin
return urljoin(current_url, href)
def save_to_excel(data: list[dict], filepath: str) -> None:
"""엑셀 저장 (서식 포함)"""
if not data:
logger.warning("⚠️ 저장할 데이터가 없습니다.")
return
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "크롤링 결과"
# 헤더 스타일
header_fill = PatternFill(start_color="1D4ED8", end_color="1D4ED8", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=11, name="맑은 고딕")
center_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
left=Side(style="thin"), right=Side(style="thin"),
top=Side(style="thin"), bottom=Side(style="thin"),
)
headers = list(data[0].keys())
ws.row_dimensions[1].height = 28
for col_idx, header in enumerate(headers, start=1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.fill = header_fill
cell.font = header_font
cell.alignment = center_align
cell.border = thin_border
# 데이터 입력
row_font = Font(size=10, name="맑은 고딕")
for row_idx, item in enumerate(data, start=2):
for col_idx, key in enumerate(headers, start=1):
cell = ws.cell(row=row_idx, column=col_idx, value=item.get(key, ""))
cell.font = row_font
cell.alignment = Alignment(vertical="center", wrap_text=True)
cell.border = thin_border
# 짝수 행 배경색
if row_idx % 2 == 0:
for col_idx in range(1, len(headers) + 1):
ws.cell(row=row_idx, column=col_idx).fill = PatternFill(
start_color="EFF6FF", end_color="EFF6FF", fill_type="solid"
)
# 열 너비 자동 조정
for col in ws.columns:
max_len = max(len(str(cell.value or "")) for cell in col)
ws.column_dimensions[col[0].column_letter].width = min(max_len + 4, 60)
# 첫 행 고정
ws.freeze_panes = "A2"
# 요약 시트
ws_summary = wb.create_sheet("요약")
ws_summary["A1"] = "크롤링 요약"
ws_summary["A1"].font = Font(bold=True, size=14)
ws_summary["A3"] = "수집 URL"
ws_summary["B3"] = TARGET_URL
ws_summary["A4"] = "수집 건수"
ws_summary["B4"] = len(data)
ws_summary["A5"] = "수집 일시"
ws_summary["B5"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
ws_summary["A7"] = "Made by 쟁승메이드 | jaengseung-made.com"
ws_summary["A7"].font = Font(color="6B7280", size=9)
wb.save(filepath)
logger.info(f"✅ 저장 완료: {filepath} ({len(data):,}건)")
def main():
logger.info("=" * 60)
logger.info(" 웹 크롤링 자동화 도구 v1.0 | 쟁승메이드")
logger.info("=" * 60)
logger.info(f"대상 URL: {TARGET_URL}")
logger.info(f"최대 페이지: {MAX_PAGES}")
all_data: list[dict] = []
current_url: str | None = TARGET_URL
for page_num in range(1, MAX_PAGES + 1):
if not current_url:
break
logger.info(f"\n[페이지 {page_num}/{MAX_PAGES}] {current_url}")
soup = fetch_page(current_url)
if not soup:
break
page_data = extract_data(soup, current_url)
all_data.extend(page_data)
logger.info(f" → 수집: {len(page_data)}건 (누적: {len(all_data)}건)")
current_url = get_next_page_url(soup, current_url)
if current_url and page_num < MAX_PAGES:
logger.info(f" → 다음 페이지 대기 {DELAY_SECONDS}초...")
time.sleep(DELAY_SECONDS)
logger.info(f"\n총 수집: {len(all_data):,}")
save_to_excel(all_data, OUTPUT_FILE)
logger.info("\n완료! 맞춤 자동화 개발이 필요하다면: jaengseung-made.com")
if __name__ == "__main__":
main()