feat(festival-bot): 일반 행사(콘서트·페스티벌 등) 추출 통합
Gemini 프롬프트를 확장해 한 번의 호출로 대학 축제 + 일반 행사를 함께 추출(type 필드로 구분). 일반 행사는 행사명을 제목으로, venue로 카카오맵 검색, subtype 'general'(상세 페이지는 기존 '행사' 배지로 표시), 중복은 날짜+행사명(공백 무시 포함관계)으로 체크. 실제 게시글 2건으로 추출 검증. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
734fc59bb2
commit
73a008f4bc
2 changed files with 74 additions and 34 deletions
|
|
@ -16,23 +16,28 @@ function buildPrompt(postUrls) {
|
||||||
|
|
||||||
${urlList}
|
${urlList}
|
||||||
|
|
||||||
각 게시글에서 프로미스나인(fromis_9)이 출연하는 2026년 **대학 축제** 일정을 모두 추출하세요.
|
각 게시글에서 프로미스나인(fromis_9)이 출연하는 2026년 **행사 일정**을 모두 추출하세요.
|
||||||
|
대학 축제(대동제 등)와 일반 행사(콘서트, 페스티벌, 지역 축제, 음악회 등)를 모두 포함합니다.
|
||||||
|
|
||||||
규칙:
|
규칙:
|
||||||
1. 게시글 내용에 "프로미스나인" 또는 "fromis_9"이 출연진으로 명시된 경우만 인정
|
1. 게시글 내용에 "프로미스나인" 또는 "fromis_9"이 출연진으로 명시된 경우만 인정
|
||||||
2. 추측, 예상, 가능성 같은 미확정 정보는 제외
|
2. 추측, 예상, 가능성 같은 미확정 정보는 제외
|
||||||
3. **대학 축제(대동제 등)만** 포함. 지역 축제, 가요제, 콘서트 등 대학 행사가 아닌 것은 제외
|
3. type 필드: 대학 축제(대동제 등 대학교 행사)면 "university", 그 외 행사면 "general"
|
||||||
4. 캠퍼스 구분이 필요한 학교(단국대·성균관대·경희대 등)는 반드시 캠퍼스명 포함
|
4. university 필드: type이 university일 때 정확한 학교명. 캠퍼스 구분이 필요한 학교(단국대·성균관대·경희대 등)는 반드시 캠퍼스명 포함. general이면 빈 문자열
|
||||||
5. 게시글에 출연 시간(공연 시작 시각)이 명시되어 있으면 time 필드에 "HH:MM" 형식으로, 없으면 빈 문자열
|
5. event_name 필드: 행사/축제의 공식 명칭. type이 general이면 필수, university면 축제명(없으면 빈 문자열)
|
||||||
6. 게시글에 적힌 정보만 사용 (추론 금지)
|
6. venue 필드: type이 general일 때 행사 장소명(공연장·경기장 등). 없으면 빈 문자열. university면 빈 문자열
|
||||||
|
7. 게시글에 출연 시간(공연 시작 시각)이 명시되어 있으면 time 필드에 "HH:MM" 형식으로, 없으면 빈 문자열
|
||||||
|
8. 게시글에 적힌 정보만 사용 (추론 금지)
|
||||||
|
|
||||||
출력: JSON 배열만 출력하세요. 설명, 코드블록 표시(\`\`\`) 없이.
|
출력: JSON 배열만 출력하세요. 설명, 코드블록 표시(\`\`\`) 없이.
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"date": "YYYY-MM-DD",
|
"date": "YYYY-MM-DD",
|
||||||
"time": "HH:MM 또는 빈 문자열",
|
"time": "HH:MM 또는 빈 문자열",
|
||||||
"university": "정확한 학교명 (캠퍼스 포함)",
|
"type": "university 또는 general",
|
||||||
"festival_name": "축제 공식 명칭 (없으면 빈 문자열)",
|
"university": "학교명 (university만, 캠퍼스 포함)",
|
||||||
|
"event_name": "행사/축제 공식 명칭",
|
||||||
|
"venue": "장소명 (general만)",
|
||||||
"source_url": "정보가 있던 게시글 URL"
|
"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[]} postUrls - 분석할 게시글 URL (최대 20개)
|
||||||
* @param {string} apiKey - Gemini API 키
|
* @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) {
|
export async function extractFestivalsFromUrls(postUrls, apiKey) {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* 대학 축제 크롤러 봇
|
* 축제 크롤러 봇
|
||||||
* memogipost 등 검색 페이지에서 프로미스나인 출연 대학 축제를 수집하여 행사 일정 자동 생성
|
* memogipost 등 검색 페이지에서 프로미스나인 출연 행사(대학 축제 + 콘서트·페스티벌 등 일반 행사)를
|
||||||
|
* 수집하여 행사 일정 자동 생성. Gemini 한 번의 호출로 두 타입을 함께 추출(type 필드로 구분).
|
||||||
*/
|
*/
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import { fetchSearchPostUrls } from './scraper.js';
|
import { fetchSearchPostUrls } from './scraper.js';
|
||||||
|
|
@ -23,11 +24,11 @@ async function festivalBotPlugin(fastify, opts) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 같은 학교 + 날짜의 행사 일정이 이미 있는지 확인
|
* 같은 학교 + 날짜의 행사 일정이 이미 있는지 확인 (대학 축제)
|
||||||
* 학교명은 부분일치로 비교 (캠퍼스명 유무 차이 흡수)
|
* 학교명은 부분일치로 비교 (캠퍼스명 유무 차이 흡수)
|
||||||
* 예: "인천대학교" vs "인천대학교 송도캠퍼스" → 같은 학교로 간주
|
* 예: "인천대학교" vs "인천대학교 송도캠퍼스" → 같은 학교로 간주
|
||||||
*/
|
*/
|
||||||
async function isDuplicate(date, university) {
|
async function isDuplicateUniversity(date, university) {
|
||||||
const [rows] = await fastify.db.query(
|
const [rows] = await fastify.db.query(
|
||||||
`SELECT se.school_name FROM schedules s
|
`SELECT se.school_name FROM schedules s
|
||||||
JOIN schedule_event se ON s.id = se.schedule_id
|
JOIN schedule_event se ON s.id = se.schedule_id
|
||||||
|
|
@ -48,6 +49,24 @@ async function festivalBotPlugin(fastify, opts) {
|
||||||
return false;
|
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만 반환
|
* 게시글 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} 생성 성공 여부
|
* @returns {boolean} 생성 성공 여부
|
||||||
*/
|
*/
|
||||||
async function createFestivalSchedule(festival, memberIds) {
|
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)}`);
|
fastify.log.warn(`[festival] 필수 정보 누락: ${JSON.stringify(festival)}`);
|
||||||
return false;
|
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}`);
|
fastify.log.info(`[festival] 중복 건너뜀: ${date} ${university}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카카오맵으로 장소 검색
|
// 장소: 대학 축제는 학교명, 일반 행사는 장소명으로 카카오맵 검색
|
||||||
let venue = { name: university };
|
const venue = await resolveVenue(isGeneral ? venueName : 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 title = festival_name
|
const title = isGeneral
|
||||||
? `${university} ${festival_name}`
|
? eventName
|
||||||
: `${university} 대학 축제`;
|
: (eventName ? `${university} ${eventName}` : `${university} 대학 축제`);
|
||||||
|
|
||||||
const scheduleId = await createEventSchedule(fastify.db, fastify.meilisearch, {
|
const scheduleId = await createEventSchedule(fastify.db, fastify.meilisearch, {
|
||||||
title,
|
title,
|
||||||
date,
|
date,
|
||||||
time: time || null,
|
time: time || null,
|
||||||
subtype: 'university',
|
subtype: isGeneral ? 'general' : 'university',
|
||||||
schoolName: university,
|
schoolName: isGeneral ? null : university,
|
||||||
memberIds,
|
memberIds,
|
||||||
venue,
|
venue,
|
||||||
postUrls: [], // 관련 링크는 관리자가 수동으로 입력 (블로그 링크 자동 추가 안 함)
|
postUrls: [], // 관련 링크는 관리자가 수동으로 입력 (블로그 링크 자동 추가 안 함)
|
||||||
|
|
@ -123,7 +158,7 @@ async function festivalBotPlugin(fastify, opts) {
|
||||||
category: 'schedule',
|
category: 'schedule',
|
||||||
targetType: 'event_schedule',
|
targetType: 'event_schedule',
|
||||||
targetId: scheduleId,
|
targetId: scheduleId,
|
||||||
summary: `대학 축제 자동 생성: ${title}`,
|
summary: `${isGeneral ? '행사' : '대학 축제'} 자동 생성: ${title}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.log.info(`[festival] 행사 생성: ${title} (${date})`);
|
fastify.log.info(`[festival] 행사 생성: ${title} (${date})`);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue