feat: 보안 강화 + 자동화 도구 3종 추가 (웹 크롤러·PPT·엑셀)

- lib/security.ts: escapeHtml, isValidEmail, sanitizeStr, checkRateLimit 유틸 추가
- next.config.ts: 보안 헤더 적용 (X-Frame-Options, HSTS, Permissions-Policy 등)
- api/contact: XSS 방어, Rate Limit(5/min), 입력 길이 제한
- api/payment/confirm: 사용자 인증·소유권 검증, 타입 체크, 에러 메시지 정제
- api/admin/quotes: PUT 허용 필드 화이트리스트 적용
- api/saju/analyze: 로그인·결제 검증, 입력 크기 제한, gender 값 검증
- public/downloads/web_scraper_v1.0.py: requests+BS4+openpyxl 웹 크롤러
- public/downloads/ppt_automation_v1.0.py: python-pptx+openpyxl PPT 자동화
- app/services/automation/tools/scraper: 크롤러 상세 페이지 추가
- app/services/automation/tools/ppt: PPT 도구 상세 페이지 추가
- app/services/automation/page.tsx: scraper ready=true, email→PPT 교체

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 07:25:46 +09:00
parent 273da6b7b3
commit df22691d50
11 changed files with 1626 additions and 66 deletions

View File

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

View File

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