This commit is contained in:
2026-11-10 12:35:16 +09:00
parent 4ada42bacc
commit a51616f330
12 changed files with 451 additions and 203 deletions
+1 -1
View File
@@ -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)"
+18
View File
@@ -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
-6
View File
@@ -12,10 +12,4 @@
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://teacherpick.kr/daegu/suseong-gu/hwanggeum-dong/middle/math</loc>
<lastmod>2026-11-10</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>
+13 -11
View File
@@ -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}개 시·도 분할 사이트맵 생성 완료!`);
+57 -20
View File
@@ -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);
+4 -19
View File
@@ -18,31 +18,16 @@
<router-link to="/regions">전국 지역별 과외</router-link>
<a href="#subjects">과목 라인업</a>
<a href="#features">기능 안내</a>
<a href="#media">미디어 쇼케이스</a>
<a href="#cta">무료 상담 신청</a>
</div>
<div class="link-col">
<h4>고객지원</h4>
<a href="#">이용약관</a>
<a href="#">개인정보처리방침</a>
<a href="#" @click.prevent="showModal = true">문의하기</a>
<a href="#cta">1:1 학습 진단</a>
<a href="#cta">방문과외 상담</a>
</div>
</div>
</div>
<!-- 사용자 화면에서는 보이지 않고 검색엔진 봇(크롤러)만 읽을 수 있도록 처리 -->
<div class="container footer-biz-info sr-only">
<p class="biz-line">
<span>상호명: 티처픽</span>
<span class="divider">|</span>
<span>대표자: 이지훈</span>
<span class="divider">|</span>
<span>사업자등록번호: 767-16-02607</span>
</p>
<p class="biz-line">
<span>주소: 대구시 동구 화랑로 80길 8</span>
<span class="divider">|</span>
<span>고객센터: <a href="tel:010-8204-9445" class="biz-link">010-8204-9445</a></span>
</p>
</div>
<!-- 사업자 정보는 네이버 숨김 텍스트(1px CSS) 감점 방지를 위해 <body> 대신 <head> 의 meta[name="business-info"] 및 Organization JSON-LD 로 검색봇에 표준 제공됨 -->
<div class="container copyright">
<p>© 2026 티처픽 (Educo Pick). All rights reserved.</p>
</div>
+120
View File
@@ -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(
+28 -2
View File
@@ -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];
}
+13 -1
View File
@@ -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 상태코드 반환)
+2 -3
View File
@@ -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 });
+194 -139
View File
@@ -4,9 +4,10 @@
<main id="top" class="post-shell container">
<!-- 상단 뱃지 -->
<div class="post-badges">
<span class="badge-item loc">[지역] {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong
}}</span>
<span class="badge-item cat">[분류] {{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 · {{ currentTarget.name }} {{ currentSubject.name }} 과외</span>
<router-link to="/regions" class="badge-item loc">[지역] {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{
regionInfo.dong }}</router-link>
<router-link :to="oppositeTargetUrl" class="badge-item cat">[분류] {{ regionInfo.sidoShort }} {{
currentTarget.name }} 과외 · {{ currentTarget.name }} {{ currentSubject.name }} 과외</router-link>
</div>
<!-- 1. 메인 헤더 타이틀 (h1) + 메타 키워드 태그 바 + 소제목 (h2) + 일반 본문 글 -->
@@ -16,9 +17,11 @@
<!-- 메타 키워드 태그 영역 (h1 바로 다음 배치 - 광역/구/동 키워드 매칭) -->
<div class="post-meta-keywords" aria-label="핵심 검색 키워드">
<span class="meta-kw-chip primary">#{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외</span>
<span class="meta-kw-chip primary">#{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name
}} 과외</span>
<span class="meta-kw-chip primary">#{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외</span>
<span class="meta-kw-chip">#{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ currentTarget.name }} {{ currentSubject.name }} 과외</span>
<span class="meta-kw-chip">#{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ currentTarget.name }} {{
currentSubject.name }} 과외</span>
<span class="meta-kw-chip">#{{ regionInfo.sigungu }} {{ currentTarget.name }} 과외</span>
<span class="meta-kw-chip">#{{ regionInfo.dong }} {{ currentTarget.name }} {{ currentSubject.name }} 과외</span>
<span class="meta-kw-chip">#{{ regionInfo.dong }} {{ currentTarget.name }} 과외</span>
@@ -26,15 +29,20 @@
</div>
<!-- 소제목 (h2) 및 일반 소개 본문 글 -->
<h2 class="post-lead-h2">{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 및 {{ regionInfo.sigungu }} {{ regionInfo.dong }} 1:1 맞춤 내신·수능 전문 지도</h2>
<h2 class="post-lead-h2">{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 및 {{
regionInfo.sigungu }} {{ regionInfo.dong }} 1:1 맞춤 내신·수능 전문 지도</h2>
<p class="post-lead-text">
<strong>{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외</strong>와 <strong>{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외</strong>를 찾으시는 학생·학부모님을 위해, {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} 인근 학교별 내신 기출 분석과 검증된 베테랑 선생님의 1:1 방문·맞춤 수업 커리큘럼 및 무료 모의수업 상담 정보를 한눈에 비교해 드립니다.
<strong>{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외</strong>와 <strong>{{
regionInfo.sidoShort }} {{ currentTarget.name }} 과외</strong>를 찾으시는 학생·학부모님을 위해, {{ regionInfo.sidoShort }}
{{ regionInfo.sigungu }} {{ regionInfo.dong }} 인근({{ targetSchools.join(', ') }}) 학교별 내신 기출 분석과 <strong>{{
activeCurriculum }}</strong> 중심의 1:1 방문·맞춤 수업 커리큘럼 및 무료 모의수업 상담 정보를 한눈에 비교해 드립니다.
</p>
</header>
<!-- 빠른 상담 액션 버튼 2종 -->
<div class="post-action-row">
<a class="post-action orange" href="#consult-form" :style="actionBtnHeight ? { minHeight: actionBtnHeight } : {}">
<a class="post-action orange" href="#consult-form"
:style="actionBtnHeight ? { minHeight: actionBtnHeight } : {}">
<span class="action-tag">상담</span>
<div>
<strong>1:1 무료 모의수업 상담 받기</strong>
@@ -163,9 +171,13 @@
<!-- 대표 지역 요약 데이터 리스트 -->
<section class="post-summary">
<div>
<h2>{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 · {{ regionInfo.sigungu }} {{ regionInfo.dong }} 맞춤 학습 안내</h2>
<p>{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} 일대에서 {{ currentTarget.name }} {{
currentSubject.name }} 과외, 내신 관리·수능 대비 등 핵심 키워드를 기준으로 인근 학교 기출 분석과 베테랑 선생님 카드를 정리했습니다.</p>
<h2>{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 · {{ regionInfo.sigungu
}} {{ regionInfo.dong }} 맞춤 학습 안내</h2>
<p>{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ regionInfo.sigungu
}} {{ regionInfo.dong }} 일대(활동 선생님 {{ regionInfo.teachersCount }}명 · 누적 상담 {{ regionInfo.consultCount }}건)에서
{{ currentTarget.name }} {{
currentSubject.name }} 과외({{ currentSubject.headline }})를 중심으로 {{ targetSchools.join(', ') }} 기출 분석과 베테랑 선생님
카드를 정리했습니다.</p>
</div>
<dl>
<div>
@@ -174,11 +186,12 @@
</div>
<div>
<dt>대표 과목</dt>
<dd>{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외</dd>
<dd>{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 ({{ currentSubject.tag
}})</dd>
</div>
<div>
<dt>핵심 키워드</dt>
<dd>{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 · 1:1 맞춤과외</dd>
<dd>{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 · {{ currentTarget.label }}</dd>
</div>
</dl>
</section>
@@ -192,11 +205,13 @@
<router-link class="pclink"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/${sub.slug}`">
<div class="pcimg-wrap">
<img :src="sub.image" :alt="`${regionInfo.sidoShort} ${regionInfo.sigungu} ${regionInfo.dong} ${currentTarget.name} ${sub.name} 과외 1:1 맞춤 지도`"
<img :src="sub.image"
:alt="`${regionInfo.sidoShort} ${regionInfo.sigungu} ${regionInfo.dong} ${currentTarget.name} ${sub.name} 과외 1:1 맞춤 지도`"
width="500" height="500" class="pcimg" itemprop="contentUrl" />
<span v-if="sub.slug === currentSubject.slug" class="selected-badge">선택됨</span>
</div>
<div class="pcname" itemprop="caption">{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ sub.name }} 과외</div>
<div class="pcname" itemprop="caption">{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ sub.name }}
과외</div>
</router-link>
</div>
</div>
@@ -210,21 +225,26 @@
</div>
<div class="place-grid">
<div v-for="(nearDong, idx) in rankPlaceList" :key="nearDong" class="place-card">
<div v-for="(card, idx) in rankPlaceCards" :key="`${card.sigunguSlug}-${card.dongSlug}-${idx}`"
class="place-card">
<div class="place-top-bar">
<span class="place-rank-badge">추천 0{{ idx + 1 }}</span>
<span class="place-cat-tag">교육서비스 · 1:1 맞춤과외</span>
</div>
<div class="place-main">
<h3>{{ getPlaceCardTitle(nearDong, idx) }}</h3>
<p class="place-addr">{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ nearDong }}</p>
<p class="place-kw">{{ getPlaceCardKeywords(idx) }}</p>
<p class="place-desc">{{ getPlaceCardDesc(nearDong, idx) }}</p>
<h3>
<router-link :to="getPlaceCardUrl(card)" class="place-title-link">
{{ getPlaceCardTitle(card, idx) }}
</router-link>
</h3>
<p class="place-addr">{{ card.sidoShort }} {{ card.sigungu }} {{ card.dong }}</p>
<p class="place-kw">{{ getPlaceCardKeywords(card, idx) }}</p>
<p class="place-desc">{{ getPlaceCardDesc(card, idx) }}</p>
</div>
<div class="place-links">
<a :href="`https://map.naver.com/v5/search/${encodeURIComponent(regionInfo.sidoShort + ' ' + regionInfo.sigungu + ' ' + nearDong + ' ' + currentSubject.name + ' 과외')}`"
<a :href="`https://map.naver.com/v5/search/${encodeURIComponent(card.sidoShort + ' ' + card.sigungu + ' ' + card.dong + ' ' + currentSubject.name + ' 과외')}`"
target="_blank" rel="noopener noreferrer nofollow" class="map-link naver">네이버지도</a>
<a :href="`https://www.google.com/maps/search/${encodeURIComponent(regionInfo.sidoShort + ' ' + regionInfo.sigungu + ' ' + nearDong + ' ' + currentSubject.name + ' 과외')}`"
<a :href="`https://www.google.com/maps/search/${encodeURIComponent(card.sidoShort + ' ' + card.sigungu + ' ' + card.dong + ' ' + currentSubject.name + ' 과외')}`"
target="_blank" rel="noopener noreferrer nofollow" class="map-link google">구글지도</a>
</div>
</div>
@@ -244,11 +264,12 @@
<h3>{{ school }}</h3>
</div>
<p class="school-desc">
{{ school }} {{ currentSubject.name }} 과목 최근 3개년 기출 족보 및 서술형 킬러 문항 1:1 밀착 클리닉 제공
{{ school }} {{ currentTarget.name }} {{ currentSubject.name }} 최근 3개년 기출 족보 분석 및 {{
currentSubject.points[index % currentSubject.points.length] }} 중심 1:1 밀착 클리닉 제공
</p>
<div class="school-tags">
<span>#{{ currentSubject.name }}내신대비</span>
<span>#기출분석</span>
<span>#{{ currentTarget.name }}기출분석</span>
<span>#수행평가지도</span>
</div>
</div>
@@ -263,78 +284,47 @@
</div>
<div class="faq-accordion-wrap">
<div v-for="(faq, fIdx) in faqs" :key="fIdx" class="faq-row">
<button type="button" class="faq-button" :class="{ open: openFaqIndex === fIdx }" @click="toggleFaq(fIdx)">
{{ faq.q }}
<span class="faq-arrow">{{ openFaqIndex === fIdx ? '▲' : '▼' }}</span>
</button>
<div v-show="openFaqIndex === fIdx" class="faq-panel">
<details v-for="(faq, fIdx) in faqs" :key="fIdx" class="faq-row" :open="fIdx === 0">
<summary class="faq-button">
<span>{{ faq.q }}</span>
<span class="faq-arrow">▼</span>
</summary>
<div class="faq-panel">
<p>{{ faq.a }}</p>
</div>
</div>
</details>
</div>
</section>
<!-- 하단 Pager 내부 링크 클러스터 (주석 처리)
<nav class="pager" aria-label="지역 및 과목별 과외 페이지 이동">
<div class="pager-group">
<strong class="pager-group-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} 학년·과목별 과외 바로가기</strong>
<div class="pager-links-wrap">
<template v-for="t in TARGETS" :key="t.slug">
<router-link v-for="sub in SUBJECTS" :key="`${t.slug}-${sub.id}`"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${t.slug}/${sub.slug}`"
class="pager-link" :class="{ active: t.slug === currentTarget.slug && sub.slug === currentSubject.slug }">
{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ t.name }} {{ sub.name }} 과외
</router-link>
</template>
</div>
</div>
<div v-if="relatedLinks.dongsInSigungu.length > 1" class="pager-group">
<strong class="pager-group-title">{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} 내 다른 동 {{ currentTarget.name }} {{ currentSubject.name }} 과외</strong>
<div class="pager-links-wrap">
<router-link v-for="d in relatedLinks.dongsInSigungu" :key="d.dongSlug"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${d.dongSlug}/${currentTarget.slug}/${currentSubject.slug}`"
class="pager-link" :class="{ active: d.dongSlug === regionInfo.dongSlug }">
{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ d.name }} {{ currentTarget.name }} {{ currentSubject.name }} 과외
</router-link>
</div>
</div>
<div v-if="relatedLinks.sigungusInSido.length > 1" class="pager-group">
<strong class="pager-group-title">{{ regionInfo.sidoShort }} 주요 시·군·구 {{ currentTarget.name }} {{ currentSubject.name }} 과외</strong>
<div class="pager-links-wrap">
<router-link v-for="sg in relatedLinks.sigungusInSido" :key="sg.sigunguSlug"
:to="`/${regionInfo.sidoSlug}/${sg.sigunguSlug}/${sg.repDongSlug}/${currentTarget.slug}/${currentSubject.slug}`"
class="pager-link" :class="{ active: sg.sigunguSlug === regionInfo.sigunguSlug }">
{{ regionInfo.sidoShort }} {{ sg.name }} {{ currentTarget.name }} {{ currentSubject.name }} 과외
</router-link>
</div>
</div>
<router-link v-if="isAdmin" to="/regions" class="pager-link pager-next">전국 지역 목록 보기 →</router-link>
</nav>
-->
<!-- 하단 가로형 과목 이미지 캐러셀 (네이버 검색 결과 가로 멀티 썸네일 노출 최적화) -->
<section class="bottom-carousel-section" itemscope itemtype="https://schema.org/ImageGallery" aria-label="과목별 과외 가로 이미지 캐러셀">
<section class="bottom-carousel-section" itemscope itemtype="https://schema.org/ImageGallery"
aria-label="과목별 과외 가로 이미지 캐러셀">
<div class="section-heading compact">
<span>Carousel Gallery</span>
<h2>{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} {{ currentTarget.name }} 과외 과목별 커리큘럼 갤러리</h2>
<h2>{{ regionInfo.sidoShort }} {{ regionInfo.sigungu }} {{ regionInfo.dong }} {{ currentTarget.name }} 과외 과목별
커리큘럼 갤러리</h2>
</div>
<p class="carousel-guide-text">
{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ currentSubject.name }} 과외 베테랑 선생님의 5대 과목 핵심 커리큘럼입니다.
{{ regionInfo.sidoShort }} {{ currentTarget.name }} 과외 및 {{ regionInfo.sidoShort }} {{ currentTarget.name }}
{{ currentSubject.name }} 과외 베테랑 선생님의 5대 과목 핵심 커리큘럼입니다.
</p>
<div class="horizontal-carousel-track">
<article v-for="sub in SUBJECTS" :key="`carousel-${sub.id}`" class="h-carousel-card" :class="{ current: sub.slug === currentSubject.slug }" itemprop="associatedMedia" itemscope itemtype="https://schema.org/ImageObject">
<router-link :to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/${sub.slug}`" class="h-carousel-link">
<article v-for="sub in SUBJECTS" :key="`carousel-${sub.id}`" class="h-carousel-card"
:class="{ current: sub.slug === currentSubject.slug }" itemprop="associatedMedia" itemscope
itemtype="https://schema.org/ImageObject">
<router-link
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/${sub.slug}`"
class="h-carousel-link">
<div class="h-carousel-img-box">
<img :src="sub.image" :alt="`${regionInfo.sidoShort} ${regionInfo.sigungu} ${regionInfo.dong} ${currentTarget.name} ${sub.name} 과외 (${regionInfo.sidoShort} ${currentTarget.name} 과외)`" width="500" height="500" itemprop="contentUrl" />
<img :src="sub.image"
:alt="`${regionInfo.sidoShort} ${regionInfo.sigungu} ${regionInfo.dong} ${currentTarget.name} ${sub.name} 과외 (${regionInfo.sidoShort} ${currentTarget.name} 과외)`"
width="500" height="500" itemprop="contentUrl" />
<span class="h-carousel-badge">{{ sub.tag }}</span>
</div>
<div class="h-carousel-body">
<h3 itemprop="name">{{ regionInfo.sidoShort }} {{ currentTarget.name }} {{ sub.name }} 과외</h3>
<p itemprop="description">{{ regionInfo.dong }} {{ currentTarget.name }} {{ sub.name }} {{ sub.headline }}</p>
<p itemprop="description">{{ regionInfo.dong }} {{ currentTarget.name }} {{ sub.name }} {{ sub.headline
}}</p>
</div>
</router-link>
</article>
@@ -345,32 +335,47 @@
<section class="post-resource-links" aria-label="과목별 과외 페이지 바로가기">
<router-link class="post-resource-link" :class="{ active: currentSubject.slug === 'korean' }"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/korean`">
<span class="post-resource-thumb"><img src="/img/pick-korean.png" :alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 국어 과외 상담`" width="500" height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 국어 과외 내신/수능 1등급 클리닉</strong>
<span class="post-resource-thumb"><img src="/img/pick-korean.png"
:alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 국어 과외 상담`" width="500"
height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
국어 과외 내신/수능 1등급 클리닉</strong>
<em class="post-resource-arrow">›</em>
</router-link>
<router-link class="post-resource-link" :class="{ active: currentSubject.slug === 'english' }"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/english`">
<span class="post-resource-thumb"><img src="/img/pick-english.png" :alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 영어 과외 상담`" width="500" height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 영어 과외 구문 독해 &amp; 빈칸추론</strong>
<span class="post-resource-thumb"><img src="/img/pick-english.png"
:alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 영어 과외 상담`" width="500"
height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
영어 과외 구문 독해 &amp; 빈칸추론</strong>
<em class="post-resource-arrow">›</em>
</router-link>
<router-link class="post-resource-link" :class="{ active: currentSubject.slug === 'math' }"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/math`">
<span class="post-resource-thumb"><img src="/img/pick-math.png" :alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 수학 과외 상담`" width="500" height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 수학 과외 개념 정복 및 서술형 대비</strong>
<span class="post-resource-thumb"><img src="/img/pick-math.png"
:alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 수학 과외 상담`" width="500"
height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
수학 과외 개념 정복 및 서술형 대비</strong>
<em class="post-resource-arrow">›</em>
</router-link>
<router-link class="post-resource-link" :class="{ active: currentSubject.slug === 'social' }"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/social`">
<span class="post-resource-thumb"><img src="/img/pick-social.png" :alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 사회 과외 상담`" width="500" height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 사회·한국사 과외 내신 단기 완성</strong>
<span class="post-resource-thumb"><img src="/img/pick-social.png"
:alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 사회 과외 상담`" width="500"
height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
사회·한국사 과외 내신 단기 완성</strong>
<em class="post-resource-arrow">›</em>
</router-link>
<router-link class="post-resource-link" :class="{ active: currentSubject.slug === 'science' }"
:to="`/${regionInfo.sidoSlug}/${regionInfo.sigunguSlug}/${regionInfo.dongSlug}/${currentTarget.slug}/science`">
<span class="post-resource-thumb"><img src="/img/pick-science.png" :alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 과학 과외 상담`" width="500" height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }} 과학·통합과학 과외 탐구영역 완성</strong>
<span class="post-resource-thumb"><img src="/img/pick-science.png"
:alt="`${regionInfo.sidoShort} ${regionInfo.dong} ${currentTarget.name} 과학 과외 상담`" width="500"
height="500"></span>
<strong class="post-resource-title">{{ regionInfo.sidoShort }} {{ regionInfo.dong }} {{ currentTarget.name }}
과학·통합과학 과외 탐구영역 완성</strong>
<em class="post-resource-arrow">›</em>
</router-link>
</section>
@@ -382,7 +387,8 @@
<div v-if="isModalOpen" class="consult-modal-overlay" @click.self="isModalOpen = false">
<div class="consult-modal-card">
<div class="consult-modal-icon">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="#FF5E00" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="#FF5E00" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
@@ -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);
+1 -1
View File
@@ -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))