feat(festival-bot): 일반 행사(콘서트·페스티벌 등) 추출 통합

Gemini 프롬프트를 확장해 한 번의 호출로 대학 축제 + 일반 행사를 함께
추출(type 필드로 구분). 일반 행사는 행사명을 제목으로, venue로 카카오맵
검색, subtype 'general'(상세 페이지는 기존 '행사' 배지로 표시), 중복은
날짜+행사명(공백 무시 포함관계)으로 체크. 실제 게시글 2건으로 추출 검증.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-06-12 10:48:21 +09:00
parent 734fc59bb2
commit 73a008f4bc
2 changed files with 74 additions and 34 deletions

View file

@ -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<Array>} [{ date, time, university, festival_name, source_url }]
* @returns {Promise<Array>} [{ date, time, type, university, event_name, venue, source_url }]
*/
export async function extractFestivalsFromUrls(postUrls, apiKey) {
if (!apiKey) {

View file

@ -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})`);