import type { Knex } from 'knex'; import { z } from 'zod'; import { db } from '../db/client.js'; import { sendNotifications } from './notification-service.js'; import { getSharedResourceTokenDetail } from './shared-resource-token-service.js'; const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr'; const SPORTS_SEARCH_KEY = '5'; const MAX_CATEGORY_COUNT = 16; const MAX_RESULT_COUNT = 12; const TICKET_BAY_FETCH_TIMEOUT_MS = 12_000; const TICKET_BAY_PRODUCT_PAGE_SIZE = 100; const BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY = 741_205_261; const teamKeywordMap: Record = { LG: 'LG', 두산: '두산', SSG: 'SSG', 키움: '키움', KT: 'KT', KIA: 'KIA', NC: 'NC', 롯데: '롯데', 삼성: '삼성', 한화: '한화', 전체: '야구', }; const titleSearchKeywordEntries = [ '잠실', '고척', '광주', '대구', '대전', '사직', '수원', '창원', '인천', '문학', 'LG', '두산', 'SSG', '키움', 'KT', 'KIA', 'NC', '롯데', '삼성', '한화', ] as const; const ticketAlertSearchSchema = z.object({ title: z.string().trim().min(1).max(200), eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/), team: z.string().trim().min(1).max(50), zone: z.string().trim().min(1).max(100), aisleSide: z.string().trim().min(1).max(100), seatDirections: z.array(z.string().trim().min(1).max(50)).max(10).default([]), maxPrice: z.number().finite().positive().nullable(), seatCount: z.number().int().positive().max(10), }); type TicketAlertSearchInput = z.infer; type AlertMatchFailureReason = | 'status' | 'eventDate' | 'team' | 'maxPrice' | 'seatCount' | 'zone' | 'aisleSide' | 'seatDirections'; type AlertMatchEvaluation = | { matched: true } | { matched: false; failedReason: AlertMatchFailureReason; }; type SearchCategoryItem = { depth1Id?: number; depth2Id?: number; depth3Id?: number; depth1Name?: string; depth2Name?: string; depth3Name?: string; startDate?: string | null; endDate?: string | null; performCity?: string | null; performLocation?: string | null; }; type TicketBayProductListItem = { id: number; category_id: number; display_number: string; name: string; start_perform_date: string | null; end_perform_date: string | null; depth1_name?: string | null; depth2_name?: string | null; depth3_name?: string | null; info_type?: string | null; seat_number_type?: string | null; seat_number?: string | null; area?: string | null; floor?: string | null; grade?: string | null; addinfo?: string | null; sale_quantity?: number | null; is_together?: string | null; price?: number | null; total_price?: number | null; product_status?: string | null; is_use_safe?: string | null; is_use_pin?: string | null; is_use_delivery?: string | null; is_use_field?: string | null; is_use_etc?: string | null; description?: string | null; created_at?: string | null; transaction_type?: string | null; }; type TicketBayProductDetailInfo = TicketBayProductListItem & { product_remark?: Array<{ id?: number; content?: string | null }>; seat_remark?: Array<{ id?: number; content?: string | null }>; photo_res?: Array<{ photo_url?: string | null; origin_name?: string | null }>; transaction_type?: string | null; }; type TicketBayCategoryInfo = { seat_image?: string | null; }; const CATEGORY_VARIANT_SUFFIX_PATTERN = /\s*\((?:주중|금토일\/공휴일)\)\s*$/u; function normalizeText(value: unknown) { return String(value ?? '').trim(); } function normalizeDateTime(value: unknown) { if (value instanceof Date) { return value.toISOString(); } const normalized = normalizeText(value); if (!normalized) { return ''; } const parsed = Date.parse(normalized); return Number.isNaN(parsed) ? normalized : new Date(parsed).toISOString(); } function normalizeTimestampForDb(value: unknown) { if (value instanceof Date) { return value.toISOString(); } const normalized = normalizeText(value); if (!normalized) { return null; } const candidates = [ normalized, normalized.replace(/\s*\([^)]*\)\s*$/u, ''), normalized.replace(/\s*\([^)]*\)\s*$/u, '').replace(/\s+GMT([+-]\d{4})$/iu, ' $1'), ]; for (const candidate of candidates) { const parsed = Date.parse(candidate); if (!Number.isNaN(parsed)) { return new Date(parsed).toISOString(); } const offsetMatch = candidate.match( /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*([+-]\d{2})(\d{2})$/u, ); if (offsetMatch) { const [, datePart, timePart, hourOffset, minuteOffset] = offsetMatch; const reparsed = Date.parse(`${datePart}T${timePart}${hourOffset}:${minuteOffset}`); if (!Number.isNaN(reparsed)) { return new Date(reparsed).toISOString(); } } } return null; } function normalizeDateOnly(value: unknown) { const normalized = normalizeText(value); if (!normalized) { return ''; } return normalized.slice(0, 10); } function buildCategorySearchKeywords(input: TicketAlertSearchInput) { const keywords: string[] = []; const primaryKeyword = teamKeywordMap[input.team] ?? normalizeText(input.team); if (primaryKeyword) { keywords.push(primaryKeyword); } const title = normalizeText(input.title); if (title) { titleSearchKeywordEntries.forEach((keyword) => { if (title.includes(keyword)) { keywords.push(keyword); } }); } return [...new Set(keywords.filter(Boolean))]; } function resolveCategoryFamilyKey(item: SearchCategoryItem) { const label = normalizeText(item.depth3Name || item.depth2Name || String(item.depth3Id)); return label.replace(CATEGORY_VARIANT_SUFFIX_PATTERN, '').trim(); } function expandCategoryVariants(categories: SearchCategoryItem[]) { const familyMap = new Map(); categories.forEach((item) => { const familyKey = resolveCategoryFamilyKey(item); const existing = familyMap.get(familyKey); if (existing) { existing.push(item); return; } familyMap.set(familyKey, [item]); }); const expanded: SearchCategoryItem[] = []; const seenCategoryIds = new Set(); categories.forEach((item) => { const familyItems = familyMap.get(resolveCategoryFamilyKey(item)) ?? [item]; familyItems.forEach((familyItem) => { if (typeof familyItem.depth3Id !== 'number' || seenCategoryIds.has(familyItem.depth3Id)) { return; } seenCategoryIds.add(familyItem.depth3Id); expanded.push(familyItem); }); }); return expanded; } async function fetchText(url: string) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TICKET_BAY_FETCH_TIMEOUT_MS); let response: Response; try { response = await fetch(url, { signal: controller.signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', }, }); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { throw new Error(`티켓베이 요청 시간 초과(${TICKET_BAY_FETCH_TIMEOUT_MS}ms): ${url}`); } throw error; } finally { clearTimeout(timeout); } if (!response.ok) { throw new Error(`티켓베이 요청 실패: ${response.status} ${url}`); } return response.text(); } async function fetchJson(url: string, init?: RequestInit) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TICKET_BAY_FETCH_TIMEOUT_MS); let response: Response; try { response = await fetch(url, { ...init, signal: controller.signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', accept: 'application/json, text/plain, */*', 'content-type': 'application/json', ...(init?.headers ?? {}), }, }); } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { throw new Error(`티켓베이 요청 시간 초과(${TICKET_BAY_FETCH_TIMEOUT_MS}ms): ${url}`); } throw error; } finally { clearTimeout(timeout); } if (!response.ok) { throw new Error(`티켓베이 요청 실패: ${response.status} ${url}`); } return (await response.json()) as T; } function parseNextDataJson(html: string) { const match = html.match(/