generated from ttipo/project-base
feat(backend): 한국문화정보원 전시 동기화 추가
전시는 관광공사·KOPIS 어디에도 없다. 한국문화정보원 한눈에보는문화정보(대구 90일 전시 13건)를 place:sync -- kcisa 로 매일 받는다. - 분야가 '전시'인 것만 받는다(공연은 KOPIS 와 겹친다) - 관광공사·KOPIS 에 이름 비교 키가 같고 기간이 겹치는 행사가 있으면 넣지 않는다(event-name.ts) - 상세는 새 전시와 7일 지난 전시만(원본에 수정일이 없다), 좌표는 목록의 gpsY(위도)·gpsX(경도) - 이미지는 이용 조건(공공누리 1유형 여부)을 확인하기 전이라 저장하지 않는다 - htmlToText 가 따옴표·가운뎃점·번호 엔티티도 푼다 — 제목이 &lsquo; 처럼 이중 인코딩되어 온다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,21 @@ describe('htmlToText', () => {
|
||||
expect(htmlToText('A & B <안내>')).toBe('A & B <안내>');
|
||||
});
|
||||
|
||||
it('따옴표·가운뎃점 같은 이름 엔티티와 번호 엔티티도 되돌린다', () => {
|
||||
expect(
|
||||
htmlToText('방짜유기박물관 ‘놋’ 특별전 · 체험'),
|
||||
).toBe('방짜유기박물관 ‘놋’ 특별전 · 체험');
|
||||
expect(htmlToText('“빛”')).toBe('“빛”');
|
||||
});
|
||||
|
||||
it('한 번만 되돌린다 — &lt; 는 < 로 남는다', () => {
|
||||
expect(htmlToText('a &lt; b')).toBe('a < b');
|
||||
});
|
||||
|
||||
it('모르는 엔티티·잘못된 번호는 그대로 둔다', () => {
|
||||
expect(htmlToText('&unknown; �')).toBe('&unknown; �');
|
||||
});
|
||||
|
||||
it('빈 값·공백뿐인 값은 null 이다', () => {
|
||||
expect(htmlToText('')).toBeNull();
|
||||
expect(htmlToText(' <br> ')).toBeNull();
|
||||
|
||||
@@ -3,15 +3,38 @@
|
||||
* 관광공사 응답에는 `<br>`, `<a href=…>` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
|
||||
*/
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
/** 이름 엔티티 — 한국문화정보원 제목에 `&lsquo;` 처럼 한 번 더 인코딩된 따옴표·가운뎃점이 온다(2026-11-03) */
|
||||
const NAMED_ENTITIES: Record<string, string> = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: ' ',
|
||||
lsquo: '‘',
|
||||
rsquo: '’',
|
||||
ldquo: '“',
|
||||
rdquo: '”',
|
||||
middot: '·',
|
||||
hellip: '…',
|
||||
ndash: '–',
|
||||
mdash: '—',
|
||||
lang: '〈',
|
||||
rang: '〉',
|
||||
};
|
||||
const MAX_CODE_POINT = 0x10ffff;
|
||||
|
||||
/** 엔티티 하나를 글자로. 모르는 이름·잘못된 번호는 그대로 둔다 */
|
||||
function decodeEntity(match: string, body: string): string {
|
||||
if (body.startsWith('#')) {
|
||||
const hex = body[1] === 'x' || body[1] === 'X';
|
||||
const code = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
|
||||
return Number.isInteger(code) && code > 0 && code <= MAX_CODE_POINT
|
||||
? String.fromCodePoint(code)
|
||||
: match;
|
||||
}
|
||||
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 흔적을 지운 일반 텍스트를 돌려준다.
|
||||
@@ -23,7 +46,7 @@ export function htmlToText(value: unknown): string | null {
|
||||
.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(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, decodeEntity)
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
Reference in New Issue
Block a user