Feat/104 contest crawler db#120
Conversation
정책뉴스 수집→LLM 가공→DB INSERT 일괄 파이프라인(sync_pipeline)과 /policy-admin 엔드포인트(sync, transform-batch, import-batch, pipeline-status)를 추가한다. KST 기준 자동 sync 스케줄러, 카드뉴스 S3 업로드·CardnewsImageS3 적재, 중복 건 스킵·파이프라인 정리, Community/EventPin(policy_api_id) 모델 확장을 포함한다.
sync_pipeline 다배치 실행 시 import errors/items/pin_ids가 마지막 배치만 반영되던 문제를 누적하도록 수정하고, 스케줄러 간격 비교에 1시간 버퍼를 적용해 실행 시각 오차로 sync가 하루 밀리는 현상을 방지한다. 동작하지 않던 cardnews_image_urls fallback을 제거하고, 카드뉴스 handoff_path 절대 경로 처리를 보완한다.
/policy-admin 접근 제어와 정책 파이프라인 캡슐화를 보강하고, 카드뉴스 정리 및 nested transaction 실패 처리의 데이터 유실 위험을 줄인다. Co-authored-by: Cursor <cursoragent@cursor.com>
/policy-admin 날짜 검증 예외가 400으로 처리되도록 보완하고, Community.pin_id nullable 변경으로 기존 데이터 마이그레이션 리스크를 줄인다.
handoff 적재와 sync_pipeline의 중복 DB 조회를 줄이고, 카드뉴스 파일 읽기·파이프라인 정리 작업을 스레드 풀에서 실행해 이벤트 루프 블로킹을 방지한다.
sync가 remaining_pending 기준으로 배치를 끝까지 돌고, import 후 캐시 정리로 handoff가 비어도 DB 적재 완료로 처리하도록 status·hint를 보강한다.
feat: 정책 핀 sync·배치 가공·DB 적재 파이프라인 및 관리자 API 추가
[FEAT] policy api 구현
Contest API와 Policy Admin 파이프라인을 동시에 유지하도록 DI·라우터·폰트 경로 충돌을 해결한다. Co-authored-by: Cursor <cursoragent@cursor.com>
RAG 설정 필드 이중 정의를 제거하고 prod 동작값(rerank off, top_k=10)을 단일 정의로 고정한다. Gemini 팩토리·민원 서비스 조립·pin_content 유틸·카드뉴스 경로 해석을 공통 모듈로 추출해 deps/main/transform 간 중복 코드를 줄인다.
Linkareer 공모전을 정책 핀과 같은 3단계(크롤→transform→import)로 자동 적재한다. contest_api_id 기준 중복과 종료일(KST) 경과 건은 LLM·DB 모두 스킵하고, attachments/media-cdn 이미지 분류·S3 카드뉴스 업로드·매일 KST 12시 sync를 포함한다. - ContestAdminRoute: /sync, /status, crawl·transform·import 배치 API - ContestEventIngestService: pin/event_pin/community/pin_image/cardnews INSERT - EventPin.contest_api_id 및 EventPinRepo 조회·중복 제거 - contest_pin_transform: handoff 병합, S3 카드뉴스, pending/만료 스킵 - ContestPinSchedulerService 및 CONTEST_* 설정·deps·main 등록 - fetch_linkareer_contests: start_page, pin_images, KST 만료 필터 - tests/test_contest_admin_batch.py
There was a problem hiding this comment.
Code Review
This pull request introduces comprehensive pipelines, schedulers, routes, and database models to automate the crawling, LLM-based transformation, S3 upload, and DB ingestion of policy and contest card news. It also refactors Gemini service instantiation into dedicated factory modules and optimizes font loading and text wrapping. Feedback on the changes highlights two key issues: first, adding a second Identity() column to ComplaintPetition will trigger database migration failures in PostgreSQL; second, removing the mascot filtering in mascot.py bypasses the manifest-registered whitelist, potentially selecting unauthorized mascot images.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def pick_mascot(rng: random.Random) -> tuple[str, Image.Image] | None: | ||
| # manifest 등록 파일만 무작위 선택 | ||
| allowed = allowed_mascot_names() | ||
| mascots = [(name, img) for name, img in load_mascots() if name in allowed] | ||
| mascots = load_mascots() | ||
| if not mascots: | ||
| return None | ||
| name, image = mascots[rng.randrange(len(mascots))] |
There was a problem hiding this comment.
코드 주석(# manifest 등록 파일만 무작위 선택) 및 설정 파일 설명에는 mascots.json에 등록된 캐릭터만 사용하도록 명시되어 있습니다. 하지만 기존의 allowed_mascot_names() 필터링 로직이 제거되고 load_mascots() 전체를 사용하도록 변경되었습니다. load_mascots()가 디렉터리 내의 모든 PNG 파일을 로드한다면, 등록되지 않은 캐릭터가 무작위로 선택될 수 있으므로 필터링 로직을 유지하는 것이 안전합니다.
| def pick_mascot(rng: random.Random) -> tuple[str, Image.Image] | None: | |
| # manifest 등록 파일만 무작위 선택 | |
| allowed = allowed_mascot_names() | |
| mascots = [(name, img) for name, img in load_mascots() if name in allowed] | |
| mascots = load_mascots() | |
| if not mascots: | |
| return None | |
| name, image = mascots[rng.randrange(len(mascots))] | |
| def pick_mascot(rng: random.Random) -> tuple[str, Image.Image] | None: | |
| # manifest 등록 파일만 무작위 선택 | |
| allowed = allowed_mascot_names() | |
| mascots = [(name, img) for name, img in load_mascots() if name in allowed] | |
| if not mascots: | |
| return None | |
| name, image = mascots[rng.randrange(len(mascots))] |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces comprehensive pipeline synchronization, scheduling, and ingestion services for both contest and policy pins, including new admin routes, S3 image repositories, and background schedulers. The feedback highlights critical improvements for the ingestion services: specifically, _parse_event_datetime in both ContestEventIngestService and PolicyEventIngestService needs to explicitly handle datetime.date objects to avoid runtime errors, and the redundant slicing logic for target_rows should be simplified to improve readability.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def _parse_event_datetime(value: Any) -> datetime: | ||
| if value is None: | ||
| raise ValueError("event datetime 없음") | ||
| if isinstance(value, datetime): | ||
| return value.replace(tzinfo=None) | ||
| text = str(value).strip() | ||
| if not text: | ||
| raise ValueError("event datetime 비어 있음") | ||
| if len(text) == 8 and text.isdigit(): | ||
| year, month, day = int(text[:4]), int(text[4:6]), int(text[6:8]) | ||
| return datetime(year, month, day) | ||
| raise ValueError(f"event datetime 파싱 실패: {value!r}") |
There was a problem hiding this comment.
_parse_event_datetime 함수에서 datetime.date 타입의 객체가 입력으로 들어올 경우, isinstance(value, datetime) 조건문은 False를 반환합니다. 이후 str(value) 변환 시 "YYYY-MM-DD" 형식의 문자열이 되어 len(text) == 8 조건을 만족하지 못하고 ValueError가 발생하게 됩니다. datetime과 date 타입을 모두 안전하게 처리할 수 있도록 isinstance(value, date) 분기를 추가하는 것이 좋습니다. (참고: datetime은 date를 상속받으므로 isinstance(value, datetime)을 먼저 검사해야 합니다.)
| def _parse_event_datetime(value: Any) -> datetime: | |
| if value is None: | |
| raise ValueError("event datetime 없음") | |
| if isinstance(value, datetime): | |
| return value.replace(tzinfo=None) | |
| text = str(value).strip() | |
| if not text: | |
| raise ValueError("event datetime 비어 있음") | |
| if len(text) == 8 and text.isdigit(): | |
| year, month, day = int(text[:4]), int(text[4:6]), int(text[6:8]) | |
| return datetime(year, month, day) | |
| raise ValueError(f"event datetime 파싱 실패: {value!r}") | |
| def _parse_event_datetime(value: Any) -> datetime: | |
| from datetime import date | |
| if value is None: | |
| raise ValueError("event datetime 없음") | |
| if isinstance(value, datetime): | |
| return value.replace(tzinfo=None) | |
| if isinstance(value, date): | |
| return datetime.combine(value, time.min) | |
| text = str(value).strip() | |
| if not text: | |
| raise ValueError("event datetime 비어 있음") | |
| if len(text) == 8 and text.isdigit(): | |
| year, month, day = int(text[:4]), int(text[4:6]), int(text[6:8]) | |
| return datetime(year, month, day) | |
| raise ValueError(f"event datetime 파싱 실패: {value!r}") |
| target_rows = pending_rows if import_all else pending_rows[: (limit or len(pending_rows))] | ||
| if limit is not None and import_all: | ||
| target_rows = pending_rows[:limit] |
There was a problem hiding this comment.
target_rows를 슬라이싱하는 로직이 불필요하게 복잡하고 중복되어 있습니다. import_all이 True이고 limit이 None이 아닐 때도 결국 pending_rows[:limit]으로 슬라이싱되므로, import_all 변수의 의미가 모호해집니다. 다음과 같이 직관적이고 단순한 코드로 개선할 수 있습니다.
limit_val = None if import_all else limit
target_rows = pending_rows[:limit_val] if limit_val is not None else pending_rows| def _parse_event_datetime(value: Any) -> datetime: | ||
| if value is None: | ||
| raise ValueError("event datetime 없음") | ||
| if isinstance(value, datetime): | ||
| return value.replace(tzinfo=None) | ||
| text = str(value).strip() | ||
| if not text: | ||
| raise ValueError("event datetime 비어 있음") | ||
| if len(text) == 8 and text.isdigit(): | ||
| year, month, day = int(text[:4]), int(text[4:6]), int(text[6:8]) | ||
| return datetime(year, month, day) | ||
| parsed = parse_policy_datetime(text) | ||
| if parsed is not None: | ||
| return parsed.replace(tzinfo=None) | ||
| raise ValueError(f"event datetime 파싱 실패: {value!r}") |
There was a problem hiding this comment.
_parse_event_datetime 함수에서 datetime.date 타입의 객체가 입력으로 들어올 경우, isinstance(value, datetime) 조건문은 False를 반환합니다. 이후 str(value) 변환 시 "YYYY-MM-DD" 형식의 문자열이 되어 parse_policy_datetime에서 파싱 실패할 가능성이 있습니다. datetime과 date 타입을 모두 안전하게 처리할 수 있도록 isinstance(value, date) 분기를 추가하는 것이 좋습니다. (참고: datetime은 date를 상속받으므로 isinstance(value, datetime)을 먼저 검사해야 합니다.)
| def _parse_event_datetime(value: Any) -> datetime: | |
| if value is None: | |
| raise ValueError("event datetime 없음") | |
| if isinstance(value, datetime): | |
| return value.replace(tzinfo=None) | |
| text = str(value).strip() | |
| if not text: | |
| raise ValueError("event datetime 비어 있음") | |
| if len(text) == 8 and text.isdigit(): | |
| year, month, day = int(text[:4]), int(text[4:6]), int(text[6:8]) | |
| return datetime(year, month, day) | |
| parsed = parse_policy_datetime(text) | |
| if parsed is not None: | |
| return parsed.replace(tzinfo=None) | |
| raise ValueError(f"event datetime 파싱 실패: {value!r}") | |
| def _parse_event_datetime(value: Any) -> datetime: | |
| from datetime import date | |
| if value is None: | |
| raise ValueError("event datetime 없음") | |
| if isinstance(value, datetime): | |
| return value.replace(tzinfo=None) | |
| if isinstance(value, date): | |
| return datetime.combine(value, time.min) | |
| text = str(value).strip() | |
| if not text: | |
| raise ValueError("event datetime 비어 있음") | |
| if len(text) == 8 and text.isdigit(): | |
| year, month, day = int(text[:4]), int(text[4:6]), int(text[6:8]) | |
| return datetime(year, month, day) | |
| parsed = parse_policy_datetime(text) | |
| if parsed is not None: | |
| return parsed.replace(tzinfo=None) | |
| raise ValueError(f"event datetime 파싱 실패: {value!r}") |
| target_rows = pending_rows if import_all else pending_rows[: (limit or len(pending_rows))] | ||
| if limit is not None and import_all: | ||
| target_rows = pending_rows[:limit] |
There was a problem hiding this comment.
target_rows를 슬라이싱하는 로직이 불필요하게 복잡하고 중복되어 있습니다. import_all이 True이고 limit이 None이 아닐 때도 결국 pending_rows[:limit]으로 슬라이싱되므로, import_all 변수의 의미가 모호해집니다. 다음과 같이 직관적이고 단순한 코드로 개선할 수 있습니다.
limit_val = None if import_all else limit
target_rows = pending_rows[:limit_val] if limit_val is not None else pending_rows
🔗 Related Issue
✨ 작업 개요
/contest-admin/sync/contest-admin/crawl/contest-admin/transform-batch/contest-admin/import-batch/contest-admin/status/contest-admin/scheduler/run-onceevent_pin.contest_api_id기준 중복 제거api.linkareer.com/attachments첫 번째 이미지만 대표 이미지media-cdn.linkareer.com이미지는 보조 이미지로 적재체크리스트
📷 이미지 첨부 (선택)
🧐 집중 리뷰 요청
event_pin.contest_api_idnullable 컬럼 및 unique index 마이그레이션 적용 방식contest_api_id기준 중복 제거 로직이 transform/import 양쪽에서 안전하게 동작하는지pin_type=CONTEST,community_type=CONTEST적재 흐름이 기존 정책 핀 구조와 충돌 없는지