generated from ttipo/project-base
feat(backend): 관광공사 장소·반려동물 동기화 명령 추가
`npm run place:sync -- tour-kor|tour-pet|status` 로 관광공사 국문 장소와 반려동물 동반 정보를 장소 DB 에 적재한다. API 서버가 아니라 CLI(운영은 cron)로 돈다. - tour-kor: 시·도 지역 목록 전체 → place upsert → 상세(개요·영업시간·요금·주차). 개발 계정 한도(오퍼레이션별 하루 1,000건)에 닿으면 멈추고 detail_synced_at 으로 다음 실행이 이어 받는다. 서버의 한도 초과 응답(코드 22)도 정상 멈춤으로 본다. 수정일이 바뀐 장소만 상세를 다시 받고, 목록에서 사라진 장소는 숨긴다 - tour-pet: detailPetTour2 전체(약 10회)를 받아 전구역/일부구역으로 요약 - 분류 대응을 「신분류체계 연계 정의서」 소분류 240개로 확정하고 'other' 분류 추가 - 키는 어떤 오류 메시지에도 남기지 않는다(원문·URL 인코딩 모두 가림) CHECK 제약 변경은 migration:generate 가 감지하지 못해 마이그레이션에 직접 적었다. 2026-11-02 대구 실측: 목록 1,193건 → 장소 1,174곳, 상세 535곳(영업시간 해석 92%), 반려동물 정보 288곳(그중 쇼핑 매장 275곳). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,12 @@ describe('validateEnv', () => {
|
||||
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('관광공사 API 하루 한도가 숫자가 아니면 실패한다', () => {
|
||||
expect(() =>
|
||||
validateEnv({ NODE_ENV: 'development', TOUR_API_DAILY_LIMIT: '천' }),
|
||||
).toThrow(/TOUR_API_DAILY_LIMIT 는 양의 정수/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('운영 환경', () => {
|
||||
|
||||
@@ -24,6 +24,8 @@ const POSITIVE_INT_KEYS = [
|
||||
'DB_PORT',
|
||||
'THROTTLE_TTL_MS',
|
||||
'THROTTLE_LIMIT',
|
||||
// 관광공사 API 오퍼레이션별 하루 호출 한도(장소 동기화 명령). 개발 계정 1,000 — 운영 계정 승인 후 올린다
|
||||
'TOUR_API_DAILY_LIMIT',
|
||||
] as const;
|
||||
|
||||
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { extractUrl, htmlToText } from './html-text';
|
||||
|
||||
describe('htmlToText', () => {
|
||||
it('줄바꿈 태그는 줄바꿈으로, 나머지 태그는 지운다', () => {
|
||||
expect(htmlToText('주차 가능<br>소형 20대<br />대형 2대')).toBe(
|
||||
'주차 가능\n소형 20대\n대형 2대',
|
||||
);
|
||||
expect(htmlToText('<strong>무료</strong> 입장')).toBe('무료 입장');
|
||||
});
|
||||
|
||||
it('자주 쓰는 HTML 엔티티를 되돌린다', () => {
|
||||
expect(htmlToText('A & B <안내>')).toBe('A & B <안내>');
|
||||
});
|
||||
|
||||
it('빈 값·공백뿐인 값은 null 이다', () => {
|
||||
expect(htmlToText('')).toBeNull();
|
||||
expect(htmlToText(' <br> ')).toBeNull();
|
||||
expect(htmlToText(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUrl', () => {
|
||||
it('a 태그의 href 를 꺼낸다', () => {
|
||||
expect(
|
||||
extractUrl(
|
||||
'<a href="https://tour.daegu.go.kr" target="_blank" title="새창">tour.daegu.go.kr</a>',
|
||||
),
|
||||
).toBe('https://tour.daegu.go.kr');
|
||||
});
|
||||
|
||||
it('태그 없이 주소만 있으면 그대로, www 로 시작하면 https 를 붙인다', () => {
|
||||
expect(extractUrl('http://example.kr/a')).toBe('http://example.kr/a');
|
||||
expect(extractUrl('www.example.kr')).toBe('https://www.example.kr');
|
||||
});
|
||||
|
||||
it('http(s) 가 아닌 링크는 버린다', () => {
|
||||
expect(extractUrl('<a href="javascript:alert(1)">x</a>')).toBeNull();
|
||||
expect(extractUrl('홈페이지 없음')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 공공데이터 텍스트 정리 유틸 (순수 함수).
|
||||
* 관광공사 응답에는 `<br>`, `<a href=…>` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
|
||||
*/
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
};
|
||||
|
||||
/**
|
||||
* HTML 흔적을 지운 일반 텍스트를 돌려준다.
|
||||
* `<br>`·`</p>` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null.
|
||||
*/
|
||||
export function htmlToText(value: unknown): string | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||
const text = String(value)
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => ENTITIES[m] ?? m)
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.trim();
|
||||
return text === '' ? null : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 홈페이지 값에서 URL 만 꺼낸다. 관광공사는 `<a href="…" …>…</a>` 를 통째로 준다.
|
||||
* http(s) 가 아닌 값은 버린다(javascript: 등). `www.` 로 시작하면 https 를 붙인다.
|
||||
*/
|
||||
export function extractUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const href = /href\s*=\s*["']([^"']+)["']/i.exec(value)?.[1];
|
||||
const raw = (
|
||||
href ?? /(https?:\/\/[^\s"'<>]+|www\.[^\s"'<>]+)/i.exec(value)?.[1]
|
||||
)?.trim();
|
||||
if (!raw) return null;
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
if (/^www\./i.test(raw)) return `https://${raw}`;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { kstStartOfDay, parseKstTimestamp } from './kst';
|
||||
|
||||
describe('kstStartOfDay', () => {
|
||||
it('한국 날짜의 0시를 돌려준다', () => {
|
||||
// 2026-11-02 14:00 KST = 05:00 UTC → 그날 0시 KST = 11-01 15:00 UTC
|
||||
expect(kstStartOfDay(new Date('2026-11-02T05:00:00Z')).toISOString()).toBe(
|
||||
'2026-11-01T15:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('UTC 로는 전날이어도 한국 날짜가 바뀌었으면 새 날로 본다', () => {
|
||||
// 2026-11-02 00:30 KST = 11-01 15:30 UTC
|
||||
expect(kstStartOfDay(new Date('2026-11-01T15:30:00Z')).toISOString()).toBe(
|
||||
'2026-11-01T15:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseKstTimestamp', () => {
|
||||
it('14자리 한국 시간을 해석한다', () => {
|
||||
expect(parseKstTimestamp('20261102143000')?.toISOString()).toBe(
|
||||
'2026-11-02T05:30:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('8자리 날짜는 그날 0시(KST)로 본다', () => {
|
||||
expect(parseKstTimestamp('20261102')?.toISOString()).toBe(
|
||||
'2026-11-01T15:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('형식이 다르거나 없는 날짜면 null', () => {
|
||||
expect(parseKstTimestamp('2026-11-02')).toBeNull();
|
||||
expect(parseKstTimestamp('20260230')).toBeNull();
|
||||
expect(parseKstTimestamp('')).toBeNull();
|
||||
expect(parseKstTimestamp(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 한국 시간(KST, UTC+9) 유틸 (순수 함수). 한국은 서머타임이 없어 고정 오프셋으로 계산한다.
|
||||
*/
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/** 주어진 시각이 속한 한국 날짜의 0시(KST)를 Date 로 돌려준다 */
|
||||
export function kstStartOfDay(now: Date): Date {
|
||||
const kst = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
const midnightUtc = Date.UTC(
|
||||
kst.getUTCFullYear(),
|
||||
kst.getUTCMonth(),
|
||||
kst.getUTCDate(),
|
||||
);
|
||||
return new Date(midnightUtc - KST_OFFSET_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 'YYYYMMDD' 또는 'YYYYMMDDhhmmss'(한국 시간) 문자열을 Date 로 바꾼다.
|
||||
* 관광공사 modifiedtime·createdtime 형식이다. 형식이 다르거나 없는 날짜면 null.
|
||||
*/
|
||||
export function parseKstTimestamp(value: unknown): Date | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2}))?$/.exec(
|
||||
String(value).trim(),
|
||||
);
|
||||
if (!m) return null;
|
||||
const [y, mo, d, h, mi, s] = m.slice(1).map((v) => Number(v ?? 0));
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(Date.UTC(y, mo - 1, d, h, mi, s) - KST_OFFSET_MS);
|
||||
// 2월 30일처럼 넘어가는 날짜는 거른다
|
||||
const back = new Date(date.getTime() + KST_OFFSET_MS);
|
||||
return back.getUTCDate() === d ? date : null;
|
||||
}
|
||||
Reference in New Issue
Block a user