feat(backend): 축제·행사 범위를 대구 근교(경북)까지 넓힘

관광공사에 등록된 앞으로 90일 행사가 대구 1건·경북 7건뿐이라(조회 조건 문제 아님)
홈 '이번 주말 축제·행사'가 대부분 비게 된다. 근교 행사를 거리와 함께 보여 준다.

- place:sync -- event 가 기본으로 대구(27)·경북(47)을 받는다(--regions= 로 변경,
  시·도마다 실행 기록을 따로 남긴다)
- GET /api/events 반경 상한 50km → 150km(포항 약 85km·청송 약 100km). 기본 5km 는 그대로
- 설계 문서에 화면 규칙: 홈은 동네 좌표 120km 거리순(대구 먼저), 이번 주말이 비면
  같은 반경 30일로 '다가오는 축제·행사'. 장소 상세 '근처'는 5km, 없으면 칸 숨김

2026-11-03 실측: 이번 주말 대구 도심 120km 안 5건(수성국제비엔날레 5km 가 맨 앞).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 19:44:28 +09:00
parent 16757b31f2
commit 009a287533
4 changed files with 56 additions and 9 deletions
+23 -4
View File
@@ -5,6 +5,7 @@ import { WinstonModule } from 'nest-winston';
import {
DEFAULT_EVENT_LOOKAHEAD_DAYS,
DEFAULT_EVENT_REGIONS,
FestivalSync,
} from '@/modules/place/sync/festival.sync';
import { PlaceDedupe } from '@/modules/place/sync/place-dedupe';
@@ -37,7 +38,7 @@ const USAGE = `사용법: place:sync <작업> [옵션]
store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다
--signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구)
event 관광공사 축제·공연·행사 적재(오늘부터 N일 안, 매일). 새로 생기거나 바뀐 행사만 상세를 받는다
--region=27 법정동 시·도 코드 (기본 27 대구)
--regions=27,47 법정동 시·도 코드들 (기본 27 대구 · 47 경북)
--days=90 며칠 뒤까지 받을지
dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행)
reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번)
@@ -61,6 +62,7 @@ function parseArgs(argv: string[]): {
skipDetails: boolean;
signgu: string[];
days: number;
regions: string[];
} {
const [first, ...rest] = argv;
const job = (JOBS as readonly string[]).includes(first ?? '')
@@ -80,23 +82,40 @@ function parseArgs(argv: string[]): {
const days = daysArg
? Number(daysArg.slice('--days='.length))
: DEFAULT_EVENT_LOOKAHEAD_DAYS;
const regionsArg = rest.find((a) => a.startsWith('--regions='));
const regions = regionsArg
? regionsArg
.slice('--regions='.length)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: DEFAULT_EVENT_REGIONS;
return {
job,
region,
skipDetails: rest.includes('--skip-details'),
signgu,
days,
regions,
};
}
async function main(): Promise<void> {
const { job, region, skipDetails, signgu, days } = parseArgs(
const { job, region, skipDetails, signgu, days, regions } = parseArgs(
process.argv.slice(2),
);
const validSigngu =
signgu.length > 0 && signgu.every((v) => /^\d{5}$/.test(v));
const validDays = Number.isInteger(days) && days >= 1 && days <= 365;
if (!job || !/^\d{2}$/.test(region) || !validSigngu || !validDays) {
const validRegions =
regions.length > 0 && regions.every((v) => /^\d{2}$/.test(v));
if (
!job ||
!/^\d{2}$/.test(region) ||
!validSigngu ||
!validDays ||
!validRegions
) {
Logger.error(USAGE, 'PlaceSync');
process.exitCode = 1;
return;
@@ -113,7 +132,7 @@ async function main(): Promise<void> {
} else if (job === 'store') {
await app.get(StoreSync).run({ signguCodes: signgu });
} else if (job === 'event') {
await app.get(FestivalSync).run({ regionCode: region, days });
await app.get(FestivalSync).runRegions({ regionCodes: regions, days });
} else if (job === 'dedupe') {
await app.get(PlaceDedupe).run();
} else if (job === 'reparse-hours') {
@@ -25,7 +25,8 @@ export const EVENT_MAX_SPAN_DAYS = 60;
/** 근처 행사 반경(m) */
export const EVENT_RADIUS_DEFAULT_M = 5_000;
export const EVENT_RADIUS_MIN_M = 100;
export const EVENT_RADIUS_MAX_M = 50_000;
/** 대구 근교(경북) 행사까지 거리순으로 보려면 100km 이상이 필요하다(포항 약 85km · 청송 약 100km) */
export const EVENT_RADIUS_MAX_M = 150_000;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -16,6 +16,12 @@ import { isSuspiciousRemoval } from './removal-guard';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourApi, TourItem } from './tour-api.client';
/**
* 행사를 받을 시·도 — 대구(27)만으로는 90일에 1건뿐이라(2026-11-03 실측: 대구 1 · 경북 7) 근교 경북(47)까지 받는다.
* 화면은 동네 좌표에서 거리순으로 보여 주므로 대구 행사가 먼저 나온다.
*/
export const DEFAULT_EVENT_REGIONS = ['27', '47'];
/** 오늘부터 며칠 안에 열리는 행사를 받을지 */
export const DEFAULT_EVENT_LOOKAHEAD_DAYS = 90;
/** 관광공사 축제·공연·행사 관광타입 */
@@ -85,6 +91,18 @@ export class FestivalSync {
private readonly apiFactory: PublicDataApiFactory,
) {}
/** 여러 시·도를 차례로 받는다(시·도마다 실행 기록을 따로 남긴다) */
async runRegions(options: {
regionCodes: string[];
days?: number;
}): Promise<FestivalSyncResult[]> {
const results: FestivalSyncResult[] = [];
for (const regionCode of options.regionCodes) {
results.push(await this.run({ regionCode, days: options.days }));
}
return results;
}
async run(options: {
regionCode: string;
days?: number;