Skip to content

Feat/104 contest crawler db#120

Merged
selnem merged 29 commits into
feat/104-contest-crawlerfrom
feat/104-contest-crawler-db
Jun 14, 2026
Merged

Feat/104 contest crawler db#120
selnem merged 29 commits into
feat/104-contest-crawlerfrom
feat/104-contest-crawler-db

Conversation

@selnem

@selnem selnem commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

🔗 Related Issue

✨ 작업 개요

Linkareer 공모전을 크롤링해 카드뉴스를 생성하고, 핀/이벤트/커뮤니티/이미지 테이블에 자동 적재하는 공모전 핀 DB sync 파이프라인을 추가했습니다.

  • 공모전 관리자 API 추가
    • /contest-admin/sync
    • /contest-admin/crawl
    • /contest-admin/transform-batch
    • /contest-admin/import-batch
    • /contest-admin/status
    • /contest-admin/scheduler/run-once
  • Linkareer 공모전 크롤링 결과를 DB 적재용 handoff JSONL로 가공
  • Gemini 기반 카드뉴스 생성 후 S3 업로드
  • event_pin.contest_api_id 기준 중복 제거
  • DB에 이미 존재하는 공모전은 LLM/cardnews 생성 없이 스킵
  • 종료일이 오늘(KST)보다 지난 공모전은 크롤/transform/import 단계에서 스킵
  • 이미지 분류 규칙 적용
    • api.linkareer.com/attachments 첫 번째 이미지만 대표 이미지
    • 이후 attachment 이미지는 DB 미적재
    • media-cdn.linkareer.com 이미지는 보조 이미지로 적재
  • 매일 KST 12시 자동 sync 스케줄러 추가
  • 공모전 batch/import/scheduler 테스트 추가

체크리스트

  • Reviewers, Assignees, Labels를 모두 등록했나요?
  • .gitignore 설정을 하였나요?
  • PR 머지 전 반드시 CI가 정상적으로 작동하는지 확인해주세요!

📷 이미지 첨부 (선택)

image

🧐 집중 리뷰 요청

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요.

  • event_pin.contest_api_id nullable 컬럼 및 unique index 마이그레이션 적용 방식
  • contest_api_id 기준 중복 제거 로직이 transform/import 양쪽에서 안전하게 동작하는지
  • 종료일 경과 공모전 스킵 기준이 KST 오늘 날짜 기준으로 적절한지
  • Linkareer 이미지 분류 규칙이 요구사항과 일치하는지
  • 카드뉴스 S3 업로드 후 handoff/cache prune 정책이 운영에 적절한지
  • pin_type=CONTEST, community_type=CONTEST 적재 흐름이 기존 정책 핀 구조와 충돌 없는지

qkwltkwkd1 and others added 28 commits June 2, 2026 09:33
정책뉴스 수집→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 추가
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
@selnem selnem self-assigned this Jun 13, 2026
@selnem selnem added the ⭐ feat 새로운 기능 label Jun 13, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/models/ComplaintPetition.py Outdated
Comment on lines 118 to 123
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))]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

코드 주석(# manifest 등록 파일만 무작위 선택) 및 설정 파일 설명에는 mascots.json에 등록된 캐릭터만 사용하도록 명시되어 있습니다. 하지만 기존의 allowed_mascot_names() 필터링 로직이 제거되고 load_mascots() 전체를 사용하도록 변경되었습니다. load_mascots()가 디렉터리 내의 모든 PNG 파일을 로드한다면, 등록되지 않은 캐릭터가 무작위로 선택될 수 있으므로 필터링 로직을 유지하는 것이 안전합니다.

Suggested change
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))]

@selnem

selnem commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +51 to +62
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}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

_parse_event_datetime 함수에서 datetime.date 타입의 객체가 입력으로 들어올 경우, isinstance(value, datetime) 조건문은 False를 반환합니다. 이후 str(value) 변환 시 "YYYY-MM-DD" 형식의 문자열이 되어 len(text) == 8 조건을 만족하지 못하고 ValueError가 발생하게 됩니다. datetimedate 타입을 모두 안전하게 처리할 수 있도록 isinstance(value, date) 분기를 추가하는 것이 좋습니다. (참고: datetimedate를 상속받으므로 isinstance(value, datetime)을 먼저 검사해야 합니다.)

Suggested change
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}")

Comment on lines +188 to +190
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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

target_rows를 슬라이싱하는 로직이 불필요하게 복잡하고 중복되어 있습니다. import_allTrue이고 limitNone이 아닐 때도 결국 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

Comment on lines +48 to +62
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}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

_parse_event_datetime 함수에서 datetime.date 타입의 객체가 입력으로 들어올 경우, isinstance(value, datetime) 조건문은 False를 반환합니다. 이후 str(value) 변환 시 "YYYY-MM-DD" 형식의 문자열이 되어 parse_policy_datetime에서 파싱 실패할 가능성이 있습니다. datetimedate 타입을 모두 안전하게 처리할 수 있도록 isinstance(value, date) 분기를 추가하는 것이 좋습니다. (참고: datetimedate를 상속받으므로 isinstance(value, datetime)을 먼저 검사해야 합니다.)

Suggested change
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}")

Comment on lines +168 to +170
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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

target_rows를 슬라이싱하는 로직이 불필요하게 복잡하고 중복되어 있습니다. import_allTrue이고 limitNone이 아닐 때도 결국 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

@selnem selnem requested a review from qkwltkwkd1 June 13, 2026 10:14

@qkwltkwkd1 qkwltkwkd1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!

@selnem selnem merged commit 20be102 into feat/104-contest-crawler Jun 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⭐ feat 새로운 기능

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants