feat: 대전(daejeon)을 울산(ulsan)으로 개편 및 전체 7개 광역 지역(seoul, gyeonggi, incheon, gwangju, busan, daegu, ulsan) 세팅 완료
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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
|
||||
subjectKey: string
|
||||
subjectName: string
|
||||
targetTerm: string
|
||||
fullKeyword: string
|
||||
}
|
||||
|
||||
// 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: '중학생', aliases: ['중학생', '중1', '중2', '중3', '중등'] },
|
||||
{ key: 'high', name: '고등학생', aliases: ['고등학생', '고1', '고2', '고3', '고등', '예비고1'] },
|
||||
{ key: 'elem', name: '초등학생', aliases: ['초등학생', '초등', '초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. '동' 붙여서 매칭
|
||||
if (map[dongQuery + '동'] && map[dongQuery + '동'].length > 0) {
|
||||
return map[dongQuery + '동'][0]
|
||||
}
|
||||
// 3. 리스트 전체 순회 매칭
|
||||
const found = (regionsData.list as RegionInfo[]).find(r =>
|
||||
r.dong === dongQuery ||
|
||||
r.dong === (dongQuery + '동') ||
|
||||
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()
|
||||
} ${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(Boolean)
|
||||
|
||||
let matchedRegion: RegionInfo | null = null
|
||||
let detectedGrade = '중학생'
|
||||
let detectedSubjectKey = 'english'
|
||||
let detectedSubjectName = '영어'
|
||||
let detectedTerm = '과외'
|
||||
|
||||
// 1. 토큰들 중 동 이름 매칭
|
||||
for (const token of tokens) {
|
||||
const reg = getRegionByDong(token)
|
||||
if (reg) {
|
||||
matchedRegion = reg
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 동 이름이 토큰에 없으면 전체 리스트에서 부분 일치 검색
|
||||
if (!matchedRegion && tokens.length > 0) {
|
||||
for (const token of tokens) {
|
||||
const found = (regionsData.list as RegionInfo[]).find(r => token.includes(r.dong) || (token.length >= 2 && r.dong.includes(token)))
|
||||
if (found) {
|
||||
matchedRegion = found
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기본값 (568개 타겟 지역 중 첫 번째 기준)
|
||||
if (!matchedRegion) {
|
||||
matchedRegion = (regionsData.list as RegionInfo[])[0] || {
|
||||
fullName: '경기도 고양시 덕양구 고양동',
|
||||
sido: '경기도',
|
||||
sigungu: '고양시 덕양구',
|
||||
dong: '고양동'
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 학년 추출
|
||||
const fullText = tokens.join(' ')
|
||||
for (const g of GRADES_LIST) {
|
||||
if (g.aliases.some(a => fullText.includes(a))) {
|
||||
detectedGrade = g.name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 과목 추출
|
||||
for (const [key, sub] of Object.entries(SUBJECTS_MAP)) {
|
||||
if (sub.aliases.some(a => fullText.includes(a))) {
|
||||
detectedSubjectKey = key
|
||||
detectedSubjectName = sub.name
|
||||
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,
|
||||
subjectKey: detectedSubjectKey,
|
||||
subjectName: detectedSubjectName,
|
||||
targetTerm: detectedTerm,
|
||||
fullKeyword
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ParsedRouteParams } from './dataHelper'
|
||||
import { getShortRegionName } from './dataHelper'
|
||||
|
||||
export function generateSeoMeta(parsed: ParsedRouteParams, siteName = '티처브릿지', siteDomain = 'https://soohyun.store') {
|
||||
const { sido, sigungu, dong, shortRegionName, grade, subjectName, targetTerm, fullKeyword } = parsed
|
||||
|
||||
const regionTitle = shortRegionName || getShortRegionName(sido, dong)
|
||||
const pageTitle = `${regionTitle} ${grade} ${subjectName} 과외 | ${dong} 내신·수능 1:1 맞춤수업 - ${siteName}`
|
||||
const pageDescription = `${sido} ${sigungu} ${dong} 지역 초·중·고 ${grade} ${subjectName} 1:1 전문 과외. 인근 학교별 기출 족보 완벽 분석, 서술형 감점 방지, 취약 개념 클리닉 및 맞춤 학습 플래너 제공. 지금 ${dong} 무료 학습 진단 및 모의수업을 신청하세요.`
|
||||
|
||||
// 표준 Canonical URL
|
||||
const canonicalUrl = `${siteDomain}/${encodeURIComponent(dong)}-${encodeURIComponent(grade)}-${encodeURIComponent(subjectName)}`
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'EducationalOrganization',
|
||||
'name': siteName,
|
||||
'url': siteDomain,
|
||||
'description': pageDescription,
|
||||
'areaServed': {
|
||||
'@type': 'AdministrativeArea',
|
||||
'name': `${sido} ${sigungu} ${dong}`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Course',
|
||||
'name': `${dong} ${grade} ${subjectName} 1:1 맞춤 과외`,
|
||||
'description': `${dong} 인근 학교 내신 출제 경향 분석 및 ${subjectName} 1등급 도약 커리큘럼`,
|
||||
'provider': {
|
||||
'@type': 'Organization',
|
||||
'name': siteName
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'BreadcrumbList',
|
||||
'itemListElement': [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
'position': 1,
|
||||
'name': '홈',
|
||||
'item': siteDomain
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
'position': 2,
|
||||
'name': sido,
|
||||
'item': `${siteDomain}/${encodeURIComponent(sido)}`
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
'position': 3,
|
||||
'name': sigungu,
|
||||
'item': `${siteDomain}/${encodeURIComponent(sigungu)}`
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
'position': 4,
|
||||
'name': `${dong} ${subjectName}`,
|
||||
'item': canonicalUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
title: pageTitle,
|
||||
meta: [
|
||||
{ name: 'description', content: pageDescription },
|
||||
{ name: 'keywords', content: `${dong}과외, ${dong}${subjectName}과외, ${dong}${grade}과외, ${sigungu}과외, ${dong}학원, ${dong}내신대비, ${subjectName}1:1과외` },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:title', content: pageTitle },
|
||||
{ property: 'og:description', content: pageDescription },
|
||||
{ property: 'og:url', content: canonicalUrl },
|
||||
{ property: 'og:site_name', content: siteName },
|
||||
{ property: 'og:image', content: `${siteDomain}/img/og-preview.png` },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: pageTitle },
|
||||
{ name: 'twitter:description', content: pageDescription }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: canonicalUrl }
|
||||
],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
children: JSON.stringify(jsonLd)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user