Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions src/api/dtryxHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* 디트릭스 GET API 핸들러
*/

import {
DTRYX_CINEMAS,
findCinemaByCode,
findCinemasByKeyword,
findCinemasByRegion,
} from '../services/dtryx/location.js';
import {
fetchDtryxNowShowing,
fetchDtryxPlayDates,
fetchDtryxTimetable,
toYyyymmdd,
} from '../services/dtryx/client.js';
import type { DtryxCinema } from '../services/dtryx/types.js';
import { type ApiContext, errorResponse, successResponse } from './response.js';

/** 요청 파라미터로 조회 대상 극장을 결정합니다. */
function resolveCinemas(
cinemaCode: string | undefined,
keyword: string | undefined,
brandCode: string | undefined,
region?: string,
): DtryxCinema[] {
if (cinemaCode) {
const known = findCinemaByCode(cinemaCode);
if (known) {
return [known];
}

return brandCode ? [{ brandCode, cinemaCode, cinemaName: '', region: '', address: '' }] : [];
}

if (keyword) {
return findCinemasByKeyword(keyword);
}

if (region) {
return findCinemasByRegion(region);
}

return [...DTRYX_CINEMAS];
}

export async function handleDtryxListCinemas(c: ApiContext) {
const keyword = c.req.query('keyword') || undefined;
const region = c.req.query('region') || undefined;
const brandCode = c.req.query('brandCode') || undefined;
const limit = parseInt(c.req.query('limit') || '50', 10);
const normalizedRegion = (region || '').replace(/\s+/g, '');

const cinemas = (keyword ? findCinemasByKeyword(keyword) : [...DTRYX_CINEMAS])
.filter((cinema) => (normalizedRegion ? cinema.region.includes(normalizedRegion) : true))
.filter((cinema) => (brandCode ? cinema.brandCode === brandCode : true))
.slice(0, limit);

return successResponse(
c,
{
filters: {
keyword: keyword || null,
region: region || null,
brandCode: brandCode || null,
limit,
},
count: cinemas.length,
cinemas,
},
{ total: cinemas.length, pageSize: limit },
);
}

export async function handleDtryxListNowShowing(c: ApiContext) {
const cinemaCode = c.req.query('cinemaCode') || undefined;
const keyword = c.req.query('keyword') || undefined;
const brandCode = c.req.query('brandCode') || undefined;
const includePlayDates = c.req.query('includePlayDates') === 'true';
const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
const cinema = resolveCinemas(cinemaCode, keyword, brandCode)[0];

if (!cinema) {
return errorResponse(
c,
'DTRYX_CINEMA_NOT_FOUND',
'극장을 찾을 수 없습니다. cinemaCode 또는 keyword 를 확인하세요.',
404,
);
}

try {
const request = {
brandCode: cinema.brandCode,
cinemaCode: cinema.cinemaCode,
timeout: timeoutMs,
};
const [movies, playDates] = await Promise.all([
fetchDtryxNowShowing(request),
includePlayDates ? fetchDtryxPlayDates(request) : Promise.resolve([]),
]);

return successResponse(
c,
{
cinema,
count: movies.length,
movies,
playDates: includePlayDates ? playDates : undefined,
},
{ total: movies.length },
);
} catch (error) {
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'DTRYX_NOW_SHOWING_FAILED', message, 500);
}
}

export async function handleDtryxGetRemainingSeats(c: ApiContext) {
const playDate = c.req.query('playDate') || toYyyymmdd();
const cinemaCode = c.req.query('cinemaCode') || undefined;
const keyword = c.req.query('keyword') || undefined;
const brandCode = c.req.query('brandCode') || undefined;
const movieName = c.req.query('movieName') || undefined;
const region = c.req.query('region') || undefined;
const limit = parseInt(c.req.query('limit') || '50', 10);
const timeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
const cinemas = resolveCinemas(cinemaCode, keyword, brandCode, region);

if (cinemas.length === 0) {
return errorResponse(
c,
'DTRYX_CINEMA_NOT_FOUND',
'극장을 찾을 수 없습니다. cinemaCode 또는 keyword 를 확인하세요.',
404,
);
}

// Promise.allSettled 로 극장별 실패를 흡수하므로 여기서 예외가 전파되지 않습니다.
const settled = await Promise.allSettled(
cinemas.map((cinema) =>
fetchDtryxTimetable({
brandCode: cinema.brandCode,
cinemaCode: cinema.cinemaCode,
playDate,
timeout: timeoutMs,
}),
),
);
const failedCinemas = cinemas
.filter((_, index) => settled[index]?.status === 'rejected')
.map((cinema) => cinema.cinemaName || cinema.cinemaCode);
const normalize = (value: string) => value.replace(/\s+/g, '').toLowerCase();
const showtimes = settled
.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
.filter((item) => (movieName ? normalize(item.movieName).includes(normalize(movieName)) : true))
.sort((a, b) =>
a.startTime === b.startTime
? a.cinemaName.localeCompare(b.cinemaName)
: a.startTime.localeCompare(b.startTime),
)
.slice(0, limit);

return successResponse(
c,
{
playDate,
filters: {
cinemaCode: cinemaCode || null,
keyword: keyword || null,
region: region || null,
movieName: movieName || null,
},
searchedCinemaCount: cinemas.length,
failedCinemas,
count: showtimes.length,
showtimes,
},
{ total: showtimes.length, pageSize: limit },
);
}
50 changes: 50 additions & 0 deletions src/api/routes/dtryxRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* 디트릭스 GET API 라우트 등록
*/

import type { Hono } from 'hono';
import { withEdgeCache } from '../../utils/cache.js';
import {
handleDtryxGetRemainingSeats,
handleDtryxListCinemas,
handleDtryxListNowShowing,
} from '../dtryxHandlers.js';
import type { AppBindings } from '../response.js';

export function registerDtryxRoutes(app: Hono<{ Bindings: AppBindings }>): void {
app.get('/api/dtryx/cinemas', async (c) =>
withEdgeCache(
c.req.url,
{
ttlSeconds: 60 * 60 * 24,
staleWhileRevalidateSeconds: 60 * 5,
keyPrefix: 'dtryx-cinemas-v1',
},
() => handleDtryxListCinemas(c),
),
);

app.get('/api/dtryx/movies', async (c) =>
withEdgeCache(
c.req.url,
{
ttlSeconds: 60 * 10,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'dtryx-movies-v1',
},
() => handleDtryxListNowShowing(c),
),
);

app.get('/api/dtryx/seats', async (c) =>
withEdgeCache(
c.req.url,
{
ttlSeconds: 60 * 3,
staleWhileRevalidateSeconds: 30,
keyPrefix: 'dtryx-seats-v1',
},
() => handleDtryxGetRemainingSeats(c),
),
);
}
41 changes: 41 additions & 0 deletions src/api/routes/registerServiceRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* 도메인별 GET API 라우트 일괄 등록
*
* `src/index.ts` 의 길이 제한(450줄)을 유지하기 위해 분리했습니다.
*/

import type { Hono } from 'hono';
import type { AppBindings } from '../response.js';
import { registerCgvRoutes } from './cgvRoutes.js';
import { registerCompareRoutes } from './compareRoutes.js';
import { registerCuRoutes } from './cuRoutes.js';
import { registerDaisoRoutes } from './daisoRoutes.js';
import { registerDtryxRoutes } from './dtryxRoutes.js';
import { registerEmart24Routes } from './emart24Routes.js';
import { registerFeedbackRoutes } from './feedbackRoutes.js';
import { registerGs25Routes } from './gs25Routes.js';
import { registerLotteCinemaRoutes } from './lottecinemaRoutes.js';
import { registerLotteMartRoutes } from './lottemartRoutes.js';
import { registerMegaboxRoutes } from './megaboxRoutes.js';
import { registerOliveyoungRoutes } from './oliveyoungRoutes.js';
import { registerOpinetRoutes } from './opinetRoutes.js';
import { registerPlacesRoutes } from './placesRoutes.js';
import { registerSevenElevenRoutes } from './sevenelevenRoutes.js';

export function registerServiceRoutes(app: Hono<{ Bindings: AppBindings }>): void {
registerDaisoRoutes(app);
registerGs25Routes(app);
registerSevenElevenRoutes(app);
registerCompareRoutes(app);
registerFeedbackRoutes(app);
registerPlacesRoutes(app);
registerOpinetRoutes(app);
registerCuRoutes(app);
registerEmart24Routes(app);
registerLotteMartRoutes(app);
registerOliveyoungRoutes(app);
registerDtryxRoutes(app);
registerMegaboxRoutes(app);
registerLotteCinemaRoutes(app);
registerCgvRoutes(app);
}
32 changes: 4 additions & 28 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
import { ServiceRegistry } from './core/registry.js';
import { createDaisoService } from './services/daiso/index.js';
import { createOliveyoungService } from './services/oliveyoung/index.js';
import { createDtryxService } from './services/dtryx/index.js';
import { createMegaboxService } from './services/megabox/index.js';
import { createCgvService } from './services/cgv/index.js';
import { createLotteCinemaService } from './services/lottecinema/index.js';
Expand All @@ -35,21 +36,8 @@ import {
import { createPrivacyResponse } from './pages/privacy.js';
import type { AppBindings } from './api/response.js';
import { buildActionQueryTargetUrl } from './api/actionsProxy.js';
import { registerDaisoRoutes } from './api/routes/daisoRoutes.js';
import { registerOliveyoungRoutes } from './api/routes/oliveyoungRoutes.js';
import { registerMegaboxRoutes } from './api/routes/megaboxRoutes.js';
import { registerCgvRoutes } from './api/routes/cgvRoutes.js';
import { registerLotteCinemaRoutes } from './api/routes/lottecinemaRoutes.js';
import { registerLotteMartRoutes } from './api/routes/lottemartRoutes.js';
import { registerCuRoutes } from './api/routes/cuRoutes.js';
import { registerEmart24Routes } from './api/routes/emart24Routes.js';
import { registerGs25Routes } from './api/routes/gs25Routes.js';
import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js';
import { registerPlacesRoutes } from './api/routes/placesRoutes.js';
import { registerOpinetRoutes } from './api/routes/opinetRoutes.js';
import { registerCompareRoutes } from './api/routes/compareRoutes.js';
import { registerFeedbackRoutes } from './api/routes/feedbackRoutes.js';
import { registerHealthRoutes } from './api/routes/healthRoutes.js';
import { registerServiceRoutes } from './api/routes/registerServiceRoutes.js';
import { registerRateLimitStatsRoutes } from './api/routes/rateLimitStatsRoutes.js';
import { buildConfigStatus } from './api/configStatus.js';
import { createDailyRateLimitMiddleware } from './middleware/dailyRateLimit.js';
Expand Down Expand Up @@ -112,6 +100,7 @@ const createRegistry = (bindings?: AppBindings) => {
zyteApiKey: bindings?.ZYTE_API_KEY,
}),
createMegaboxService,
createDtryxService,
() =>
createLotteCinemaService({
googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
Expand Down Expand Up @@ -354,6 +343,7 @@ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest);
app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) }));
registerHealthRoutes(app);
registerRateLimitStatsRoutes(app);
registerServiceRoutes(app);

// 프롬프트 페이지 (MCP 미지원 에이전트용)
app.get('/prompt', (c) => {
Expand Down Expand Up @@ -389,20 +379,6 @@ app.get('/privacy', (c) => {
});

// GET API 엔드포인트 (MCP 미지원 에이전트용)
registerDaisoRoutes(app);
registerGs25Routes(app);
registerSevenElevenRoutes(app);
registerCompareRoutes(app);
registerFeedbackRoutes(app);
registerPlacesRoutes(app);
registerOpinetRoutes(app);
registerCuRoutes(app);
registerEmart24Routes(app);
registerLotteMartRoutes(app);
registerOliveyoungRoutes(app);
registerMegaboxRoutes(app);
registerLotteCinemaRoutes(app);
registerCgvRoutes(app);

app.get('/api/actions/query', async (c) => {
try {
Expand Down
26 changes: 26 additions & 0 deletions src/services/dtryx/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* 디트릭스(Dtryx) API 엔드포인트 및 상수
*
* 디트릭스는 국내 독립·예술영화관 다수가 공용으로 사용하는 예매 플랫폼입니다.
* 공개 조회 엔드포인트이며 별도 인증 키가 필요하지 않습니다.
*/

export const DTRYX_API = {
BASE_URL: 'https://api.dtryx.com:30443',
THIRDPARTY_PATH: '/dtryx/cms/thirdparty/movie',
CHANNEL_CODE: 'homepage',
ENDPOINTS: {
TIMETABLE_LIST: 'third-party-type2-timetable-list',
PLAY_DATE_LIST: 'third-party-type2-timetable-play-date-list',
MOVIE_NOW: 'third-party-type2-movie-now',
},
/**
* 엔드포인트별 고정 작업 식별자입니다.
* 각 극장 홈페이지에 공개된 값과 동일합니다.
*/
WORK_GUIDS: {
TIMETABLE_LIST: '37E0BA0F-DA5F-4376-9BA4-B5D27286AB87',
PLAY_DATE_LIST: '324A2914-AB19-42A3-BDFE-58A08B2DC35D',
MOVIE_NOW: '6D741A36-A973-4BC4-87B4-2475E6654114',
},
} as const;
Loading