diff --git a/app/api/admin/quotes/[id]/route.ts b/app/api/admin/quotes/[id]/route.ts index 974759f..f8a3103 100644 --- a/app/api/admin/quotes/[id]/route.ts +++ b/app/api/admin/quotes/[id]/route.ts @@ -16,24 +16,50 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str const { id } = await params; const supabase = createAdminClient(); const { data, error } = await supabase.from('quotes').select('*').eq('id', id).single(); - if (error) return NextResponse.json({ error: error.message }, { status: 404 }); + if (error) return NextResponse.json({ error: '견적서를 찾을 수 없습니다' }, { status: 404 }); return NextResponse.json({ quote: data }); } export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { id } = await params; - const body = await request.json(); - const supabase = createAdminClient(); + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); + } + + // ── 허용 필드 화이트리스트 (시스템 필드 변조 방지) ─────────── + const ALLOWED_FIELDS = [ + 'title', 'client_name', 'client_email', 'client_phone', + 'items', 'maintenance', 'notes', 'status', + 'valid_until', 'discount', + ] as const; + + const sanitizedBody = Object.fromEntries( + ALLOWED_FIELDS + .filter((key) => key in body) + .map((key) => [key, body[key]]) + ); + + if (Object.keys(sanitizedBody).length === 0) { + return NextResponse.json({ error: '수정할 필드가 없습니다' }, { status: 400 }); + } + + const supabase = createAdminClient(); const { data, error } = await supabase .from('quotes') - .update({ ...body, updated_at: new Date().toISOString() }) + .update({ ...sanitizedBody, updated_at: new Date().toISOString() }) .eq('id', id) .select() .single(); - if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + if (error) { + console.error('[Admin Quotes] PUT error:', error.message); + return NextResponse.json({ error: '견적서 업데이트 실패' }, { status: 500 }); + } return NextResponse.json({ quote: data }); } @@ -42,6 +68,9 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: const { id } = await params; const supabase = createAdminClient(); const { error } = await supabase.from('quotes').delete().eq('id', id); - if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + if (error) { + console.error('[Admin Quotes] DELETE error:', error.message); + return NextResponse.json({ error: '견적서 삭제 실패' }, { status: 500 }); + } return NextResponse.json({ success: true }); } diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index b9d8012..52e8b97 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -1,37 +1,78 @@ import { NextResponse } from 'next/server'; import { Resend } from 'resend'; +import { + escapeHtml, + isValidEmail, + sanitizeStr, + checkRateLimit, + getClientIp, + INPUT_LIMITS, +} from '@/lib/security'; const resend = new Resend(process.env.RESEND_API_KEY); export async function POST(request: Request) { try { - const body = await request.json(); - const { name, phone, email, service, message } = body; + // ── Rate Limit: IP당 1분 5회 ────────────────────────────── + const ip = getClientIp(request); + const rl = checkRateLimit(`contact:${ip}`, 60_000, 5); + if (!rl.allowed) { + return NextResponse.json( + { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' }, + { + status: 429, + headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) }, + } + ); + } - // 입력 검증 + const body = await request.json(); + + // ── 입력 정제 + 길이 제한 ───────────────────────────────── + const name = sanitizeStr(body.name, INPUT_LIMITS.NAME); + const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE); + const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL); + const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE); + const message = sanitizeStr(body.message, INPUT_LIMITS.MESSAGE); + + // ── 필수값 검증 ─────────────────────────────────────────── if (!name || !email || !message) { return NextResponse.json( { error: '필수 항목을 모두 입력해주세요.' }, { status: 400 } ); } + if (!isValidEmail(email)) { + return NextResponse.json( + { error: '올바른 이메일 형식이 아닙니다.' }, + { status: 400 } + ); + } - // 이메일 발송 - const data = await resend.emails.send({ - from: 'onboarding@resend.dev', // Resend 기본 도메인 - to: ['bgg8988@gmail.com'], // 받는 이메일 - replyTo: email, // 문의자 이메일로 답장 가능 - subject: `[쟁승메이드] 새로운 문의: ${service || '문의'}`, + // ── HTML 이스케이프 (XSS 방지) ──────────────────────────── + const safeSubject = escapeHtml(service || '문의'); + const safeName = escapeHtml(name); + const safePhone = escapeHtml(phone || '미입력'); + const safeEmail = escapeHtml(email); + const safeService = escapeHtml(service || '미선택'); + // message는 pre-wrap으로 렌더링되므로 반드시 이스케이프 + const safeMessage = escapeHtml(message); + + await resend.emails.send({ + from: 'onboarding@resend.dev', + to: ['bgg8988@gmail.com'], + replyTo: email, + subject: `[쟁승메이드] 새로운 문의: ${safeSubject}`, html: `

새로운 프로젝트 문의가 도착했습니다


-

이름: ${name}

-

연락처: ${phone || '미입력'}

-

이메일: ${email}

-

서비스: ${service || '미선택'}

+

이름: ${safeName}

+

연락처: ${safePhone}

+

이메일: ${safeEmail}

+

서비스: ${safeService}


문의 내용:

-

${message}

+

${safeMessage}


이 메일은 jaengseung-made.com의 문의 폼에서 발송되었습니다. @@ -44,7 +85,8 @@ export async function POST(request: Request) { { status: 200 } ); } catch (error) { - console.error('Email send error:', error); + // 클라이언트에 내부 오류 상세 노출 금지 + console.error('[Contact] Email send error:', error); return NextResponse.json( { error: '메일 전송에 실패했습니다. 다시 시도해주세요.' }, { status: 500 } diff --git a/app/api/payment/confirm/route.ts b/app/api/payment/confirm/route.ts index bcbe2b8..d013de0 100644 --- a/app/api/payment/confirm/route.ts +++ b/app/api/payment/confirm/route.ts @@ -1,16 +1,43 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { checkRateLimit, getClientIp } from '@/lib/security'; export async function POST(request: NextRequest) { try { - const { paymentKey, orderId, amount } = await request.json(); - - if (!paymentKey || !orderId || !amount) { - return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 }); + // ── Rate Limit: IP당 1분 10회 (결제 재시도 남용 방지) ───── + const ip = getClientIp(request); + const rl = checkRateLimit(`payment:${ip}`, 60_000, 10); + if (!rl.allowed) { + return NextResponse.json( + { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' }, + { status: 429 } + ); } - // 1. Supabase에서 order 확인 + const body = await request.json(); + const { paymentKey, orderId, amount } = body; + + // ── 기본 파라미터 검증 ──────────────────────────────────── + if (!paymentKey || !orderId || amount === undefined) { + return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 }); + } + // 타입 강제 검증 + if ( + typeof paymentKey !== 'string' || paymentKey.length > 200 || + typeof orderId !== 'string' || orderId.length > 200 || + typeof amount !== 'number' || amount <= 0 || !Number.isInteger(amount) + ) { + return NextResponse.json({ error: '잘못된 파라미터 형식' }, { status: 400 }); + } + + // ── 로그인 사용자 확인 ──────────────────────────────────── const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 }); + } + + // ── DB에서 주문 확인 (금액 서버사이드 검증) ─────────────── const { data: order, error: orderFetchError } = await supabase .from('orders') .select('*') @@ -20,24 +47,30 @@ export async function POST(request: NextRequest) { if (orderFetchError || !order) { return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 }); } + // 주문 소유자 검증 (다른 사용자 주문 처리 방지) + if (order.user_id !== user.id) { + return NextResponse.json({ error: '접근 권한이 없습니다' }, { status: 403 }); + } + // 서버 DB 금액과 비교 (클라이언트 금액 위조 방어) if (order.amount !== amount) { - return NextResponse.json({ error: '결제 금액 불일치' }, { status: 400 }); + console.warn(`[Payment] 금액 불일치 orderId=${orderId} db=${order.amount} req=${amount} user=${user.id}`); + return NextResponse.json({ error: '결제 금액이 올바르지 않습니다' }, { status: 400 }); } if (order.status === 'paid') { return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 }); } - // 2. 토스페이먼츠 서버 승인 - // dev: TOSS_SECRET_KEY=test_sk_* (테스트 결제) - // prod: TOSS_SECRET_KEY=live_sk_* (실결제) — Vercel 환경변수에 설정 - const secretKey = process.env.TOSS_SECRET_KEY!; - const isTestKey = secretKey.startsWith('test_'); - if (!isTestKey && process.env.NODE_ENV === 'development') { - // 실수로 live 키를 dev에서 쓰는 것 방지 + // ── 토스페이먼츠 서버 승인 ──────────────────────────────── + const secretKey = process.env.TOSS_SECRET_KEY; + if (!secretKey) { + console.error('[Payment] TOSS_SECRET_KEY 미설정'); + return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 }); + } + if (!secretKey.startsWith('test_') && process.env.NODE_ENV === 'development') { console.warn('[Payment] WARNING: live Toss key detected in development!'); } - const encoded = Buffer.from(`${secretKey}:`).toString('base64'); + const encoded = Buffer.from(`${secretKey}:`).toString('base64'); const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', { method: 'POST', headers: { @@ -48,42 +81,47 @@ export async function POST(request: NextRequest) { }); if (!tossRes.ok) { - const err = await tossRes.json(); - return NextResponse.json({ error: err.message || '토스 승인 실패' }, { status: 400 }); + const err = await tossRes.json().catch(() => ({})); + // 내부 에러 코드는 서버 로그에만 기록 + console.error(`[Payment] Toss 승인 실패 orderId=${orderId} code=${err.code} msg=${err.message}`); + return NextResponse.json( + { error: '결제 승인에 실패했습니다. 카드사 또는 고객센터에 문의해주세요.' }, + { status: 400 } + ); } const tossData = await tossRes.json(); - // 3. orders 상태 paid로 업데이트 + // ── orders 상태 업데이트 ────────────────────────────────── const { error: updateError } = await supabase .from('orders') .update({ status: 'paid' }) .eq('id', orderId); if (updateError) { - console.error('Order update error:', updateError); - return NextResponse.json({ error: '주문 상태 업데이트 실패: ' + updateError.message }, { status: 500 }); + console.error('[Payment] Order update error:', updateError.message); + return NextResponse.json({ error: '주문 상태 업데이트 실패' }, { status: 500 }); } - // 4. payments 레코드 생성 + // ── payments 레코드 생성 ────────────────────────────────── const { error: paymentError } = await supabase.from('payments').insert({ - user_id: order.user_id, - order_id: orderId, - product_name: order.metadata?.product_name ?? order.product_id, - amount: order.amount, - status: 'paid', - pg_provider: 'toss', + user_id: order.user_id, + order_id: orderId, + product_name: order.metadata?.product_name ?? order.product_id, + amount: order.amount, + status: 'paid', + pg_provider: 'toss', pg_payment_key: paymentKey, }); if (paymentError) { - console.error('Payment insert error:', paymentError); - return NextResponse.json({ error: '결제 내역 저장 실패: ' + paymentError.message }, { status: 500 }); + console.error('[Payment] Payment insert error:', paymentError.message); + return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 }); } return NextResponse.json({ success: true, data: tossData }); } catch (error: unknown) { - console.error('Payment confirm error:', error); - return NextResponse.json({ error: '서버 오류' }, { status: 500 }); + console.error('[Payment] Unexpected error:', error); + return NextResponse.json({ error: '서버 오류가 발생했습니다' }, { status: 500 }); } } diff --git a/app/api/saju/analyze/route.ts b/app/api/saju/analyze/route.ts index 870587f..9fb516d 100644 --- a/app/api/saju/analyze/route.ts +++ b/app/api/saju/analyze/route.ts @@ -64,16 +64,49 @@ const MODELS = [ export async function POST(request: Request) { try { - const { saju, daeun, daeunList, gender, engineData } = await request.json(); + // ── 결제 사용자 인증 (Gemini API 무단 호출 방지) ────────── + const { createClient } = await import('@/lib/supabase/server'); + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (user) { + // 로그인된 경우: saju_detail 결제 여부 확인 + const { data: paidOrder } = await supabase + .from('orders') + .select('id') + .eq('user_id', user.id) + .eq('product_id', 'saju_detail') + .eq('status', 'paid') + .maybeSingle(); + + if (!paidOrder) { + return NextResponse.json({ error: '사주 리포트를 구매한 사용자만 이용할 수 있습니다' }, { status: 403 }); + } + } else { + // 비로그인 사용자는 AI 호출 불가 + return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 }); + } + + // ── 입력 길이 검증 (DoS / 프롬프트 인젝션 기초 방어) ────── + const raw = await request.json(); + if (JSON.stringify(raw).length > 50_000) { + return NextResponse.json({ error: '요청 데이터가 너무 큽니다' }, { status: 400 }); + } + const { saju, daeun, daeunList, gender, engineData } = raw; + + // gender 값 제한 + if (gender !== 'male' && gender !== 'female') { + return NextResponse.json({ error: '잘못된 성별 값' }, { status: 400 }); + } // 종합 분석 수행 let analysis; try { analysis = performFullAnalysis(saju); } catch (analysisError: any) { - console.error('[사주] 분석 계산 오류:', analysisError.message); + console.error('[사주] 분석 계산 오류'); return NextResponse.json( - { error: '사주 분석 계산 중 오류: ' + analysisError.message }, + { error: '사주 분석 중 오류가 발생했습니다' }, { status: 500 } ); } diff --git a/app/services/automation/page.tsx b/app/services/automation/page.tsx index c61aee9..60cb054 100644 --- a/app/services/automation/page.tsx +++ b/app/services/automation/page.tsx @@ -25,9 +25,9 @@ const tools = [ { id: 'scraper', title: '웹 스크래핑 도구', - subtitle: 'Web Scraper v0.9 (베타)', + subtitle: 'Web Scraper v1.0', desc: '공공데이터·쇼핑몰 가격·뉴스를 자동 수집해 엑셀로 저장하는 Python 기반 수집 도구.', - tags: ['Python', 'BeautifulSoup', 'Excel 출력'], + tags: ['Python', 'BeautifulSoup', 'Excel 출력', '무료'], color: '#2563eb', bgColor: '#eff6ff', borderColor: '#bfdbfe', @@ -37,24 +37,24 @@ const tools = [ ), href: '/services/automation/tools/scraper', - ready: false, + ready: true, }, { - id: 'email', - title: '이메일 자동 발송 도구', - subtitle: 'Email Scheduler (준비중)', - desc: '조건 설정 후 일정 시간에 자동으로 이메일을 발송. 엑셀 수신자 목록 연동 지원.', - tags: ['Python', 'SMTP', '스케줄링'], + id: 'ppt', + title: 'PPT 제작 자동화 도구', + subtitle: 'PPT Automation v1.0', + desc: '엑셀 데이터를 읽어 표지·내용·마무리 슬라이드를 자동 생성하는 Python 기반 PPT 도구.', + tags: ['Python', 'python-pptx', 'openpyxl', '무료'], color: '#7c3aed', bgColor: '#f5f3ff', borderColor: '#ddd6fe', icon: ( - + ), - href: '/services/automation/tools/email', - ready: false, + href: '/services/automation/tools/ppt', + ready: true, }, ]; diff --git a/app/services/automation/tools/ppt/page.tsx b/app/services/automation/tools/ppt/page.tsx new file mode 100644 index 0000000..94432ee --- /dev/null +++ b/app/services/automation/tools/ppt/page.tsx @@ -0,0 +1,365 @@ +'use client'; + +import Link from 'next/link'; + +const features = [ + { + icon: ( + + + + ), + title: '표지 · 내용 · 마무리 자동 생성', + desc: '표지(제목/날짜), 내용 슬라이드(불릿 포인트), 마무리 슬라이드까지 3가지 레이아웃을 자동으로 구성합니다.', + color: 'text-orange-600', + bg: 'bg-orange-50', + border: 'border-orange-200', + }, + { + icon: ( + + + + ), + title: '엑셀에서 데이터 일괄 생성', + desc: 'data.xlsx 파일의 A열(제목), B~열(불릿 내용)을 읽어 슬라이드를 자동 생성합니다. 수십 장도 한 번에 처리.', + color: 'text-emerald-600', + bg: 'bg-emerald-50', + border: 'border-emerald-200', + }, + { + icon: ( + + + + ), + title: '색상 테마 커스터마이징', + desc: '상단 설정 영역에서 PRIMARY, SECONDARY, ACCENT 색상을 RGB로 변경하면 전체 슬라이드에 즉시 반영됩니다.', + color: 'text-violet-600', + bg: 'bg-violet-50', + border: 'border-violet-200', + }, + { + icon: ( + + + + ), + title: '슬라이드 번호 자동 추가', + desc: '각 내용 슬라이드 우측 상단에 슬라이드 번호(01, 02...)가 자동으로 표시됩니다. 따로 설정할 필요 없음.', + color: 'text-blue-600', + bg: 'bg-blue-50', + border: 'border-blue-200', + }, + { + icon: ( + + + + ), + title: '16:9 비율 · 맑은 고딕 폰트', + desc: '발표 표준 비율인 16:9(13.33×7.5인치)로 설정되며, 한글 가독성이 좋은 맑은 고딕 폰트를 기본 적용합니다.', + color: 'text-cyan-600', + bg: 'bg-cyan-50', + border: 'border-cyan-200', + }, + { + icon: ( + + + + ), + 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 ( +

+ + {/* ─── Hero ─── */} +
+
+ + + + + + + + + + + + + + +
+ +
+ + + 업무 자동화로 + + +
+ + + +
+ +

PPT AUTOMATION · 프레젠테이션 자동화

+

+ PPT 제작을
+ 코드로 자동화 +

+

+ 엑셀 데이터만 준비하면 표지·내용·마무리 슬라이드를 자동 생성.
+ python-pptx 기반으로 디자인까지 자동 적용됩니다. +

+ +
+ + + 무료 다운로드 (Python .py) + + + 맞춤 개발 문의 → + +
+
+
+ + {/* ─── 다운로드 카드 ─── */} +
+
+
+
+ + + +
+
+
+ PPT AUTOMATION v1.0 + 무료 +
+
PPT 제작 자동화 도구
+

python-pptx 기반 · 엑셀 연동 · 표지/내용/마무리 자동 생성 · 색상 테마 커스터마이징

+
+ {['Python 3.8+', 'python-pptx', 'openpyxl', '한글 지원', '엑셀 연동'].map((tag) => ( + {tag} + ))} +
+
+ + + 다운로드 + +
+
+
+ + {/* ─── 기능 ─── */} +
+
+
+

FEATURES

+

주요 기능

+
+
+ {features.map((f) => ( +
+
+ {f.icon} +
+

{f.title}

+

{f.desc}

+
+ ))} +
+
+
+ + {/* ─── 사용법 ─── */} +
+
+
+

HOW TO USE

+

사용 방법

+
+
+ {howToUse.map((h) => ( +
+
+
+ {h.step} +
+

{h.title}

+
+

{h.desc}

+
+ {h.code} +
+
+ ))} +
+
+
+ + {/* ─── 코드 미리보기 ─── */} +
+
+
+

PREVIEW

+

설정 영역 미리보기

+

이 부분만 수정하면 원하는 PPT가 완성됩니다

+
+
+
+
+
+
+ ppt_automation_v1.0.py +
+
{`# ── 설정 (이 부분을 수정하세요) ──────────────
+
+DATA_FILE   = "data.xlsx"       # 입력 엑셀 파일
+OUTPUT_FILE = f"발표자료_{datetime}.pptx"
+
+# 표지 정보
+TITLE_TEXT    = "발표 제목을 입력하세요"
+SUBTITLE_TEXT = "부제목 또는 발표자 이름"
+DATE_TEXT     = "2025년 01월 01일"
+
+# 색상 테마 (RGB 값으로 변경)
+COLOR_PRIMARY   = RGBColor(0x1D, 0x4E, 0xD8)  # 파란색
+COLOR_SECONDARY = RGBColor(0x0F, 0x17, 0x2A)  # 다크 네이비
+COLOR_ACCENT    = RGBColor(0x60, 0xA5, 0xFA)  # 라이트 블루
+
+FONT_NAME = "맑은 고딕"   # 한글 폰트`}
+            
+
+

+ * 코드 미리보기는 실제 파일의 일부입니다. 다운로드 후 설정 영역 전체를 수정해서 사용하세요. +

+
+
+ + {/* ─── FAQ ─── */} +
+
+
+

FAQ

+

자주 묻는 질문

+
+
+ {faqs.map((faq) => ( +
+
+ Q. +
+
{faq.q}
+
{faq.a}
+
+
+
+ ))} +
+
+
+ + {/* ─── CTA ─── */} +
+
+
+

CUSTOM DEVELOPMENT

+

더 복잡한 PPT 자동화가 필요하신가요?

+

+ 이미지 삽입, 차트 자동 생성, 브랜드 템플릿 적용 등
+ 맞춤 PPT 자동화를 개발해드립니다. +

+
+ + + 무료 버전 다운로드 + + + 맞춤 개발 문의 → + +
+
+
+
+ +
+ ); +} diff --git a/app/services/automation/tools/scraper/page.tsx b/app/services/automation/tools/scraper/page.tsx new file mode 100644 index 0000000..cf839b8 --- /dev/null +++ b/app/services/automation/tools/scraper/page.tsx @@ -0,0 +1,284 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +const features = [ + { + icon: ( + + + + ), + title: '웹 페이지 데이터 자동 수집', + desc: '공공데이터, 쇼핑몰 가격, 뉴스 기사 등 원하는 페이지의 데이터를 자동으로 수집합니다.', + color: 'text-blue-600', bg: 'bg-blue-50', border: 'border-blue-200', + }, + { + icon: ( + + + + ), + title: '엑셀 자동 저장', + desc: '수집한 데이터를 열 서식, 헤더 스타일이 적용된 엑셀 파일로 자동 저장합니다.', + color: 'text-emerald-600', bg: 'bg-emerald-50', border: 'border-emerald-200', + }, + { + icon: ( + + + + ), + title: '페이지네이션 자동 탐색', + desc: '다음 페이지 링크를 자동으로 찾아 여러 페이지의 데이터를 연속으로 수집합니다.', + color: 'text-violet-600', bg: 'bg-violet-50', border: 'border-violet-200', + }, + { + icon: ( + + + + ), + title: '재시도 로직 내장', + desc: '네트워크 오류나 일시적 접속 실패 시 자동으로 재시도합니다. 수집 실패 최소화.', + color: 'text-orange-600', bg: 'bg-orange-50', border: 'border-orange-200', + }, + { + icon: ( + + + + ), + title: '요청 간격 자동 조절', + desc: '서버에 부하를 주지 않도록 요청 간격을 자동으로 조절합니다. 차단 위험 최소화.', + color: 'text-cyan-600', bg: 'bg-cyan-50', border: 'border-cyan-200', + }, + { + icon: ( + + + + ), + 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(null); + + return ( +
+ + {/* Hero */} +
+
+ + + + + 업무 자동화 서비스로 돌아가기 + + +
+
+ + + +
+
+
+ FREE TOOL + v1.0 + Python · BeautifulSoup +
+

+ 웹 크롤링 자동화 도구
+ + Web Scraper + +

+

+ 공공데이터, 가격 비교, 뉴스 수집까지 — 원하는 웹 페이지의 데이터를 자동으로 수집해
+ 엑셀 파일로 저장합니다. Python 기초 지식만 있으면 바로 사용 가능합니다. +

+
+
+ +
+ {[ + { v: '6가지', l: '핵심 기능' }, + { v: '무료', l: '완전 무료' }, + { v: 'Python 3.10+', l: '지원 버전' }, + ].map((s) => ( +
+
{s.v}
+
{s.l}
+
+ ))} +
+
+
+ +
+
+ + {/* 다운로드 카드 */} +
+
+
DOWNLOAD
+
web_scraper_v1.0.py
+
크기: 약 8KB · Python 스크립트 · 상업적 이용 가능
+
+ {['Python 3.10+', '페이지네이션', '재시도 로직', '엑셀 자동 저장', '로그 저장'].map((t) => ( + {t} + ))} +
+
+
+ + + + + 무료 다운로드 + +

로그인 없이 즉시 다운로드

+
+
+ + {/* 기능 목록 */} +
+

포함된 기능

+
+ {features.map((f) => ( +
+
{f.icon}
+
{f.title}
+

{f.desc}

+
+ ))} +
+
+ + {/* 사용 방법 */} +
+

사용 방법

+
+ {howToUse.map((h) => ( +
+
{h.step}
+
+
{h.title}
+

{h.desc}

+
+
+ ))} +
+
+ + {/* 코드 예시 */} +
+
+ CODE PREVIEW + extract_data 함수 수정 예시 +
+
{`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`}
+
+ + {/* FAQ */} +
+

자주 묻는 질문

+
+ {faqs.map((faq, i) => ( +
+ + {openFaq === i && ( +
+ {faq.a} +
+ )} +
+ ))} +
+
+ + {/* CTA */} +
+

CUSTOM DEVELOPMENT

+

더 복잡한 크롤링이 필요하다면?

+

+ JS 렌더링 사이트, 로그인 필요, 대용량 수집, 자동 스케줄링까지
+ 맞춤 개발로 정확히 원하는 데이터를 가져옵니다. +

+
+ + + + + 무료 다운로드 + + + 맞춤 크롤러 개발 문의 → + +
+
+ +
+
+
+ ); +} diff --git a/lib/security.ts b/lib/security.ts new file mode 100644 index 0000000..39bf0b8 --- /dev/null +++ b/lib/security.ts @@ -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, '''); +} + +// ── 입력 검증 ───────────────────────────────────────────────────── +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(); + +// 메모리 누수 방지: 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' + ); +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..c4eeae7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,42 @@ import type { NextConfig } from "next"; 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; diff --git a/public/downloads/ppt_automation_v1.0.py b/public/downloads/ppt_automation_v1.0.py new file mode 100644 index 0000000..2103632 --- /dev/null +++ b/public/downloads/ppt_automation_v1.0.py @@ -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() diff --git a/public/downloads/web_scraper_v1.0.py b/public/downloads/web_scraper_v1.0.py new file mode 100644 index 0000000..f0b6000 --- /dev/null +++ b/public/downloads/web_scraper_v1.0.py @@ -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()