feat(backend): 한국문화정보원 전시 동기화 추가

전시는 관광공사·KOPIS 어디에도 없다. 한국문화정보원 한눈에보는문화정보(대구 90일 전시 13건)를
place:sync -- kcisa 로 매일 받는다.

- 분야가 '전시'인 것만 받는다(공연은 KOPIS 와 겹친다)
- 관광공사·KOPIS 에 이름 비교 키가 같고 기간이 겹치는 행사가 있으면 넣지 않는다(event-name.ts)
- 상세는 새 전시와 7일 지난 전시만(원본에 수정일이 없다), 좌표는 목록의 gpsY(위도)·gpsX(경도)
- 이미지는 이용 조건(공공누리 1유형 여부)을 확인하기 전이라 저장하지 않는다
- htmlToText 가 따옴표·가운뎃점·번호 엔티티도 푼다 — 제목이 ‘ 처럼 이중 인코딩되어 온다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 23:02:15 +09:00
parent 94c8693dad
commit 175e2d454b
12 changed files with 1109 additions and 10 deletions
@@ -18,6 +18,21 @@ describe('htmlToText', () => {
expect(htmlToText('A &amp; B &lt;안내&gt;')).toBe('A & B <안내>');
});
it('따옴표·가운뎃점 같은 이름 엔티티와 번호 엔티티도 되돌린다', () => {
expect(
htmlToText('방짜유기박물관 &lsquo;놋&rsquo; 특별전 &middot; 체험'),
).toBe('방짜유기박물관 ‘놋’ 특별전 · 체험');
expect(htmlToText('&#8220;빛&#x201D;')).toBe('“빛”');
});
it('한 번만 되돌린다 — &amp;lt; 는 &lt; 로 남는다', () => {
expect(htmlToText('a &amp;lt; b')).toBe('a &lt; b');
});
it('모르는 엔티티·잘못된 번호는 그대로 둔다', () => {
expect(htmlToText('&unknown; &#99999999;')).toBe('&unknown; &#99999999;');
});
it('빈 값·공백뿐인 값은 null 이다', () => {
expect(htmlToText('')).toBeNull();
expect(htmlToText(' <br> ')).toBeNull();
+32 -9
View File
@@ -3,15 +3,38 @@
* 관광공사 응답에는 `<br>`, `<a href=…>` 같은 HTML 이 섞여 온다(개요·주차·홈페이지 등).
*/
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
'&nbsp;': ' ',
/** 이름 엔티티 — 한국문화정보원 제목에 `&amp;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')