-
Notifications
You must be signed in to change notification settings - Fork 0
[deploy] contest 기능 배포 #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
06dc7d5
feat: contest api 구현
qkwltkwkd1 c0a6aba
refactor: 폰트 풀백
qkwltkwkd1 6985e7d
fix: total_in_file 수정
qkwltkwkd1 32711b2
fix: 충돌 해결
qkwltkwkd1 c3bab71
fix: ci 해결
qkwltkwkd1 d91080d
refactor: 하드코딩 수정
qkwltkwkd1 3904d81
Merge branch 'develop' into feat/104-contest-crawler
selnem 06e0af1
refactor: config·deps 중복 정리 및 Gemini/민원 wiring 공통화
selnem 57ff93e
feat: 공모전 핀 크롤·카드뉴스·DB 적재 파이프라인 추가
selnem 855bf91
fix: ComplaintPetition 미사용 ORM 컬럼 매핑 제거
selnem 20be102
Merge pull request #120 from IssueIssyu/feat/104-contest-crawler-db
selnem 901b791
Merge pull request #105 from IssueIssyu/feat/104-contest-crawler
selnem 7c7a609
fix: 공모전 슬라이드 빈 필터를 정규화 이후에 수행
selnem 21d8a9e
Merge pull request #122 from IssueIssyu/hotfix/AttributeError
selnem File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Binary file not shown.
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| from app.contest_cardnews.render import render_contest_cardnews_slides | ||
| from app.contest_cardnews.slides import parse_contest_cardnews_slides_json | ||
|
|
||
| __all__ = ["parse_contest_cardnews_slides_json", "render_contest_cardnews_slides"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| from __future__ import annotations | ||
|
|
||
| # 정책 카드뉴스와 동일 (app.policy_cardnews.constants) | ||
| CANVAS_WIDTH = 1080 | ||
| CANVAS_HEIGHT = 1350 | ||
|
|
||
| CARD_INSET = 28 | ||
| CONTENT_PAD = 40 | ||
| INNER_PAD_X = 20 | ||
| INNER_PAD_Y = 12 | ||
| CHROME_HEIGHT = 118 | ||
|
|
||
| GAP_SM = 8 | ||
| GAP_MD = 14 | ||
| GAP_LG = 22 | ||
| # 슬라이드 내 요소 간 여백 | ||
| ELEMENT_GAP = 20 | ||
| ROW_GAP = 22 | ||
| TITLE_TOP_PAD = 28 | ||
| CARD_INNER_PAD = 16 | ||
| LABEL_STRIP_H = 58 | ||
|
|
||
| INK = (28, 32, 40) | ||
| INK_SOFT = (72, 80, 96) | ||
| STAR_PEACH = (255, 190, 160) | ||
| NOTE_PINK = (255, 160, 190) | ||
|
|
||
| # 둥근미소는 동일 pt에서 작게 보여 전체 스케일 업 | ||
| FONT_SCALE = 1.28 | ||
|
|
||
| # 레이아웃 겹침 방지용 예약 높이 | ||
| POINT_FOOTER_H = 56 | ||
| MASCOT_ZONE_H = 300 | ||
| MASCOT_GAP = 24 | ||
| SPEECH_ICON_GAP = 22 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import Any | ||
|
|
||
| from app.contest_cardnews.template.dispatch import ( | ||
| LAYOUT_CHECKLIST, | ||
| LAYOUT_COVER, | ||
| LAYOUT_CTA, | ||
| LAYOUT_TABLE, | ||
| normalize_layout_type, | ||
| ) | ||
| from app.policy_cardnews.copy import is_filler_text, polish_korean_text | ||
|
|
||
| _SPEECH_MAX = 14 | ||
| _TARGET_SLIDES = 3 | ||
| _MIDDLE_LAYOUTS = (LAYOUT_TABLE, LAYOUT_CHECKLIST) | ||
|
|
||
|
|
||
| def _content_score(slide: dict[str, Any]) -> int: | ||
| score = 0 | ||
| for key in ("eyebrow", "headline", "highlight", "body", "cta", "point"): | ||
| score += len(str(slide.get(key) or "").strip()) | ||
| for item in slide.get("items") or []: | ||
| if isinstance(item, dict): | ||
| score += len(str(item.get("label") or "")) + len(str(item.get("text") or "")) | ||
| return score | ||
|
|
||
|
|
||
| def _middle_layout_bonus(layout: str) -> int: | ||
| if layout == LAYOUT_TABLE: | ||
| return 40 | ||
| if layout == LAYOUT_CHECKLIST: | ||
| return 30 | ||
| return 0 | ||
|
|
||
|
|
||
| def compact_contest_deck(slides: list[dict[str, Any]]) -> list[dict[str, Any]]: | ||
| """표지 + 요약 1장 + CTA = 3장. 중간은 table/checklist 우선.""" | ||
| rows = [s for s in slides if isinstance(s, dict)] | ||
| if len(rows) <= _TARGET_SLIDES: | ||
| return _renumber_slides(rows) | ||
|
|
||
| cover = rows[0] | ||
| cta = rows[-1] | ||
| middles = rows[1:-1] | ||
|
|
||
| def rank(slide: dict[str, Any]) -> tuple[int, int]: | ||
| layout = normalize_layout_type(str(slide.get("layout_type") or "")) | ||
| prefer = 0 if layout in _MIDDLE_LAYOUTS else 1 | ||
| return (prefer, -(_content_score(slide) + _middle_layout_bonus(layout))) | ||
|
|
||
| best_middle = min(middles, key=rank) | ||
| merged_layout = normalize_layout_type(str(best_middle.get("layout_type") or "")) | ||
| if merged_layout not in _MIDDLE_LAYOUTS: | ||
| if len(best_middle.get("items") or []) >= 3: | ||
| best_middle = {**best_middle, "layout_type": LAYOUT_CHECKLIST} | ||
| elif best_middle.get("items"): | ||
| best_middle = {**best_middle, "layout_type": LAYOUT_TABLE} | ||
|
|
||
| return _renumber_slides([cover, best_middle, cta]) | ||
|
|
||
|
|
||
| def _renumber_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]: | ||
| out: list[dict[str, Any]] = [] | ||
| for index, slide in enumerate(slides, start=1): | ||
| row = dict(slide) | ||
| row["slide"] = index | ||
| out.append(row) | ||
| return out | ||
|
|
||
|
|
||
| def normalize_contest_slide_copy(slide: dict[str, Any]) -> dict[str, Any]: | ||
| row = dict(slide) | ||
| layout = str(row.get("layout_type") or "").strip() | ||
|
|
||
| for key in ("eyebrow", "headline", "highlight", "subtext", "body", "cta", "speech", "point"): | ||
| if key in row: | ||
| row[key] = polish_korean_text(str(row.get(key) or "")) | ||
|
|
||
| simplified_items: list[dict[str, str]] = [] | ||
| for item in row.get("items") or []: | ||
| if isinstance(item, dict): | ||
| simplified_items.append( | ||
| { | ||
| "label": polish_korean_text(str(item.get("label") or item.get("title") or "")), | ||
| "text": polish_korean_text( | ||
| str(item.get("text") or item.get("value") or item.get("content") or ""), | ||
| ), | ||
| }, | ||
| ) | ||
| elif str(item).strip(): | ||
| simplified_items.append({"label": "", "text": polish_korean_text(str(item))}) | ||
| if simplified_items: | ||
| row["items"] = simplified_items[:5] | ||
|
|
||
| headline = row.get("headline", "") | ||
| highlight = row.get("highlight", "") | ||
| if highlight and headline and highlight == headline and "cover" in layout: | ||
| row["headline"] = "" | ||
|
|
||
| body = row.get("body", "") | ||
| if body and is_filler_text(body) and "cover" not in layout: | ||
| row["body"] = "" | ||
|
|
||
| if not row.get("speech"): | ||
| row["speech"] = _derive_contest_speech(row, layout=layout) | ||
|
|
||
| row["use_image"] = False | ||
| return row | ||
|
|
||
|
|
||
| def _derive_contest_speech(slide: dict[str, Any], *, layout: str) -> str: | ||
| explicit = str(slide.get("speech") or "").strip() | ||
| if explicit and len(explicit) <= _SPEECH_MAX: | ||
| return explicit | ||
| if "cta" in layout: | ||
| cta = str(slide.get("cta") or "").strip() | ||
| if cta and len(cta) <= _SPEECH_MAX: | ||
| return cta.rstrip(".!") + "!" | ||
| if "cover" in layout: | ||
| return "놓치지 마!" | ||
| return "" | ||
|
|
||
|
|
||
| def is_contest_slide_empty(slide: dict[str, Any]) -> bool: | ||
| layout = str(slide.get("layout_type") or "") | ||
| if "cover" in layout: | ||
| return not bool( | ||
| str(slide.get("headline") or "").strip() | ||
| or str(slide.get("highlight") or "").strip() | ||
| or str(slide.get("body") or "").strip() | ||
| or str(slide.get("eyebrow") or "").strip() | ||
| ) | ||
| if "cta" in layout: | ||
| return not ( | ||
| bool(str(slide.get("cta") or "").strip()) | ||
| or bool(str(slide.get("headline") or "").strip()) | ||
| ) | ||
| if slide.get("items"): | ||
| return len([ | ||
| i for i in slide["items"] | ||
| if (str(i.get("text") or "").strip() if isinstance(i, dict) else str(i).strip()) | ||
| ]) == 0 | ||
| return not bool(str(slide.get("body") or slide.get("headline") or "").strip()) | ||
|
|
||
|
|
||
| def prepare_contest_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]: | ||
| rows = compact_contest_deck(slides) | ||
| normalized = [normalize_contest_slide_copy(s) for s in rows] | ||
| return [s for s in normalized if not is_contest_slide_empty(s)] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import random | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from PIL import Image | ||
|
|
||
| from app.contest_cardnews.template import ( | ||
| LAYOUT_COVER, | ||
| LAYOUT_CTA, | ||
| apply_deck_palette, | ||
| normalize_contest_slide, | ||
| render_contest_slide, | ||
| resolve_palette, | ||
| ) | ||
| from app.contest_cardnews.copy import ( | ||
| is_contest_slide_empty, | ||
| normalize_contest_slide_copy, | ||
| prepare_contest_slides, | ||
| ) | ||
| from app.policy_cardnews.mascot import pick_mascot, pick_pin_mascot | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _REPO_ROOT = Path(__file__).resolve().parents[2] | ||
|
|
||
|
|
||
| @dataclass | ||
| class ContestSlideRenderContext: | ||
| slide: dict[str, Any] | ||
| host_org: str | ||
| mascot: Image.Image | None | ||
| mascot_name: str | ||
| slide_total: int | ||
| source_url: str = "" | ||
|
|
||
|
|
||
| def _to_handoff_path(path: Path) -> str: | ||
| try: | ||
| return path.relative_to(_REPO_ROOT).as_posix() | ||
| except ValueError: | ||
| return path.as_posix() | ||
|
|
||
|
|
||
| def _render_slide_image(*, ctx: ContestSlideRenderContext) -> Image.Image: | ||
| slide_no = int(ctx.slide.get("slide") or 1) | ||
| slide = normalize_contest_slide(ctx.slide, index=slide_no, total=ctx.slide_total) | ||
| palette = resolve_palette(str(slide.get("template_palette") or "pastel_mint")) | ||
| return render_contest_slide( | ||
| slide, | ||
| palette=palette, | ||
| mascot=ctx.mascot, | ||
| source_url=ctx.source_url, | ||
| ) | ||
|
|
||
|
|
||
| async def render_contest_cardnews_slides( | ||
| *, | ||
| contentid: str, | ||
| slides: list[dict[str, Any]], | ||
| output_dir: Path, | ||
| host_org: str = "", | ||
| source_url: str = "", | ||
| ) -> list[str]: | ||
| """공모전 전용 브라우저형 템플릿 + 캐릭터 PNG (정책 템플릿·크롤 이미지 미사용).""" | ||
| target_dir = output_dir / contentid | ||
| target_dir.mkdir(parents=True, exist_ok=True) | ||
|
|
||
| rng = random.Random(contentid) | ||
| prepared = prepare_contest_slides(slides) | ||
| for row in prepared: | ||
| row["use_image"] = False | ||
| prepared = apply_deck_palette(prepared, rng=rng, contentid=contentid) | ||
| prepared = [normalize_contest_slide_copy(s) for s in prepared] | ||
| slides_to_render = [s for s in prepared if not is_contest_slide_empty(s)] | ||
| if not slides_to_render: | ||
| raise ValueError("렌더링할 카드뉴스 슬라이드가 없습니다 (내용 부족)") | ||
|
|
||
| slide_total = len(slides_to_render) | ||
| saved_paths: list[str] = [] | ||
|
|
||
| for index, slide in enumerate(slides_to_render, start=1): | ||
| slide = dict(slide) | ||
| slide["slide"] = index | ||
| slide["use_image"] = False | ||
| layout = str(slide.get("layout_type") or LAYOUT_COVER) | ||
| is_cover = layout == LAYOUT_COVER or index == 1 | ||
| is_cta = index == slide_total or layout == LAYOUT_CTA | ||
|
|
||
| mascot_name = "" | ||
| mascot: Image.Image | None = None | ||
| if is_cover: | ||
| mascot_pick = pick_mascot(rng) | ||
| if mascot_pick: | ||
| mascot_name, mascot = mascot_pick[0], mascot_pick[1] | ||
| elif is_cta: | ||
| mascot_pick = pick_pin_mascot(rng) | ||
| if mascot_pick: | ||
| mascot_name, mascot = mascot_pick[0], mascot_pick[1] | ||
| ctx = ContestSlideRenderContext( | ||
| slide=slide, | ||
| host_org=host_org, | ||
| mascot=mascot, | ||
| mascot_name=mascot_name, | ||
| slide_total=slide_total, | ||
| source_url=source_url, | ||
| ) | ||
| out_path = target_dir / f"slide_{index:02d}.png" | ||
| image = _render_slide_image(ctx=ctx) | ||
| image.save(out_path, format="PNG") | ||
| saved_paths.append(_to_handoff_path(out_path)) | ||
| if mascot_name: | ||
| logger.info("공모전 카드뉴스 slide_%02d 캐릭터: %s", index, mascot_name) | ||
|
|
||
| return saved_paths | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
prepare_contest_slides함수 내부에서 이미normalize_contest_slide_copy호출 및is_contest_slide_empty필터링을 모두 수행하여 반환합니다. 따라서render_contest_cardnews_slides에서prepare_contest_slides를 호출한 이후에 다시 동일한 정규화와 필터링을 반복하는 것은 불필요한 중복 연산입니다. 이를 제거하여 코드를 단순화할 수 있습니다.