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 { 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
lib/security.ts
Normal file
95
lib/security.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* lib/security.ts — 공통 보안 유틸리티
|
||||||
|
* XSS 방지, 입력 검증, Rate Limiting
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── HTML 이스케이프 (이메일 템플릿 XSS 방지) ──────────────────────
|
||||||
|
export function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 입력 검증 ─────────────────────────────────────────────────────
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
363
public/downloads/ppt_automation_v1.0.py
Normal file
363
public/downloads/ppt_automation_v1.0.py
Normal 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()
|
||||||
276
public/downloads/web_scraper_v1.0.py
Normal file
276
public/downloads/web_scraper_v1.0.py
Normal 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()
|
||||||
Reference in New Issue
Block a user