From 73a008f4bc3b90fafa8ca58209f86962d2eaeeef Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 12 Jun 2026 10:48:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(festival-bot):=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=ED=96=89=EC=82=AC(=EC=BD=98=EC=84=9C=ED=8A=B8=C2=B7=ED=8E=98?= =?UTF-8?q?=EC=8A=A4=ED=8B=B0=EB=B2=8C=20=EB=93=B1)=20=EC=B6=94=EC=B6=9C?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini 프롬프트를 확장해 한 번의 호출로 대학 축제 + 일반 행사를 함께 추출(type 필드로 구분). 일반 행사는 행사명을 제목으로, venue로 카카오맵 검색, subtype 'general'(상세 페이지는 기존 '행사' 배지로 표시), 중복은 날짜+행사명(공백 무시 포함관계)으로 체크. 실제 게시글 2건으로 추출 검증. Co-Authored-By: Claude Opus 4.7 --- backend/src/services/festival/gemini.js | 23 ++++--- backend/src/services/festival/index.js | 85 +++++++++++++++++-------- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/backend/src/services/festival/gemini.js b/backend/src/services/festival/gemini.js index f204f7a..9254201 100644 --- a/backend/src/services/festival/gemini.js +++ b/backend/src/services/festival/gemini.js @@ -16,23 +16,28 @@ function buildPrompt(postUrls) { ${urlList} -각 게시글에서 프로미스나인(fromis_9)이 출연하는 2026년 **대학 축제** 일정을 모두 추출하세요. +각 게시글에서 프로미스나인(fromis_9)이 출연하는 2026년 **행사 일정**을 모두 추출하세요. +대학 축제(대동제 등)와 일반 행사(콘서트, 페스티벌, 지역 축제, 음악회 등)를 모두 포함합니다. 규칙: 1. 게시글 내용에 "프로미스나인" 또는 "fromis_9"이 출연진으로 명시된 경우만 인정 2. 추측, 예상, 가능성 같은 미확정 정보는 제외 -3. **대학 축제(대동제 등)만** 포함. 지역 축제, 가요제, 콘서트 등 대학 행사가 아닌 것은 제외 -4. 캠퍼스 구분이 필요한 학교(단국대·성균관대·경희대 등)는 반드시 캠퍼스명 포함 -5. 게시글에 출연 시간(공연 시작 시각)이 명시되어 있으면 time 필드에 "HH:MM" 형식으로, 없으면 빈 문자열 -6. 게시글에 적힌 정보만 사용 (추론 금지) +3. type 필드: 대학 축제(대동제 등 대학교 행사)면 "university", 그 외 행사면 "general" +4. university 필드: type이 university일 때 정확한 학교명. 캠퍼스 구분이 필요한 학교(단국대·성균관대·경희대 등)는 반드시 캠퍼스명 포함. general이면 빈 문자열 +5. event_name 필드: 행사/축제의 공식 명칭. type이 general이면 필수, university면 축제명(없으면 빈 문자열) +6. venue 필드: type이 general일 때 행사 장소명(공연장·경기장 등). 없으면 빈 문자열. university면 빈 문자열 +7. 게시글에 출연 시간(공연 시작 시각)이 명시되어 있으면 time 필드에 "HH:MM" 형식으로, 없으면 빈 문자열 +8. 게시글에 적힌 정보만 사용 (추론 금지) 출력: JSON 배열만 출력하세요. 설명, 코드블록 표시(\`\`\`) 없이. [ { "date": "YYYY-MM-DD", "time": "HH:MM 또는 빈 문자열", - "university": "정확한 학교명 (캠퍼스 포함)", - "festival_name": "축제 공식 명칭 (없으면 빈 문자열)", + "type": "university 또는 general", + "university": "학교명 (university만, 캠퍼스 포함)", + "event_name": "행사/축제 공식 명칭", + "venue": "장소명 (general만)", "source_url": "정보가 있던 게시글 URL" } ] @@ -100,10 +105,10 @@ async function callGemini(postUrls, apiKey) { } /** - * URL 목록을 Gemini url_context로 분석하여 축제 일정 추출 + * URL 목록을 Gemini url_context로 분석하여 행사(대학 축제 + 일반) 일정 추출 * @param {string[]} postUrls - 분석할 게시글 URL (최대 20개) * @param {string} apiKey - Gemini API 키 - * @returns {Promise} [{ date, time, university, festival_name, source_url }] + * @returns {Promise} [{ date, time, type, university, event_name, venue, source_url }] */ export async function extractFestivalsFromUrls(postUrls, apiKey) { if (!apiKey) { diff --git a/backend/src/services/festival/index.js b/backend/src/services/festival/index.js index b453b79..db080a6 100644 --- a/backend/src/services/festival/index.js +++ b/backend/src/services/festival/index.js @@ -1,6 +1,7 @@ /** - * 대학 축제 크롤러 봇 - * memogipost 등 검색 페이지에서 프로미스나인 출연 대학 축제를 수집하여 행사 일정 자동 생성 + * 축제 크롤러 봇 + * memogipost 등 검색 페이지에서 프로미스나인 출연 행사(대학 축제 + 콘서트·페스티벌 등 일반 행사)를 + * 수집하여 행사 일정 자동 생성. Gemini 한 번의 호출로 두 타입을 함께 추출(type 필드로 구분). */ import fp from 'fastify-plugin'; import { fetchSearchPostUrls } from './scraper.js'; @@ -23,11 +24,11 @@ async function festivalBotPlugin(fastify, opts) { } /** - * 같은 학교 + 날짜의 행사 일정이 이미 있는지 확인 + * 같은 학교 + 날짜의 행사 일정이 이미 있는지 확인 (대학 축제) * 학교명은 부분일치로 비교 (캠퍼스명 유무 차이 흡수) * 예: "인천대학교" vs "인천대학교 송도캠퍼스" → 같은 학교로 간주 */ - async function isDuplicate(date, university) { + async function isDuplicateUniversity(date, university) { const [rows] = await fastify.db.query( `SELECT se.school_name FROM schedules s JOIN schedule_event se ON s.id = se.schedule_id @@ -48,6 +49,24 @@ async function festivalBotPlugin(fastify, opts) { return false; } + /** + * 같은 날짜 + 같은 행사명의 일정이 이미 있는지 확인 (일반 행사) + * 제목은 공백 제거 후 포함 관계로 비교 (수동 등록 제목과의 표기 차이 흡수) + */ + async function isDuplicateGeneral(date, eventName) { + const norm = (s) => (s || '').replace(/\s+/g, '').toLowerCase(); + const target = norm(eventName); + if (!target) return false; + const [rows] = await fastify.db.query( + 'SELECT title FROM schedules WHERE category_id = ? AND date = ?', + [CATEGORY_IDS.EVENT, date] + ); + return rows.some((r) => { + const existing = norm(r.title); + return existing && (existing.includes(target) || target.includes(existing)); + }); + } + /** * 게시글 URL이 이미 처리됐는지 필터링 → 새 URL만 반환 */ @@ -73,45 +92,61 @@ async function festivalBotPlugin(fastify, opts) { } /** - * 단일 축제 정보로 행사 일정 생성 + * 카카오맵 장소 검색 (실패 시 이름만 가진 venue 반환) + */ + async function resolveVenue(query) { + let venue = query ? { name: query } : null; + if (!query) return venue; + try { + const docs = await searchKakaoPlace(query); + if (docs.length > 0) { + venue = kakaoToVenue(docs[0]); + } + } catch (err) { + fastify.log.warn(`[festival] 장소 검색 실패 (${query}): ${err.message}`); + } + return venue; + } + + /** + * 단일 행사 정보로 일정 생성 (type에 따라 대학 축제 / 일반 행사 분기) * @returns {boolean} 생성 성공 여부 */ async function createFestivalSchedule(festival, memberIds) { - const { date, time, university, festival_name } = festival; + const { date, time, type, university, event_name: eventName, venue: venueName } = festival; + const isGeneral = type === 'general'; - if (!date || !university) { + // 필수 정보: 대학 축제는 학교명, 일반 행사는 행사명 + if (!date || (isGeneral ? !eventName : !university)) { fastify.log.warn(`[festival] 필수 정보 누락: ${JSON.stringify(festival)}`); return false; } // 중복 체크 - if (await isDuplicate(date, university)) { + if (isGeneral) { + if (await isDuplicateGeneral(date, eventName)) { + fastify.log.info(`[festival] 중복 건너뜀: ${date} ${eventName}`); + return false; + } + } else if (await isDuplicateUniversity(date, university)) { fastify.log.info(`[festival] 중복 건너뜀: ${date} ${university}`); return false; } - // 카카오맵으로 장소 검색 - let venue = { name: university }; - try { - const docs = await searchKakaoPlace(university); - if (docs.length > 0) { - venue = kakaoToVenue(docs[0]); - } - } catch (err) { - fastify.log.warn(`[festival] 장소 검색 실패 (${university}): ${err.message}`); - } + // 장소: 대학 축제는 학교명, 일반 행사는 장소명으로 카카오맵 검색 + const venue = await resolveVenue(isGeneral ? venueName : university); - // 제목: 축제명이 있으면 "학교명 축제명", 없으면 "학교명 대학 축제" - const title = festival_name - ? `${university} ${festival_name}` - : `${university} 대학 축제`; + // 제목: 대학 축제는 "학교명 축제명", 일반 행사는 행사명 그대로 + const title = isGeneral + ? eventName + : (eventName ? `${university} ${eventName}` : `${university} 대학 축제`); const scheduleId = await createEventSchedule(fastify.db, fastify.meilisearch, { title, date, time: time || null, - subtype: 'university', - schoolName: university, + subtype: isGeneral ? 'general' : 'university', + schoolName: isGeneral ? null : university, memberIds, venue, postUrls: [], // 관련 링크는 관리자가 수동으로 입력 (블로그 링크 자동 추가 안 함) @@ -123,7 +158,7 @@ async function festivalBotPlugin(fastify, opts) { category: 'schedule', targetType: 'event_schedule', targetId: scheduleId, - summary: `대학 축제 자동 생성: ${title}`, + summary: `${isGeneral ? '행사' : '대학 축제'} 자동 생성: ${title}`, }); fastify.log.info(`[festival] 행사 생성: ${title} (${date})`);