Skip to content

Commit c483b32

Browse files
committed
fix: decode KDCA press release entities
1 parent 0221360 commit c483b32

5 files changed

Lines changed: 116 additions & 25 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { decodeHtmlEntities } from './decode-html-entities';
3+
4+
describe('decodeHtmlEntities', () => {
5+
it('should decode named, decimal, and hexadecimal entities', () => {
6+
expect(decodeHtmlEntities('&lt;경보&nbsp;&#9312;&#x2461;&gt;')).toBe('<경보 ①②>');
7+
});
8+
9+
it('should decode escaped entities up to max passes', () => {
10+
expect(decodeHtmlEntities('&amp;#9312;')).toBe('&#9312;');
11+
expect(decodeHtmlEntities('&amp;#9312;', { maxPasses: 2 })).toBe('①');
12+
});
13+
14+
it('should keep unknown and invalid entities unchanged', () => {
15+
expect(decodeHtmlEntities('&unknown; &#9999999999;')).toBe('&unknown; &#9999999999;');
16+
});
17+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
const NAMED_ENTITIES = new Map<string, string>([
2+
['nbsp', ' '],
3+
['lt', '<'],
4+
['gt', '>'],
5+
['amp', '&'],
6+
['quot', '"'],
7+
['apos', "'"],
8+
]);
9+
10+
type DecodeHtmlEntitiesOptions = {
11+
maxPasses?: number;
12+
};
13+
14+
export function decodeHtmlEntities(value: string, options: DecodeHtmlEntitiesOptions = {}): string {
15+
const maxPasses = Math.max(1, options.maxPasses ?? 1);
16+
let decoded = value;
17+
18+
for (let index = 0; index < maxPasses; index += 1) {
19+
const next = decodeHtmlEntitiesOnce(decoded);
20+
if (next === decoded) {
21+
return next;
22+
}
23+
decoded = next;
24+
}
25+
26+
return decoded;
27+
}
28+
29+
function decodeHtmlEntitiesOnce(value: string): string {
30+
return value
31+
.replace(/&#x([0-9a-fA-F]+);/g, (full, hex: string) => {
32+
const codePoint = Number.parseInt(hex, 16);
33+
return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : full;
34+
})
35+
.replace(/&#(\d+);/g, (full, num: string) => {
36+
const codePoint = Number.parseInt(num, 10);
37+
return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : full;
38+
})
39+
.replace(/&([a-zA-Z]+);/g, (full, name: string) => NAMED_ENTITIES.get(name) ?? full);
40+
}
41+
42+
function isValidCodePoint(value: number): boolean {
43+
return Number.isInteger(value) && value >= 0 && value <= 0x10ffff;
44+
}

src/modules/ingest/app/sources/kdca-press-release.source.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,32 @@ describe('KdcaPressReleaseSource', () => {
100100
const payload = result.events[0].payload as { id?: string };
101101
expect(payload.id).toBe('전염병 경보 상향');
102102
});
103+
104+
it('should decode escaped html entities and remove trailing title artifact', async () => {
105+
vi.useFakeTimers();
106+
vi.setSystemTime(new Date('2026-06-17T06:00:00.000Z'));
107+
108+
const xml = buildRss(
109+
buildItem({
110+
title: '일본뇌염 바이러스 검출, 전국 경보 발령(6.17.수)}',
111+
link: '/bbs/kdca/41/311453/artclView.do?layout=unknown',
112+
pubDate: '2026-06-17 14:00:00.0',
113+
author: '대변인',
114+
description:
115+
'일본뇌염 경보 발령 [ 일본뇌염 주의보 및 경보 기준 ] (일본뇌염 주의보)&amp;#9312; 일본뇌염 매개모기 최초 채집 시',
116+
}),
117+
);
118+
119+
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(xml, { status: 200 })));
120+
vi.stubGlobal('fetch', fetchMock);
121+
122+
const source = new KdcaPressReleaseSource();
123+
const result = await source.run(null);
124+
125+
expect(result.events).toHaveLength(1);
126+
expect(result.events[0].title).toBe('일본뇌염 바이러스 검출, 전국 경보 발령(6.17.수)');
127+
expect(result.events[0].body).toBe(
128+
'일본뇌염 경보 발령 [ 일본뇌염 주의보 및 경보 기준 ] (일본뇌염 주의보)① 일본뇌염 매개모기 최초 채집 시',
129+
);
130+
});
103131
});

src/modules/ingest/app/sources/kdca-press-release.source.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { load } from 'cheerio';
22
import { logger } from '@/core/logger';
33
import { EventKinds, EventLevels, EventSources } from '@/modules/events/domain/event.enums';
44
import type { Source, SourceEvent, SourceRunResult } from '../../domain/port/source.interface';
5+
import { decodeHtmlEntities } from './_shared/decode-html-entities';
56
import { fetchWithTimeout } from './_shared/fetch-with-timeout';
67
import { isTooOld } from './_shared/is-too-old';
78
import { normalizeText } from './_shared/normalize';
@@ -119,7 +120,7 @@ function parseRssItems(xml: string, now: Date): KdcaPressItem[] {
119120

120121
const items: KdcaPressItem[] = [];
121122
for (const node of nodes) {
122-
const title = normalizeText($(node).find('title').first().text());
123+
const title = normalizeRssTitle($(node).find('title').first().text());
123124
const rawLink = normalizeText($(node).find('link').first().text());
124125
if (!title || !rawLink) {
125126
continue;
@@ -135,8 +136,8 @@ function parseRssItems(xml: string, now: Date): KdcaPressItem[] {
135136
logger.warn({ link, title }, 'Using title as KDCA press release id fallback');
136137
}
137138

138-
const author = normalizeText($(node).find('author').first().text());
139-
const description = normalizeText($(node).find('description').first().text());
139+
const author = normalizeRssText($(node).find('author').first().text());
140+
const description = normalizeRssText($(node).find('description').first().text());
140141
const rawDate = normalizeText($(node).find('pubDate').first().text());
141142
const occurredAt = parseKdcaDate(rawDate, now);
142143
if (rawDate && !occurredAt) {
@@ -157,6 +158,28 @@ function parseRssItems(xml: string, now: Date): KdcaPressItem[] {
157158
return items;
158159
}
159160

161+
function normalizeRssTitle(value: string | null | undefined): string | null {
162+
const normalized = normalizeRssText(value);
163+
if (!normalized) {
164+
return null;
165+
}
166+
167+
if (!normalized.includes('{')) {
168+
return normalizeText(normalized.replace(/\}+$/, ''));
169+
}
170+
171+
return normalized;
172+
}
173+
174+
function normalizeRssText(value: string | null | undefined): string | null {
175+
const normalized = normalizeText(value);
176+
if (!normalized) {
177+
return null;
178+
}
179+
180+
return decodeHtmlEntities(normalized, { maxPasses: 3 });
181+
}
182+
160183
function mapCrisisLevel(title: string): EventLevels {
161184
const candidates: Array<{ keyword: string; level: EventLevels }> = [
162185
{ keyword: '심각', level: EventLevels.Severe },

src/modules/ingest/app/sources/kma-micro-earthquake.source.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,11 @@ import { logger } from '@/core/logger';
22
import type { EventPayload } from '@/modules/events/domain/entity/event.entity';
33
import { EventKinds, EventLevels, EventSources } from '@/modules/events/domain/event.enums';
44
import type { Source, SourceEvent, SourceRunResult } from '../../domain/port/source.interface';
5+
import { decodeHtmlEntities } from './_shared/decode-html-entities';
56
import { fetchWithTimeout } from './_shared/fetch-with-timeout';
67

78
const KMA_MICRO_EARTHQUAKE_ENDPOINT = 'https://www.weather.go.kr/w/wnuri-eqk-vol/eqk/eqk-micro.do';
89

9-
const NAMED_ENTITIES = new Map<string, string>([
10-
['nbsp', ' '],
11-
['lt', '<'],
12-
['gt', '>'],
13-
['amp', '&'],
14-
['quot', '"'],
15-
['apos', "'"],
16-
]);
17-
1810
type MicroEarthquakeDetail = {
1911
occurredAt: string | null;
2012
regionText: string | null;
@@ -196,19 +188,6 @@ const sanitizeHtmlFragment = (fragment: string): string => {
196188
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim();
197189
};
198190

199-
const decodeHtmlEntities = (text: string): string => {
200-
return text
201-
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex: string) => {
202-
const codePoint = Number.parseInt(hex, 16);
203-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : _;
204-
})
205-
.replace(/&#(\d+);/g, (_, num: string) => {
206-
const codePoint = Number.parseInt(num, 10);
207-
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : _;
208-
})
209-
.replace(/&([a-zA-Z]+);/g, (full, name: string) => NAMED_ENTITIES.get(name) ?? full);
210-
};
211-
212191
const parseKstDateTime = (value: string): string | null => {
213192
const matched = value.match(/(\d{4})[./-](\d{2})[./-](\d{2})\s+(\d{2}):(\d{2}):(\d{2})/);
214193
if (!matched) {

0 commit comments

Comments
 (0)