Skip to content

Commit 7a9e96a

Browse files
committed
feat(ingest): simplify airkorea warning titles with merged zones
1 parent 27b6630 commit 7a9e96a

5 files changed

Lines changed: 164 additions & 34 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { normalizeText } from './normalize';
2+
3+
export function formatZoneTitle(zones: string[], region?: string): string | null {
4+
const normalizedRegion = normalizeText(region);
5+
const regionLabel = normalizedRegion ? normalizedRegion.replace(/\s*$/, '').trim() : null;
6+
const normalizedZones: string[] = [];
7+
let hasSameAsRegionZone = false;
8+
9+
for (const zone of zones) {
10+
const normalized = normalizeText(zone);
11+
if (!normalized) {
12+
continue;
13+
}
14+
15+
const withoutSuffix = normalized.replace(/\s*$/, '').trim();
16+
const zoneLabel = withoutSuffix.length > 0 ? withoutSuffix : normalized;
17+
if (regionLabel && zoneLabel === regionLabel) {
18+
hasSameAsRegionZone = true;
19+
continue;
20+
}
21+
22+
if (!normalizedZones.includes(zoneLabel)) {
23+
normalizedZones.push(zoneLabel);
24+
}
25+
}
26+
27+
if (normalizedZones.length === 0) {
28+
if (hasSameAsRegionZone) {
29+
return '권역';
30+
}
31+
32+
return null;
33+
}
34+
35+
return `${normalizedZones.join(', ')} 권역`;
36+
}

src/modules/ingest/app/sources/airkorea-o3-warning.source.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ describe('AirkoreaO3WarningSource', () => {
1818
<tr>
1919
<td>1</td>
2020
<td>서울</td>
21-
<td>강남구</td>
21+
<td>강남권역</td>
2222
<td>주의보</td>
2323
<td>2025-01-02 09</td>
2424
<td></td>
2525
</tr>
2626
<tr>
2727
<td>2</td>
2828
<td>서울</td>
29-
<td>서초구</td>
29+
<td>서초권역</td>
3030
<td>주의보</td>
3131
<td>2025-01-02 09</td>
3232
<td></td>
@@ -42,12 +42,42 @@ describe('AirkoreaO3WarningSource', () => {
4242
const result = await source.run(null);
4343

4444
expect(result.events).toHaveLength(1);
45-
expect(result.events[0].title).toBe('서울 오존 주의보');
46-
expect(result.events[0].body).toBe('권역: 강남구, 서초구');
45+
expect(result.events[0].title).toBe('서울 강남, 서초 권역 오존 주의보');
46+
expect(result.events[0].body).toBeUndefined();
4747
expect(result.events[0].occurredAt).toBe('2025-01-02T00:00:00.000Z');
4848
expect(result.events[0].level).toBe(EventLevels.Minor);
4949
});
5050

51+
it('should remove duplicated region zone in title', async () => {
52+
vi.useFakeTimers();
53+
vi.setSystemTime(new Date('2025-01-02T02:00:00.000Z'));
54+
55+
const html = `
56+
<table>
57+
<tbody>
58+
<tr>
59+
<td>1</td>
60+
<td>서울</td>
61+
<td>서울권역</td>
62+
<td>경보</td>
63+
<td>2025-01-02 09</td>
64+
<td></td>
65+
</tr>
66+
</tbody>
67+
</table>
68+
`;
69+
70+
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(new Response(html, { status: 200 })));
71+
vi.stubGlobal('fetch', fetchMock);
72+
73+
const source = new AirkoreaO3WarningSource();
74+
const result = await source.run(null);
75+
76+
expect(result.events).toHaveLength(1);
77+
expect(result.events[0].title).toBe('서울 권역 오존 경보');
78+
expect(result.events[0].body).toBeUndefined();
79+
});
80+
5181
it('should cap future issued time to now', async () => {
5282
vi.useFakeTimers();
5383
vi.setSystemTime(new Date('2025-01-02T02:00:00.000Z'));

src/modules/ingest/app/sources/airkorea-o3-warning.source.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { EventPayload } from '@/modules/events/domain/entity/event.entity';
55
import { EventKinds, EventLevels, EventSources } from '@/modules/events/domain/event.enums';
66
import type { Source, SourceEvent, SourceRunResult } from '../../domain/port/source.interface';
77
import { fetchWithTimeout } from './_shared/fetch-with-timeout';
8+
import { formatZoneTitle } from './_shared/format-zone-title';
89
import { isTooOld } from './_shared/is-too-old';
910
import { normalizeText } from './_shared/normalize';
1011
import { pruneTimedMap } from './_shared/prune-timed-map';
@@ -86,28 +87,19 @@ export class AirkoreaO3WarningSource implements Source {
8687
const buildWarningEvent = (group: O3WarningGroup, regionText: string | null): SourceEvent => {
8788
return {
8889
kind: EventKinds.O3,
89-
title: buildTitle(group.region, group.level),
90-
body: buildBody(group.zones),
90+
title: buildTitle(group.region, group.zones, group.level),
9191
occurredAt: group.issuedAt,
9292
regionText,
9393
level: mapWarningLevel(group.level),
9494
payload: buildPayload(group),
9595
};
9696
};
9797

98-
const buildTitle = (region: string, level: string): string => {
99-
const parts = [normalizeText(region), '오존', normalizeText(level) ?? '안내'];
98+
const buildTitle = (region: string, zones: string[], level: string): string => {
99+
const parts = [normalizeText(region), formatZoneTitle(zones, region), '오존', normalizeText(level) ?? '안내'];
100100
return parts.join(' ').trim();
101101
};
102102

103-
const buildBody = (zones: string[]): string | null => {
104-
if (zones.length === 0) {
105-
return null;
106-
}
107-
108-
return `권역: ${zones.join(', ')}`;
109-
};
110-
111103
const buildPayload = (group: O3WarningGroup): EventPayload => {
112104
return {
113105
region: group.region,

src/modules/ingest/app/sources/airkorea-pm-warning.source.test.ts

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe('AirkoreaPmWarningSource', () => {
2222
<tr>
2323
<td>1</td>
2424
<td>서울</td>
25-
<td>종로구</td>
25+
<td>남부권역</td>
2626
<td>미세먼지</td>
2727
<td>주의보</td>
2828
<td>2025-01-02 09</td>
@@ -31,7 +31,25 @@ describe('AirkoreaPmWarningSource', () => {
3131
<tr>
3232
<td>2</td>
3333
<td>서울</td>
34-
<td>강남구</td>
34+
<td>동부권역</td>
35+
<td>미세먼지</td>
36+
<td>주의보</td>
37+
<td>2025-01-02 09</td>
38+
<td></td>
39+
</tr>
40+
<tr>
41+
<td>3</td>
42+
<td>서울</td>
43+
<td>북구권역</td>
44+
<td>미세먼지</td>
45+
<td>주의보</td>
46+
<td>2025-01-02 09</td>
47+
<td></td>
48+
</tr>
49+
<tr>
50+
<td>4</td>
51+
<td>서울</td>
52+
<td>중부권역</td>
3553
<td>미세먼지</td>
3654
<td>주의보</td>
3755
<td>2025-01-02 09</td>
@@ -72,13 +90,72 @@ describe('AirkoreaPmWarningSource', () => {
7290
const result = await source.run(null);
7391

7492
expect(result.events).toHaveLength(1);
75-
expect(result.events[0].title).toBe('서울 미세먼지 주의보');
76-
expect(result.events[0].body).toBe('권역: 종로구, 강남구');
93+
expect(result.events[0].title).toBe('서울 남부, 동부, 북구, 중부 권역 미세먼지 주의보');
94+
expect(result.events[0].body).toBeUndefined();
7795
expect(result.events[0].occurredAt).toBe('2025-01-02T00:00:00.000Z');
7896
expect(result.events[0].level).toBe(EventLevels.Minor);
7997
expect(result.nextState).not.toBeNull();
8098
});
8199

100+
it('should remove duplicated region zone in title', async () => {
101+
vi.useFakeTimers();
102+
vi.setSystemTime(new Date('2025-01-02T02:00:00.000Z'));
103+
104+
const htmlWithRows = `
105+
<div id="dataSearch">
106+
<div>
107+
<div class="contSub">
108+
<div class="tblList">
109+
<table>
110+
<tbody>
111+
<tr>
112+
<td>1</td>
113+
<td>서울</td>
114+
<td>서울권역</td>
115+
<td>PM-10</td>
116+
<td>경보</td>
117+
<td>2025-01-02 09</td>
118+
<td></td>
119+
</tr>
120+
</tbody>
121+
</table>
122+
</div>
123+
</div>
124+
</div>
125+
</div>
126+
`;
127+
const htmlEmpty = `
128+
<div id="dataSearch">
129+
<div>
130+
<div class="contSub">
131+
<div class="tblList">
132+
<table>
133+
<tbody>
134+
<tr>
135+
<td>자료가 없습니다</td>
136+
</tr>
137+
</tbody>
138+
</table>
139+
</div>
140+
</div>
141+
</div>
142+
</div>
143+
`;
144+
145+
const fetchMock = vi
146+
.fn()
147+
.mockImplementationOnce(() => Promise.resolve(new Response(htmlWithRows, { status: 200 })))
148+
.mockImplementationOnce(() => Promise.resolve(new Response(htmlEmpty, { status: 200 })));
149+
vi.stubGlobal('fetch', fetchMock);
150+
151+
const source = new AirkoreaPmWarningSource();
152+
const result = await source.run(null);
153+
154+
expect(result.events).toHaveLength(1);
155+
expect(result.events[0].title).toBe('서울 권역 PM-10 경보');
156+
expect(result.events[0].body).toBeUndefined();
157+
});
158+
82159
it('should cap future issued time to now', async () => {
83160
vi.useFakeTimers();
84161
vi.setSystemTime(new Date('2025-01-02T02:00:00.000Z'));

src/modules/ingest/app/sources/airkorea-pm-warning.source.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { EventPayload } from '@/modules/events/domain/entity/event.entity';
55
import { EventKinds, EventLevels, EventSources } from '@/modules/events/domain/event.enums';
66
import type { Source, SourceEvent, SourceRunResult } from '../../domain/port/source.interface';
77
import { fetchWithTimeout } from './_shared/fetch-with-timeout';
8+
import { formatZoneTitle } from './_shared/format-zone-title';
89
import { isTooOld } from './_shared/is-too-old';
910
import { normalizeText } from './_shared/normalize';
1011
import { pruneTimedMap } from './_shared/prune-timed-map';
@@ -120,30 +121,24 @@ export class AirkoreaPmWarningSource implements Source {
120121
const buildWarningEvent = (group: PmWarningGroup, regionText: string | null): SourceEvent => {
121122
return {
122123
kind: EventKinds.FineDust,
123-
title: buildTitle(group.region, group.item, group.level),
124-
body: buildBody(group.zones),
124+
title: buildTitle(group.region, group.zones, group.item, group.level),
125125
occurredAt: group.issuedAt,
126126
regionText,
127127
level: mapWarningLevel(group.level),
128128
payload: buildPayload(group),
129129
};
130130
};
131131

132-
const buildTitle = (region: string, item: string, level: string): string => {
133-
const parts = [normalizeText(region), normalizeText(item), normalizeText(level)].filter((value): value is string =>
134-
Boolean(value),
135-
);
132+
const buildTitle = (region: string, zones: string[], item: string, level: string): string => {
133+
const parts = [
134+
normalizeText(region),
135+
formatZoneTitle(zones, region),
136+
normalizeText(item),
137+
normalizeText(level),
138+
].filter((value): value is string => Boolean(value));
136139
return parts.length > 0 ? parts.join(' ') : '미세먼지 경보';
137140
};
138141

139-
const buildBody = (zones: string[]): string | null => {
140-
if (zones.length === 0) {
141-
return null;
142-
}
143-
144-
return `권역: ${zones.join(', ')}`;
145-
};
146-
147142
const buildPayload = (group: PmWarningGroup): EventPayload => {
148143
return {
149144
region: group.region,

0 commit comments

Comments
 (0)