Files
2026-11-11 12:31:47 +09:00

231 lines
8.7 KiB
TypeScript

import regionsData from '../data/regions.json'
import keywordsData from '../data/keywords.json'
import curriculumsData from '../data/curriculums.json'
export interface RegionInfo {
fullName: string
sido: string
sigungu: string
dong: string
}
export interface ParsedRouteParams {
region: RegionInfo
sido: string
shortSido: string
sigungu: string
dong: string
shortRegionName: string
grade: string
gradeShort: string
subjectKey: string
subjectName: string
targetTerm: string
fullKeyword: string
isRegionOnly?: boolean
}
// 5대 핵심 과목 정의
export const SUBJECTS_MAP: Record<string, { key: string, name: string, badge: string, aliases: string[] }> = {
korean: { key: 'korean', name: '국어', badge: '문해력·서술형', aliases: ['국어', '문학', '독서', '비문학', '화법'] },
english: { key: 'english', name: '영어', badge: '구문독해·내신', aliases: ['영어', '영문법', '영어독해', '공통영어'] },
math: { key: 'math', name: '수학', badge: '개념정립·킬러문항', aliases: ['수학', '수1', '수2', '미적분', '확통', '기하'] },
science: { key: 'science', name: '과학', badge: '통합과학·물화생지', aliases: ['과학', '통합과학', '물리', '화학', '생명과학', '지구과학', '과탐'] },
social: { key: 'social', name: '사회', badge: '통합사회·한국사', aliases: ['사회', '통합사회', '한국사', '사탐', '역사', '사회문화', '생활과윤리'] }
}
// 학년 정의
export const GRADES_LIST = [
{ key: 'middle', name: '중학생', shortName: '중등', aliases: ['중학생', '중1', '중2', '중3', '중등'] },
{ key: 'high', name: '고등학생', shortName: '고등', aliases: ['고등학생', '고1', '고2', '고3', '고등', '예비고1', 'N수', '재수'] },
{ key: 'elem', name: '초등학생', shortName: '초등', aliases: ['초등학생', '초등', '초1', '초2', '초3', '초4', '초5', '초6'] }
]
// 동 이름으로 지역 검색
export function getRegionByDong(dongQuery: string): RegionInfo | null {
if (!dongQuery) return null
const clean = dongQuery.trim().replace(/[동읍면리가]$/, '')
const map = regionsData.dongMap as Record<string, RegionInfo[]>
// 1. 정확 매칭
if (map[dongQuery] && map[dongQuery].length > 0) {
return map[dongQuery][0]
}
// 2. '동', '읍', '면' 붙여서 매칭
for (const suffix of ['동', '읍', '면']) {
if (map[clean + suffix] && map[clean + suffix].length > 0) {
return map[clean + suffix][0]
}
}
// 3. 리스트 전체 순회 매칭
const found = (regionsData.list as RegionInfo[]).find(r =>
r.dong === dongQuery ||
r.dong === (clean + '동') ||
r.dong === (clean + '읍') ||
r.dong === (clean + '면') ||
r.dong.startsWith(clean)
)
return found || null
}
// 17개 시도 축약 매핑 및 헬퍼 함수 (예: 대구광역시 -> 대구, 서울특별시 -> 서울)
export function getShortSido(sido: string): string {
if (!sido) return ''
const trimmed = sido.trim()
if (trimmed.includes('서울')) return '서울'
if (trimmed.includes('대구')) return '대구'
if (trimmed.includes('부산')) return '부산'
if (trimmed.includes('인천')) return '인천'
if (trimmed.includes('광주')) return '광주'
if (trimmed.includes('대전')) return '대전'
if (trimmed.includes('울산')) return '울산'
if (trimmed.includes('세종')) return '세종'
if (trimmed.includes('경기')) return '경기'
if (trimmed.includes('강원')) return '강원'
if (trimmed.includes('충북') || trimmed.includes('충청북')) return '충북'
if (trimmed.includes('충남') || trimmed.includes('충청남')) return '충남'
if (trimmed.includes('전북') || trimmed.includes('전라북')) return '전북'
if (trimmed.includes('전남') || trimmed.includes('전라남')) return '전남'
if (trimmed.includes('경북') || trimmed.includes('경상북')) return '경북'
if (trimmed.includes('경남') || trimmed.includes('경상남')) return '경남'
if (trimmed.includes('제주')) return '제주'
return trimmed.slice(0, 2)
}
// 축약 지역명 생성 (예: '대구 황금동', '서울 대치동', '경기 정발산동')
export function getShortRegionName(sido: string, dong: string, sigungu?: string): string {
const meta = regionsData as any
const prefix = meta.keywordPrefix !== undefined ? meta.keywordPrefix : `${getShortSido(sido)} `
if (prefix === '') {
// 서울 (예: 강남구 개포동)
return `${sigungu ? sigungu + ' ' : ''}${dong || ''}`.trim()
}
// 경기, 인천, 광주, 부산, 대구, 대전 (예: 경기 수원 영통동, 대구 수성구 범어동)
return `${prefix}${sigungu ? sigungu + ' ' : ''}${dong || ''}`.trim()
}
// 지능형 URL 슬러그 파서
export function parseRouteSlug(slugInput: string | string[]): ParsedRouteParams {
let tokens: string[] = []
if (Array.isArray(slugInput)) {
tokens = slugInput.flatMap(s => decodeURIComponent(s).split(/[-_\s/]+/))
} else if (typeof slugInput === 'string' && slugInput.trim()) {
tokens = decodeURIComponent(slugInput).split(/[-_\s/]+/)
}
tokens = tokens.map(t => t.trim()).filter(t => Boolean(t) && !/^index\.html?$/i.test(t))
let matchedRegion: RegionInfo | null = null
let matchedTokenIdx = -1
let detectedGrade = '중학생'
let detectedGradeShort = '중등'
let detectedSubjectKey = 'math'
let detectedSubjectName = '수학'
let detectedTerm = '과외'
// 1. 토큰들 중 동 이름 매칭
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const reg = getRegionByDong(token)
if (reg) {
matchedRegion = reg
matchedTokenIdx = i
break
}
}
// 2. 동 이름이 토큰에 없으면 전체 리스트에서 부분 일치 검색
if (!matchedRegion && tokens.length > 0) {
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
const found = (regionsData.list as RegionInfo[]).find(r => token.includes(r.dong) || (token.length >= 2 && r.dong.includes(token)))
if (found) {
matchedRegion = found
matchedTokenIdx = i
break
}
}
}
// 기본값 (타겟 지역 중 첫 번째 기준)
if (!matchedRegion) {
matchedRegion = (regionsData.list as RegionInfo[])[0] || {
fullName: '인천광역시 연수구 송도동',
sido: '인천광역시',
sigungu: '연수구',
dong: '송도동'
}
}
// 3. 학년 추출 (동 이름 토큰을 제외한 나머지 토큰에서만 검색하여 '고등동', '문학동' 등 별칭 충돌 원천 차단)
let hasExplicitGrade = false
const remainingTokens = tokens.filter((_, idx) => idx !== matchedTokenIdx)
const fullText = remainingTokens.join(' ')
for (const g of GRADES_LIST) {
if (g.aliases.some(a => fullText.includes(a))) {
detectedGrade = g.name
detectedGradeShort = g.shortName
hasExplicitGrade = true
break
}
}
if (!detectedGradeShort) {
detectedGradeShort = detectedGrade === '초등학생' ? '초등' : detectedGrade === '고등학생' ? '고등' : '중등'
}
// 4. 과목 추출
let hasExplicitSubject = false
for (const [key, sub] of Object.entries(SUBJECTS_MAP)) {
if (sub.aliases.some(a => fullText.includes(a))) {
detectedSubjectKey = key
detectedSubjectName = sub.name
hasExplicitSubject = true
break
}
}
// 5. 목적/행동 추출
if (fullText.includes('내신')) detectedTerm = '내신대비'
else if (fullText.includes('학원')) detectedTerm = '학원추천'
else if (fullText.includes('수능')) detectedTerm = '수능대비'
else if (fullText.includes('기출')) detectedTerm = '기출문제분석'
else detectedTerm = '1:1 맞춤과외'
const shortSido = getShortSido(matchedRegion.sido)
const shortRegionName = getShortRegionName(matchedRegion.sido, matchedRegion.dong, matchedRegion.sigungu)
const fullKeyword = `${shortRegionName} ${detectedGrade} ${detectedSubjectName} ${detectedTerm}`
return {
region: matchedRegion,
sido: matchedRegion.sido,
shortSido,
sigungu: matchedRegion.sigungu,
dong: matchedRegion.dong,
shortRegionName,
grade: detectedGrade,
gradeShort: detectedGradeShort,
subjectKey: detectedSubjectKey,
subjectName: detectedSubjectName,
targetTerm: detectedTerm,
fullKeyword,
isRegionOnly: !hasExplicitGrade && !hasExplicitSubject
}
}
export function getAllSubjectsList() {
return Object.values(SUBJECTS_MAP)
}
export function getCurriculumBySubject(subjectKey: string) {
const data = curriculumsData as any
return data[subjectKey] || data['english']
}
export function getPopularRegions(count = 20) {
return (regionsData.list as RegionInfo[]).slice(0, count)
}
export function getTotalRegionsCount() {
return regionsData.total
}