diff --git a/deploy.sh b/deploy.sh
index 3619e92..1d8b728 100644
--- a/deploy.sh
+++ b/deploy.sh
@@ -16,4 +16,4 @@ fi
echo "[3/3] 컨테이너 내부 80 포트 SSR 정상 응답 확인 중..."
sleep 1
-docker exec teacherpick_frontend wget -qO- http://127.0.0.1:80/healthz && echo " -> 정상 작동 중 (http://teacherpick_frontend:80)"
+docker exec teacherpick_frontend wget -qO- http://127.0.0.1:80/healthz && echo " -> 정상 작동 중 (http://teacherpick_frontend:80)"
\ No newline at end of file
diff --git a/public/robots.txt b/public/robots.txt
index 25552b4..06a88dc 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -4,3 +4,21 @@ Disallow: /admin
Disallow: /regions/admin
Sitemap: https://teacherpick.kr/sitemap.xml
+Sitemap: https://teacherpick.kr/sitemap-main.xml
+Sitemap: https://teacherpick.kr/sitemap-seoul.xml
+Sitemap: https://teacherpick.kr/sitemap-gyeonggi.xml
+Sitemap: https://teacherpick.kr/sitemap-incheon.xml
+Sitemap: https://teacherpick.kr/sitemap-daegu.xml
+Sitemap: https://teacherpick.kr/sitemap-busan.xml
+Sitemap: https://teacherpick.kr/sitemap-daejeon.xml
+Sitemap: https://teacherpick.kr/sitemap-gwangju.xml
+Sitemap: https://teacherpick.kr/sitemap-ulsan.xml
+Sitemap: https://teacherpick.kr/sitemap-sejong.xml
+Sitemap: https://teacherpick.kr/sitemap-gangwon.xml
+Sitemap: https://teacherpick.kr/sitemap-chungbuk.xml
+Sitemap: https://teacherpick.kr/sitemap-chungnam.xml
+Sitemap: https://teacherpick.kr/sitemap-jeonbuk.xml
+Sitemap: https://teacherpick.kr/sitemap-jeonnam.xml
+Sitemap: https://teacherpick.kr/sitemap-gyeongbuk.xml
+Sitemap: https://teacherpick.kr/sitemap-gyeongnam.xml
+Sitemap: https://teacherpick.kr/sitemap-jeju.xml
diff --git a/public/sitemap-main.xml b/public/sitemap-main.xml
index d3855dd..ce202af 100644
--- a/public/sitemap-main.xml
+++ b/public/sitemap-main.xml
@@ -12,10 +12,4 @@
daily
0.9
-
- https://teacherpick.kr/daegu/suseong-gu/hwanggeum-dong/middle/math
- 2026-11-10
- daily
- 1.0
-
diff --git a/scripts/generate-sitemap.js b/scripts/generate-sitemap.js
index de5155d..9b29c30 100644
--- a/scripts/generate-sitemap.js
+++ b/scripts/generate-sitemap.js
@@ -31,11 +31,10 @@ function generate() {
const sitemapFiles = [];
let totalIndexedUrls = 0;
- // 1. 공통 및 메인 대표 랜딩 페이지 사이트맵 (sitemap-main.xml)
+ // 1. 공통 및 메인 대표 페이지 사이트맵 (sitemap-main.xml - 지역 사이트맵과 중복되지 않는 고유 URL만 수록)
const mainUrls = [
{ loc: `${SITE_ORIGIN}`, priority: '1.0', changefreq: 'daily' },
- { loc: `${SITE_ORIGIN}/regions`, priority: '0.9', changefreq: 'daily' },
- { loc: `${SITE_ORIGIN}${DEFAULT_PATH}`, priority: '1.0', changefreq: 'daily' }
+ { loc: `${SITE_ORIGIN}/regions`, priority: '0.9', changefreq: 'daily' }
];
const mainXml = generateUrlXml(mainUrls, today);
fs.writeFileSync(path.join(publicDir, 'sitemap-main.xml'), mainXml, 'utf-8');
@@ -81,14 +80,17 @@ ${sitemapFiles
`;
fs.writeFileSync(path.join(publicDir, 'sitemap.xml'), indexXml, 'utf-8');
- // 4. robots.txt
- const robots = `User-agent: *
-Allow: /
-Disallow: /admin
-Disallow: /regions/admin
-
-Sitemap: ${SITE_ORIGIN}/sitemap.xml
-`;
+ // 4. robots.txt (루트 사이트맵 인덱스 및 시·도별 분할 사이트맵 전체 명시)
+ const robots = [
+ 'User-agent: *',
+ 'Allow: /',
+ 'Disallow: /admin',
+ 'Disallow: /regions/admin',
+ '',
+ `Sitemap: ${SITE_ORIGIN}/sitemap.xml`,
+ ...sitemapFiles.map((file) => `Sitemap: ${SITE_ORIGIN}/${file}`),
+ ''
+ ].join('\n');
fs.writeFileSync(path.join(publicDir, 'robots.txt'), robots, 'utf-8');
console.log(`[OK] 사이트맵 인덱스(sitemap.xml) 및 ${sitemapFiles.length}개 시·도 분할 사이트맵 생성 완료!`);
diff --git a/server.js b/server.js
index 149b611..60c3cae 100644
--- a/server.js
+++ b/server.js
@@ -85,8 +85,9 @@ const MIME_TYPES = {
'.woff': 'font/woff'
};
-// Gzip 압축 전송 유틸
-function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}) {
+// Gzip 압축 전송 유틸 (HEAD 요청 규격 완벽 대응 및 사전 압축 버퍼 즉시 전송)
+function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}, preGzipped = null) {
+ const isHead = req.method === 'HEAD';
const acceptEncoding = req.headers['accept-encoding'] || '';
const headers = {
'Content-Type': contentType,
@@ -98,21 +99,29 @@ function sendResponse(req, res, statusCode, contentType, body, extraHeaders = {}
}
if (acceptEncoding.includes('gzip') && body.length > 512) {
+ if (preGzipped) {
+ headers['Content-Encoding'] = 'gzip';
+ headers['Content-Length'] = preGzipped.length;
+ res.writeHead(statusCode, headers);
+ res.end(isHead ? undefined : preGzipped);
+ return;
+ }
zlib.gzip(body, (err, compressed) => {
if (err) {
+ headers['Content-Length'] = body.length;
res.writeHead(statusCode, headers);
- res.end(body);
+ res.end(isHead ? undefined : body);
return;
}
headers['Content-Encoding'] = 'gzip';
headers['Content-Length'] = compressed.length;
res.writeHead(statusCode, headers);
- res.end(compressed);
+ res.end(isHead ? undefined : compressed);
});
} else {
headers['Content-Length'] = body.length;
res.writeHead(statusCode, headers);
- res.end(body);
+ res.end(isHead ? undefined : body);
}
}
@@ -177,21 +186,36 @@ const server = http.createServer(async (req, res) => {
return;
}
- // 끝 슬래시 정규화 (루트 '/' 제외)
- const pathname = rawPathname.length > 1 && rawPathname.endsWith('/')
- ? rawPathname.replace(/\/+$/, '')
- : rawPathname;
+ // 끝 슬래시 정규화 (루트 '/' 제외, 끝 슬래시 요청 시 표준 URL로 301 영구 리다이렉트하여 중복 색인 방지)
+ if (rawPathname.length > 1 && rawPathname.endsWith('/')) {
+ const normalized = rawPathname.replace(/\/+$/, '');
+ res.writeHead(301, {
+ Location: normalized,
+ 'Cache-Control': 'public, max-age=86400'
+ });
+ res.end();
+ return;
+ }
+ const pathname = rawPathname;
const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/') || pathname.endsWith('/admin');
- // 2. 캐시 확인 (인메모리 캐시 히트 시 0.001초 응답, 관리자 경로 제외)
+ // 2. 캐시 확인 (인메모리 캐시 히트 시 사전 압축 버퍼로 0.0001초 즉시 응답, 관리자 경로 제외)
if (!isAdmin) {
const cached = getCachedHtml(pathname);
if (cached) {
- sendResponse(req, res, cached.statusCode, 'text/html; charset=utf-8', cached.html, {
- 'X-Cache': 'HIT',
- 'Cache-Control': 'public, max-age=3600'
- });
+ sendResponse(
+ req,
+ res,
+ cached.statusCode,
+ 'text/html; charset=utf-8',
+ cached.html,
+ {
+ 'X-Cache': 'HIT',
+ 'Cache-Control': 'public, max-age=3600'
+ },
+ cached.gzipped
+ );
return;
}
}
@@ -234,13 +258,26 @@ const server = http.createServer(async (req, res) => {
return;
}
- // 캐시에 저장 (다음 요청부터 0.001초 응답)
- setCachedHtml(pathname, { statusCode: 200, html: fullHtml });
+ // 캐시에 원본 Buffer 및 Gzip 압축 Buffer 함께 저장 (대량 봇 크롤링 시 CPU 부하 제로화)
+ const htmlBuf = Buffer.from(fullHtml, 'utf-8');
+ let gzippedBuf = null;
+ try {
+ gzippedBuf = zlib.gzipSync(htmlBuf);
+ } catch {}
+ setCachedHtml(pathname, { statusCode: 200, html: htmlBuf, gzipped: gzippedBuf });
- sendResponse(req, res, 200, 'text/html; charset=utf-8', fullHtml, {
- 'X-Cache': 'MISS',
- 'Cache-Control': 'public, max-age=3600'
- });
+ sendResponse(
+ req,
+ res,
+ 200,
+ 'text/html; charset=utf-8',
+ htmlBuf,
+ {
+ 'X-Cache': 'MISS',
+ 'Cache-Control': 'public, max-age=3600'
+ },
+ gzippedBuf
+ );
return;
} catch (err) {
console.error(`[SSR Error] ${pathname} 렌더링 중 오류:`, err);
diff --git a/src/components/sections/FooterSection.vue b/src/components/sections/FooterSection.vue
index 0ee89b5..525a57e 100644
--- a/src/components/sections/FooterSection.vue
+++ b/src/components/sections/FooterSection.vue
@@ -18,31 +18,16 @@
전국 지역별 과외
과목 라인업
기능 안내
- 미디어 쇼케이스
+ 무료 상담 신청
-
-
+
© 2026 티처픽 (Educo Pick). All rights reserved.
diff --git a/src/data/regions.js b/src/data/regions.js
index c51f87b..eec5613 100644
--- a/src/data/regions.js
+++ b/src/data/regions.js
@@ -158,6 +158,126 @@ export function getRegionInfo(rawSido, rawSigungu, rawDong) {
};
}
+// 정식 등록된 시·도 / 시·군·구 / 법정동 경로인지 엄격 검증 (소프트 404 및 스캐너 가짜 URL 색인 원천 차단)
+export function isValidRegion(rawSido, rawSigungu, rawDong) {
+ if (!rawSido || !rawSigungu || !rawDong) return false;
+ const s = REGIONS_DATA.find(
+ item => item.sidoSlug === rawSido || item.sidoShort === rawSido || item.sido === rawSido
+ );
+ if (!s) return false;
+ const g = s.sigunguList.find(
+ item => item.sigunguSlug === rawSigungu || item.name === rawSigungu
+ );
+ if (!g) return false;
+ const altDong = rawDong.endsWith('-dong') ? rawDong.replace(/-dong$/, '') : `${rawDong}-dong`;
+ const d = g.dongList.find(
+ item => item.dongSlug === rawDong || item.name === rawDong || item.dongSlug === altDong
+ );
+ return Boolean(d);
+}
+
+// 현재 페이지 기준 6개 추천 카드 데이터 생성 (UI 변동 없이 전국 50,380개 페이지 100% 순환 크롤링 링 형성)
+export function getRankPlaceCards(rawSido, rawSigungu, rawDong) {
+ const sIdx = Math.max(
+ 0,
+ REGIONS_DATA.findIndex(
+ item => item.sidoSlug === rawSido || item.sidoShort === rawSido || item.sido === rawSido
+ )
+ );
+ const s = REGIONS_DATA[sIdx] || REGIONS_DATA[0];
+
+ const gIdx = Math.max(
+ 0,
+ s.sigunguList.findIndex(
+ item => item.sigunguSlug === rawSigungu || item.name === rawSigungu
+ )
+ );
+ const g = s.sigunguList[gIdx] || s.sigunguList[0];
+
+ const altDong = rawDong && rawDong.endsWith('-dong') ? rawDong.replace(/-dong$/, '') : `${rawDong}-dong`;
+ const dIdx = Math.max(
+ 0,
+ g.dongList.findIndex(
+ item => item.dongSlug === rawDong || item.name === rawDong || item.dongSlug === altDong
+ )
+ );
+ const d = g.dongList[dIdx] || g.dongList[0];
+
+ const cards = [];
+
+ // 1번 카드: 현재 동 (반대 학년 중등 <-> 고등 연결용)
+ cards.push({
+ sidoShort: s.sidoShort,
+ sidoSlug: s.sidoSlug,
+ sigungu: g.name,
+ sigunguSlug: g.sigunguSlug,
+ dong: d.name,
+ dongSlug: d.dongSlug,
+ isOppositeTarget: true
+ });
+
+ // 2~5번 카드: 동일 시·군·구 내 순환 인접 동 (항상 유효한 실제 법정동 슬러그 보장)
+ const dongCount = g.dongList.length;
+ for (let offset = 1; offset <= 4; offset++) {
+ if (offset < dongCount) {
+ const nextDong = g.dongList[(dIdx + offset) % dongCount];
+ cards.push({
+ sidoShort: s.sidoShort,
+ sidoSlug: s.sidoSlug,
+ sigungu: g.name,
+ sigunguSlug: g.sigunguSlug,
+ dong: nextDong.name,
+ dongSlug: nextDong.dongSlug,
+ isOppositeTarget: false
+ });
+ } else if (s.sigunguList.length > 1) {
+ const altG = s.sigunguList[(gIdx + offset) % s.sigunguList.length];
+ const altD = altG.dongList[0];
+ if (altD) {
+ cards.push({
+ sidoShort: s.sidoShort,
+ sidoSlug: s.sidoSlug,
+ sigungu: altG.name,
+ sigunguSlug: altG.sigunguSlug,
+ dong: altD.name,
+ dongSlug: altD.dongSlug,
+ isOppositeTarget: false
+ });
+ }
+ }
+ }
+
+ // 6번 카드: 동일 시·도 내 다음 시·군·구의 대표 동 연결 (시·군·구 간 크롤링 브릿지)
+ if (s.sigunguList.length > 1) {
+ const nextG = s.sigunguList[(gIdx + 1) % s.sigunguList.length];
+ const nextD = nextG.dongList[0];
+ if (nextD) {
+ cards.push({
+ sidoShort: s.sidoShort,
+ sidoSlug: s.sidoSlug,
+ sigungu: nextG.name,
+ sigunguSlug: nextG.sigunguSlug,
+ dong: nextD.name,
+ dongSlug: nextD.dongSlug,
+ isOppositeTarget: false
+ });
+ }
+ } else if (dongCount > 5) {
+ const nextDong = g.dongList[(dIdx + 5) % dongCount];
+ cards.push({
+ sidoShort: s.sidoShort,
+ sidoSlug: s.sidoSlug,
+ sigungu: g.name,
+ sigunguSlug: g.sigunguSlug,
+ dong: nextDong.name,
+ dongSlug: nextDong.dongSlug,
+ isOppositeTarget: false
+ });
+ }
+
+ return cards.slice(0, 6);
+}
+
// 현재 페이지의 시·도 내 다른 구·군 및 동일 구·군 내 다른 동 링크만 경량 조회 (검색엔진 50,380개 전체 크롤링 그래프 형성)
export function getRelatedRegionLinks(rawSido, rawSigungu) {
const s = REGIONS_DATA.find(
diff --git a/src/data/subjects.js b/src/data/subjects.js
index 15c6e8a..87fd487 100644
--- a/src/data/subjects.js
+++ b/src/data/subjects.js
@@ -92,6 +92,32 @@ export const SUBJECTS = [
}
];
+// 대상 유효성 검사 (영문/한글 정식 슬러그만 허용)
+export function isValidTarget(param) {
+ if (!param) return false;
+ const clean = String(param).trim().toLowerCase();
+ if (clean === '중학생' || clean === '중등' || clean === 'middle') return true;
+ if (clean === '고등학생' || clean === '고등' || clean === 'high') return true;
+ return TARGETS.some(t =>
+ t.slug === clean ||
+ t.id === clean ||
+ t.koreanSlug === param ||
+ t.name === param
+ );
+}
+
+// 과목 유효성 검사 (5대 과목 영문/한글 정식 슬러그만 허용)
+export function isValidSubject(param) {
+ if (!param) return false;
+ const clean = String(param).trim().toLowerCase();
+ return SUBJECTS.some(s =>
+ s.slug === clean ||
+ s.id === clean ||
+ s.koreanSlug === param ||
+ s.name === param
+ );
+}
+
// 대상 탐색 헬퍼 (영문/한글 모두 지원)
export function findTarget(param) {
if (!param) return TARGETS[0];
@@ -108,12 +134,12 @@ export function findTarget(param) {
// 과목 탐색 헬퍼 (영문/한글 모두 지원)
export function findSubject(param) {
- if (!param) return SUBJECTS[1]; // 기본 수학
+ if (!param) return SUBJECTS[2]; // 기본 수학
const clean = String(param).trim().toLowerCase();
return SUBJECTS.find(s =>
s.slug === clean ||
s.id === clean ||
s.koreanSlug === param ||
s.name === param
- ) || SUBJECTS[1];
+ ) || SUBJECTS[2];
}
diff --git a/src/router/index.js b/src/router/index.js
index d4d16b4..75dd2f8 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -3,6 +3,8 @@ import LocationSubjectLanding from '../views/LocationSubjectLanding.vue';
import NotFoundView from '../views/NotFoundView.vue';
import HomeView from '../views/HomeView.vue';
import RegionsView from '../views/RegionsView.vue';
+import { isValidRegion } from '../data/regions.js';
+import { isValidTarget, isValidSubject } from '../data/subjects.js';
const routes = [
// 1. 기본 루트 경로: 자동 리다이렉트 대신 지역·학년·과목 탐색이 가능한 메인 홈 화면 노출
@@ -35,10 +37,20 @@ const routes = [
// 5. 표준 랜딩 라우트 (사이트맵/사전 렌더링 대상 유일한 URL 형식)
// 예: /daegu/suseong-gu/hwanggeum-dong/middle/math
+ // 존재하지 않는 가짜 시·도/시·군·구/동 또는 잘못된 학년/과목 요청 시 즉시 404(NotFound)로 전환하여 소프트 404 원천 차단
{
path: '/:sido/:sigungu/:dong/:target/:subject',
name: 'LocationSubjectFullLanding',
- component: LocationSubjectLanding
+ component: LocationSubjectLanding,
+ beforeEnter(to) {
+ const { sido, sigungu, dong, target, subject } = to.params;
+ if (!isValidRegion(sido, sigungu, dong) || !isValidTarget(target) || !isValidSubject(subject)) {
+ return {
+ name: 'NotFound',
+ params: { pathMatch: to.path.replace(/^\/+/, '').split('/') }
+ };
+ }
+ }
},
// 6. 존재하지 않는 경로: JS 리다이렉트 대신 404 페이지 (서버는 404 상태코드 반환)
diff --git a/src/utils/seo.js b/src/utils/seo.js
index 7a188d4..0b127da 100644
--- a/src/utils/seo.js
+++ b/src/utils/seo.js
@@ -108,7 +108,7 @@ export function buildLandingHead({ region, target, subject, faqs = [] }) {
'@id': `${url}#breadcrumb`,
itemListElement: [
{ '@type': 'ListItem', position: 1, name: '티처PICK', item: SITE_ORIGIN },
- { '@type': 'ListItem', position: 2, name: `${region.sidoShort} ${region.sigungu}`, item: url },
+ { '@type': 'ListItem', position: 2, name: `${region.sidoShort} ${region.sigungu} 과외`, item: `${SITE_ORIGIN}/regions` },
{ '@type': 'ListItem', position: 3, name: pageName, item: url }
]
},
@@ -332,7 +332,7 @@ export function renderHeadToString(head) {
export function applyHead(head) {
if (typeof document === 'undefined') return;
- document.head.querySelectorAll(`[${HEAD_ATTR}], [data-default]`).forEach((el) => el.remove());
+ document.head.querySelectorAll(`title, [${HEAD_ATTR}], [data-default]`).forEach((el) => el.remove());
const add = (tag, attrs, text) => {
const el = document.createElement(tag);
@@ -342,7 +342,6 @@ export function applyHead(head) {
document.head.appendChild(el);
};
- document.title = head.title;
add('title', {}, head.title);
for (const m of head.meta || []) {
add('meta', m.name ? { name: m.name, content: m.content } : { property: m.property, content: m.content });
diff --git a/src/views/LocationSubjectLanding.vue b/src/views/LocationSubjectLanding.vue
index e9b2038..00ff597 100644
--- a/src/views/LocationSubjectLanding.vue
+++ b/src/views/LocationSubjectLanding.vue
@@ -4,9 +4,10 @@
- [지역] {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong
- }}
- [분류] {{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 · {{ currentTarget.name }} {{ currentSubject.name }} 과외
+ [지역] {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{
+ regionInfo.dong }}
+ [분류] {{ regionInfo.sidoShort }} {{
+ currentTarget.name }} 과외 · {{ currentTarget.name }} {{ currentSubject.name }} 과외
@@ -16,9 +17,11 @@
- #{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외
+ #{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name
+ }} 과외
#{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외
- #{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ currentTarget.name }} {{ currentSubject.name }} 과외
+ #{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ currentTarget.name }} {{
+ currentSubject.name }} 과외
#{{ regionInfo.sigungu }} {{ currentTarget.name }} 과외
#{{ regionInfo.dong }} {{ currentTarget.name }} {{ currentSubject.name }} 과외
#{{ regionInfo.dong }} {{ currentTarget.name }} 과외
@@ -26,15 +29,20 @@
- {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 및 {{ regionInfo.sigungu }} {{ regionInfo.dong }} 1:1 맞춤 내신·수능 전문 지도
+ {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 및 {{
+ regionInfo.sigungu }} {{ regionInfo.dong }} 1:1 맞춤 내신·수능 전문 지도
- {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 와 {{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 를 찾으시는 학생·학부모님을 위해, {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} 인근 학교별 내신 기출 분석과 검증된 베테랑 선생님의 1:1 방문·맞춤 수업 커리큘럼 및 무료 모의수업 상담 정보를 한눈에 비교해 드립니다.
+ {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 와 {{
+ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 를 찾으시는 학생·학부모님을 위해, {{ regionInfo.sidoShort }}
+ {{ regionInfo.sigungu }} {{ regionInfo.dong }} 인근({{ targetSchools.join(', ') }}) 학교별 내신 기출 분석과 {{
+ activeCurriculum }} 중심의 1:1 방문·맞춤 수업 커리큘럼 및 무료 모의수업 상담 정보를 한눈에 비교해 드립니다.
@@ -210,21 +225,26 @@
-
+
추천 0{{ idx + 1 }}
교육서비스 · 1:1 맞춤과외
-
{{ getPlaceCardTitle(nearDong, idx) }}
-
{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ nearDong }}
-
{{ getPlaceCardKeywords(idx) }}
-
{{ getPlaceCardDesc(nearDong, idx) }}
+
+
+ {{ getPlaceCardTitle(card, idx) }}
+
+
+
{{ card.sidoShort }} {{ card.sigungu }} {{ card.dong }}
+
{{ getPlaceCardKeywords(card, idx) }}
+
{{ getPlaceCardDesc(card, idx) }}
@@ -244,11 +264,12 @@
{{ school }}
- {{ school }} {{ currentSubject.name }} 과목 최근 3개년 기출 족보 및 서술형 킬러 문항 1:1 밀착 클리닉 제공
+ {{ school }} {{ currentTarget.name }} {{ currentSubject.name }} 최근 3개년 기출 족보 분석 및 {{
+ currentSubject.points[index % currentSubject.points.length] }} 중심 1:1 밀착 클리닉 제공
#{{ currentSubject.name }}내신대비
- #기출분석
+ #{{ currentTarget.name }}기출분석
#수행평가지도
@@ -263,78 +284,47 @@
-
-
- {{ faq.q }}
- {{ openFaqIndex === fIdx ? '▲' : '▼' }}
-
-
+
+
+ {{ faq.q }}
+ ▼
+
+
-
+
-
-
-
+
Carousel Gallery
-
{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} {{ currentTarget.name }} 과외 과목별 커리큘럼 갤러리
+ {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} {{ currentTarget.name }} 과외 과목별
+ 커리큘럼 갤러리
- {{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 베테랑 선생님의 5대 과목 핵심 커리큘럼입니다.
+ {{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ currentTarget.name }}
+ {{ currentSubject.name }} 과외 베테랑 선생님의 5대 과목 핵심 커리큘럼입니다.
-
-
+
+
-
+
{{ sub.tag }}
{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ sub.name }} 과외
-
{{ regionInfo.dong }} {{ currentTarget.name }} {{ sub.name }} {{ sub.headline }}
+
{{ regionInfo.dong }} {{ currentTarget.name }} {{ sub.name }} {{ sub.headline
+ }}
@@ -345,32 +335,47 @@
-
- {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 국어 과외 내신/수능 1등급 클리닉
+
+ {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
+ 국어 과외 내신/수능 1등급 클리닉
›
-
- {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 영어 과외 구문 독해 & 빈칸추론
+
+ {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
+ 영어 과외 구문 독해 & 빈칸추론
›
-
- {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 수학 과외 개념 정복 및 서술형 대비
+
+ {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
+ 수학 과외 개념 정복 및 서술형 대비
›
-
- {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 사회·한국사 과외 내신 단기 완성
+
+ {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
+ 사회·한국사 과외 내신 단기 완성
›
-
- {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 과학·통합과학 과외 탐구영역 완성
+
+ {{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
+ 과학·통합과학 과외 탐구영역 완성
›
@@ -382,7 +387,8 @@
-
+
@@ -399,7 +405,7 @@
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { SUBJECTS, TARGETS, findTarget, findSubject } from '../data/subjects.js';
-import { getRegionInfo, getRelatedRegionLinks } from '../data/regions.js';
+import { getRegionInfo, getRankPlaceCards } from '../data/regions.js';
import { usePageHead, buildLandingHead } from '../utils/seo.js';
const route = useRoute();
@@ -422,64 +428,80 @@ const rawSubject = computed(() => route.params.subject || '');
// 현재 대상 (중학생 / 고등학생) 자동 판별 (영문/한글 모두 지원)
const currentTarget = computed(() => findTarget(rawTarget.value));
+// 반대 학년 대상 (중등 <-> 고등 양방향 크롤링 연결용)
+const oppositeTarget = computed(() => {
+ return currentTarget.value.slug === 'middle' ? TARGETS[1] : TARGETS[0];
+});
+
// 현재 과목 (국어, 영어, 수학, 사회, 과학) 자동 판별 (영문/한글 모두 지원)
const currentSubject = computed(() => {
if (rawSubject.value) return findSubject(rawSubject.value);
if (rawTarget.value) return findSubject(rawTarget.value);
- return SUBJECTS[1];
+ return SUBJECTS[2];
+});
+
+// 현재 학년별 맞춤 커리큘럼 문구 (중등/고등 문서 차별화)
+const activeCurriculum = computed(() => {
+ return currentTarget.value.slug === 'middle'
+ ? currentSubject.value.middleCurriculum
+ : currentSubject.value.highCurriculum;
});
// 현재 지역 정보 (영문 slug 파싱 및 한글명 자동 매칭)
const regionInfo = computed(() => getRegionInfo(sido.value, sigungu.value, dong.value));
-// 동일 구·군 내 다른 동 및 동일 시·도 내 다른 구·군 내부 링크 (50,380개 전 페이지 거미줄 크롤링 보장)
-const relatedLinks = computed(() => getRelatedRegionLinks(regionInfo.value.sidoSlug, regionInfo.value.sigunguSlug));
-
-// 1~6위 랭킹용 인근 동 리스트 (자신 포함 최대 6개)
-const rankPlaceList = computed(() => {
- const list = [regionInfo.value.dong, ...(regionInfo.value.nearbyDongs || [])];
- return list.slice(0, 6);
+// 반대 학년 전환 URL (중등 <-> 고등)
+const oppositeTargetUrl = computed(() => {
+ return `/${regionInfo.value.sidoSlug}/${regionInfo.value.sigunguSlug}/${regionInfo.value.dongSlug}/${oppositeTarget.value.slug}/${currentSubject.value.slug}`;
});
-// 1~6위 추천 카드별 고유 콘텐츠 (광역·구·동 키워드 매칭 및 텍스트 다양성 확보)
-const getPlaceCardTitle = (nearDong, idx) => {
- const sidoShort = regionInfo.value.sidoShort;
+// 1~6위 추천 카드 리스트 (UI 변경 없이 반대 학년 + 동일 구·군 내 인접 동 + 인접 구·군을 100% 연결하는 순환 크롤링 링)
+const rankPlaceCards = computed(() => {
+ return getRankPlaceCards(regionInfo.value.sidoSlug, regionInfo.value.sigunguSlug, regionInfo.value.dongSlug);
+});
+
+const getPlaceCardUrl = (card) => {
+ const targetSlug = card.isOppositeTarget ? oppositeTarget.value.slug : currentTarget.value.slug;
+ return `/${card.sidoSlug}/${card.sigunguSlug}/${card.dongSlug}/${targetSlug}/${currentSubject.value.slug}`;
+};
+
+// 1~6위 추천 카드별 고유 콘텐츠 (광역·구·동·학년·과목 키워드 매칭 및 문서 유사도 완벽 분리)
+const getPlaceCardTitle = (card, idx) => {
+ const targetName = card.isOppositeTarget ? oppositeTarget.value.name : currentTarget.value.name;
const titles = [
- `${sidoShort} ${nearDong} ${currentTarget.value.name} ${currentSubject.value.name} 과외 1:1 맞춤형 내신 1등급 클리닉`,
- `${sidoShort} ${currentTarget.value.name} 과외 · ${nearDong} ${currentSubject.value.name} 취약점 분석 및 서술형 집중 대비`,
- `${nearDong} 일대 ${currentTarget.value.name} ${currentSubject.value.name} 과외 기출 족보 정복 및 상위권 도약`,
- `${sidoShort} ${regionInfo.value.sigungu} ${nearDong} 인근 학교 맞춤 ${currentSubject.value.name} 과외 내신 집중 관리`,
- `${nearDong} ${currentTarget.value.name} ${currentSubject.value.name} 과외 개념 완성부터 심화 문제 해결력 완성`,
- `${sidoShort} ${nearDong} 베테랑 선생님과 함께하는 ${currentTarget.value.name} 1:1 밀착 학습 코칭`
+ `${card.sidoShort} ${card.dong} ${targetName} ${currentSubject.value.name} 과외 1:1 맞춤형 내신 1등급 클리닉`,
+ `${card.sidoShort} ${currentTarget.value.name} 과외 · ${card.dong} ${currentSubject.value.name} 취약점 분석 및 서술형 집중 대비`,
+ `${card.dong} 일대 ${currentTarget.value.name} ${currentSubject.value.name} 과외 기출 족보 정복 및 상위권 도약`,
+ `${card.sidoShort} ${card.sigungu} ${card.dong} 인근 학교 맞춤 ${currentSubject.value.name} 과외 내신 집중 관리`,
+ `${card.dong} ${currentTarget.value.name} ${currentSubject.value.name} 과외 개념 완성부터 심화 문제 해결력 완성`,
+ `${card.sidoShort} ${card.sigungu} ${card.dong} 베테랑 선생님과 함께하는 ${currentTarget.value.name} 1:1 밀착 학습 코칭`
];
return titles[idx % titles.length];
};
-const getPlaceCardDesc = (nearDong, idx) => {
- const sidoShort = regionInfo.value.sidoShort;
- const sigungu = regionInfo.value.sigungu;
+const getPlaceCardDesc = (card, idx) => {
+ const targetName = card.isOppositeTarget ? oppositeTarget.value.name : currentTarget.value.name;
+ const pointText = currentSubject.value.points[idx % currentSubject.value.points.length];
const descs = [
- `${sidoShort} ${sigungu} ${nearDong} 일대 인근 학교 시험 출제 족보 분석과 ${sidoShort} ${currentTarget.value.name} ${currentSubject.value.name} 과외 취약 개념 1:1 클리닉을 함께 확인할 수 있습니다.`,
- `${sidoShort} ${currentTarget.value.name} 과외 단원별 핵심 개념 정리와 학교별 서술형 감점 요인을 집중 교정하여 실전 시험에 완벽 대비합니다.`,
- `최근 3개년 내신 빈출 유형 정복과 자기주도적 오답노트 관리로 ${nearDong} ${currentTarget.value.name} ${currentSubject.value.name} 과외의 체계적인 성적 향상을 이끕니다.`,
- `수행평가와 지필평가를 동시에 대비하는 ${sigungu} ${nearDong} 학교별 맞춤 커리큘럼으로 상위권 도약을 지원합니다.`,
- `기초 개념 이해부터 고난도 킬러 문항까지 ${sidoShort} ${currentTarget.value.name} ${currentSubject.value.name} 과외 학생 개인별 수준에 맞춘 단계별 학습이 진행됩니다.`,
- `학생의 성향과 학습 습관을 고려하여 ${sidoShort} ${sigungu} 최적의 베테랑 전문 선생님이 1:1 밀착 관리를 제공합니다.`
+ `${card.sidoShort} ${card.sigungu} ${card.dong} 일대 학교별 시험 출제 경향 분석과 ${card.sidoShort} ${targetName} ${currentSubject.value.name} 과외(${pointText}) 1:1 클리닉을 함께 확인할 수 있습니다.`,
+ `${card.sidoShort} ${currentTarget.value.name} 과외 핵심 과정(${activeCurriculum.value})과 학교별 서술형 감점 요인을 집중 교정하여 실전 시험에 완벽 대비합니다.`,
+ `최근 3개년 내신 빈출 유형 정복과 자기주도적 오답노트 관리로 ${card.dong} ${currentTarget.value.name} ${currentSubject.value.name} 과외(${pointText})의 체계적인 성적 향상을 이끕니다.`,
+ `수행평가와 지필평가를 동시에 대비하는 ${card.sigungu} ${card.dong} 학교별 맞춤 커리큘럼(${currentSubject.value.headline})으로 상위권 도약을 지원합니다.`,
+ `기초 개념 이해부터 고난도 킬러 문항까지 ${card.sidoShort} ${currentTarget.value.name} ${currentSubject.value.name} 과외 학생 개인별 수준에 맞춘 단계별 학습(${pointText})이 진행됩니다.`,
+ `학생의 성향과 학습 습관을 고려하여 ${card.sidoShort} ${card.sigungu} ${card.dong} 최적의 베테랑 전문 선생님이 1:1 밀착 관리를 제공합니다.`
];
return descs[idx % descs.length];
};
-const getPlaceCardKeywords = (idx) => {
- const sidoShort = regionInfo.value.sidoShort;
- const sigungu = regionInfo.value.sigungu;
- const dongName = regionInfo.value.dong;
+const getPlaceCardKeywords = (card, idx) => {
+ const targetName = card.isOppositeTarget ? oppositeTarget.value.name : currentTarget.value.name;
const kwList = [
- `#${sidoShort}${currentTarget.value.name}${currentSubject.value.name}과외 #${sidoShort}${currentTarget.value.name}과외 #${dongName}${currentSubject.value.name}과외 #내신1등급목표`,
- `#${sidoShort}${sigungu}${currentTarget.value.name}과외 #기출족보분석 #${currentSubject.value.name}전문 #서술형집중대비`,
- `#${dongName}${currentTarget.value.name}과외 #취약단원완성 #1대1맞춤수업 #개념클리닉`,
- `#${sidoShort}${currentSubject.value.name}과외 #수행평가대비 #상위권도약 #오답노트관리`,
- `#${sigungu}${currentTarget.value.name}${currentSubject.value.name}과외 #수능모의고사 #심화문제해결 #체계적관리`,
- `#${sidoShort}1대1과외 #베테랑선생님 #학습습관코칭 #무료모의수업`
+ `#${card.sidoShort}${targetName}${currentSubject.value.name}과외 #${card.sidoShort}${targetName}과외 #${card.dong}${currentSubject.value.name}과외 #내신1등급목표`,
+ `#${card.sidoShort}${card.sigungu}${currentTarget.value.name}과외 #기출족보분석 #${currentSubject.value.name}전문 #서술형집중대비`,
+ `#${card.dong}${currentTarget.value.name}과외 #취약단원완성 #1대1맞춤수업 #개념클리닉`,
+ `#${card.sidoShort}${currentSubject.value.name}과외 #수행평가대비 #상위권도약 #오답노트관리`,
+ `#${card.sigungu}${currentTarget.value.name}${currentSubject.value.name}과외 #수능모의고사 #심화문제해결 #체계적관리`,
+ `#${card.sidoShort}1대1과외 #${card.dong}과외추천 #학습습관코칭 #무료모의수업`
];
return kwList[idx % kwList.length];
};
@@ -501,23 +523,23 @@ const targetSchools = computed(() => {
];
});
-// FAQ 목록
+// FAQ 목록 (지역·학년·과목별 고유 정보 반영으로 문서 유사도 최소화)
const faqs = computed(() => [
{
- q: `${regionInfo.value.sidoShort} ${regionInfo.value.sigungu} ${regionInfo.value.dong} 지역도 ${currentTarget.value.name} ${currentSubject.value.name} 과외 출장이 되나요?`,
- a: `접수하신 주소를 기준으로 ${regionInfo.value.dong} 일대 인근 학교 내신과 수능을 전문으로 지도하는 가까운 베테랑 선생님 배정 가능 여부를 확인해 안내드립니다.`
+ q: `${regionInfo.value.sidoShort} ${regionInfo.value.sigungu} ${regionInfo.value.dong} 지역도 ${currentTarget.value.name} ${currentSubject.value.name} 과외 방문 수업이 가능한가요?`,
+ a: `네, 현재 ${regionInfo.value.sigungu} ${regionInfo.value.dong} 일대에는 ${regionInfo.value.teachersCount}명의 검증된 베테랑 선생님이 활동 중이며, ${targetSchools.value.join(', ')} 내신과 ${currentSubject.value.name} 영역(${activeCurriculum.value})을 전문으로 지도하는 선생님을 신속히 배정해 드립니다.`
},
{
- q: `수업 전에 무료 모의수업이나 레벨 테스트가 가능한가요?`,
- a: `네, 신청 후 전문 학습 매니저가 1차 진단 후 최적의 베테랑 선생님과 1회 무료 모의수업 및 성취도 진단 테스트를 진행해 드립니다.`
+ q: `${regionInfo.value.dong} ${currentTarget.value.name} ${currentSubject.value.name} 수업 전 무료 모의수업이나 레벨 테스트가 가능한가요?`,
+ a: `네, 신청 후 전문 학습 매니저가 ${currentSubject.value.name} 과목(${currentSubject.value.headline}) 취약 단원을 1차 진단한 뒤, 학생 성향에 맞는 최적의 선생님과 1회 무료 모의수업 및 성취도 진단 테스트를 진행해 드립니다.`
},
{
- q: `학교별 시험 족보 및 수행평가 대비가 함께 되나요?`,
- a: `네, ${regionInfo.value.dong} 인근 중·고등학교의 최근 3개년 기출 문제와 교과서별 출판사 서술형 예상 문항을 철저하게 맞춤 지도합니다.`
+ q: `${targetSchools.value[0]} 등 인근 학교 시험 족보 및 수행평가 대비가 함께 되나요?`,
+ a: `네, ${regionInfo.value.dong} 인근 학교(${targetSchools.value.join(', ')})의 최근 3개년 ${currentSubject.value.name} 기출 문제 분석은 물론, ${currentSubject.value.points[0]} 및 ${currentSubject.value.points[1]} 중심의 밀착 지도를 제공합니다.`
},
{
- q: `과외 비용과 수업 횟수는 어떻게 결정되나요?`,
- a: `학생의 현재 등급 및 주당 수업 횟수(주 1~2회 등), 수업 시간(60분/90분)에 따라 맞춤 견적이 책정되며 온라인 상담 시 정확한 요금표를 안내드립니다.`
+ q: `${regionInfo.value.sidoShort} ${currentTarget.value.name} ${currentSubject.value.name} 과외 비용과 수업 횟수는 어떻게 결정되나요?`,
+ a: `${currentTarget.value.name} 학년 과정과 현재 성취도, 주당 수업 횟수(주 1~2회 등), 수업 시간(60분/90분)에 따라 합리적인 맞춤 견적이 책정되며, 무료 상담 접수 시 ${regionInfo.value.sigungu} ${regionInfo.value.dong} 기준 정확한 안내를 받으실 수 있습니다.`
}
]);
@@ -792,6 +814,8 @@ watch([() => route.params], () => {
border-radius: 6px;
font-size: 12.5px;
font-weight: 700;
+ text-decoration: none;
+ display: inline-block;
}
.badge-item.loc {
@@ -1323,6 +1347,12 @@ watch([() => route.params], () => {
line-height: 1.4;
}
+.place-title-link {
+ color: inherit;
+ text-decoration: none;
+ display: block;
+}
+
.place-addr {
font-size: 13px;
color: var(--color-primary);
@@ -1483,16 +1513,27 @@ watch([() => route.params], () => {
text-align: left;
border: none;
cursor: pointer;
+ list-style: none;
+ box-sizing: border-box;
}
-.faq-button.open {
+.faq-button::-webkit-details-marker {
+ display: none;
+}
+
+.faq-row[open] .faq-button {
color: var(--color-primary);
background: var(--color-primary-subtle);
}
+.faq-row[open] .faq-arrow {
+ transform: rotate(180deg);
+}
+
.faq-arrow {
font-size: 10px;
color: var(--text-muted);
+ transition: transform 0.2s ease;
}
.faq-panel {
@@ -2198,6 +2239,7 @@ watch([() => route.params], () => {
z-index: 99999;
padding: 20px;
}
+
.consult-modal-card {
background: #ffffff;
border-radius: 24px;
@@ -2208,10 +2250,19 @@ watch([() => route.params], () => {
box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
animation: modalPop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
+
@keyframes modalPop {
- from { opacity: 0; transform: scale(0.92) translateY(10px); }
- to { opacity: 1; transform: scale(1) translateY(0); }
+ from {
+ opacity: 0;
+ transform: scale(0.92) translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
}
+
.consult-modal-icon {
width: 68px;
height: 68px;
@@ -2222,6 +2273,7 @@ watch([() => route.params], () => {
align-items: center;
justify-content: center;
}
+
.consult-modal-title {
font-size: 20px;
font-weight: 800;
@@ -2230,6 +2282,7 @@ watch([() => route.params], () => {
line-height: 1.4;
word-break: keep-all;
}
+
.consult-modal-desc {
font-size: 15px;
color: #64748b;
@@ -2237,6 +2290,7 @@ watch([() => route.params], () => {
line-height: 1.5;
word-break: keep-all;
}
+
.consult-modal-btn {
width: 100%;
padding: 15px;
@@ -2250,6 +2304,7 @@ watch([() => route.params], () => {
box-shadow: 0 8px 18px rgba(255, 94, 0, 0.3);
transition: all 0.2s ease;
}
+
.consult-modal-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(255, 94, 0, 0.4);
diff --git a/vite.config.js b/vite.config.js
index 81c6f68..505e7d0 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -27,7 +27,7 @@ export default defineConfig(({ isPreview }) => ({
noExternal: true
},
build: {
- emptyOutDir: false
+ emptyOutDir: true
},
define: {
__BUILD_DATE__: JSON.stringify(new Date().toISOString().slice(0, 10))