pal-crawl 2.2.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pal.test.js DELETED
@@ -1,1276 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- const pal_1 = require("./pal");
4
- const parser_1 = require("./parser");
5
- const DEFAULT_TEST_TIMEOUT_MS = 120000;
6
- const configuredTimeout = Number.parseInt(process.env.PAL_CRAWL_TEST_TIMEOUT_MS ?? String(DEFAULT_TEST_TIMEOUT_MS), 10);
7
- const testTimeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0
8
- ? configuredTimeout
9
- : DEFAULT_TEST_TIMEOUT_MS;
10
- // Integration tests call external services and can be slow on unstable networks.
11
- jest.setTimeout(testTimeoutMs);
12
- const FIXED_ONGOING_CONTENT_ID = 'PRC_W2W6V0D4D0B9C1B4B4Z6V2W0U7V2T9';
13
- const FIXED_DONE_CONTENT_ID = 'PRC_S2R6N0M3K2J3K1J5K3S8R3P4O3P5X6';
14
- const SHOULD_DEBUG = process.env.PAL_CRAWL_TEST_DEBUG === '1';
15
- const debugLog = (label, value) => {
16
- if (!SHOULD_DEBUG)
17
- return;
18
- console.debug(label, JSON.stringify(value, null, 2));
19
- };
20
- // ─── HTML Fixtures ────────────────────────────────────────────────────────────
21
- const TABLE_ROW_HTML = `
22
- <table>
23
- <tbody>
24
- <tr>
25
- <td>42</td>
26
- <td class="td_block">
27
- <a href="/napal/lgsltpa/lgsltpaOngoing/view.do?lgsltPaId=PRC_TEST000" class="board_subject">테스트 법률안 (홍길동의원 등 5인)</a>
28
- </td>
29
- <td>의원</td>
30
- <td>법제사법위원회</td>
31
- <td></td>
32
- <td>
33
- <a href="#">버튼</a>
34
- <a href="https://example.com/file.hwp">hwp</a>
35
- <a href="https://example.com/file.pdf">pdf</a>
36
- </td>
37
- <td></td>
38
- <td>1,234</td>
39
- <td></td>
40
- </tr>
41
- </tbody>
42
- </table>
43
- `;
44
- const TABLE_ROW_NO_LINK_HTML = `
45
- <table>
46
- <tbody>
47
- <tr>
48
- <td>1</td>
49
- <td class="td_block">
50
- <a class="board_subject">링크없는 법률안</a>
51
- </td>
52
- <td>정부</td>
53
- <td>기획재정위원회</td>
54
- <td></td>
55
- <td></td>
56
- <td></td>
57
- <td>0</td>
58
- <td></td>
59
- </tr>
60
- </tbody>
61
- </table>
62
- `;
63
- const CONTENT_HTML = `
64
- <div class="legislation-heading">
65
- <h3>[2218288] 조세특례제한법 일부개정법률안(윤한홍의원 등 10인)</h3>
66
- </div>
67
- <div class="board01 pr td_center board-added">
68
- <table>
69
- <thead>
70
- <tr>
71
- <th scope="col">의안번호</th>
72
- <th scope="col">제안자</th>
73
- <th scope="col">제안일</th>
74
- <th scope="col">소관위원회</th>
75
- <th scope="col">회부일</th>
76
- <th scope="col">입법예고기간</th>
77
- <th scope="col">법률안원문</th>
78
- <th scope="col">제안회기</th>
79
- </tr>
80
- </thead>
81
- <tbody>
82
- <tr>
83
- <td>2218288</td>
84
- <td>
85
- 윤한홍의원 등 10인
86
- <a class="btn_sm" href="#">제안자목록</a>
87
- </td>
88
- <td>2026-04-01</td>
89
- <td class="td_block">
90
- 기획재정위원회
91
- <div class="m_subject">
92
- <ul class="m_date">
93
- <li>입법예고기간 : 2026-04-02~2026-04-11</li>
94
- </ul>
95
- </div>
96
- </td>
97
- <td>2026-04-02</td>
98
- <td>2026-04-02~2026-04-11</td>
99
- <td><a href="#">미리보기</a></td>
100
- <td>제22대(2024~2028) 제433회</td>
101
- </tr>
102
- </tbody>
103
- </table>
104
- </div>
105
- <div class="card-wrap">
106
- <div class="item">
107
- <h4>제안이유 및 주요내용</h4>
108
- <div class="desc">
109
- 제안이유 및 주요내용
110
- <br/>첫 번째 문장
111
- <br/>두 번째 문장
112
- </div>
113
- </div>
114
- <div class="item">
115
- <h4>의견제출 방법</h4>
116
- <div class="desc">국회 재정경제기획위원회</div>
117
- </div>
118
- <div class="item item-means">
119
- <div class="desc">제출방법: 상단 의견등록 탭 사용</div>
120
- </div>
121
- </div>
122
- `;
123
- const SEARCH_RESULT_HTML = `
124
- <div class="board_count">
125
- <span>전체</span>
126
- <strong>143</strong>
127
- <span>건 (2/15 페이지)</span>
128
- </div>
129
- <table>
130
- <tbody>
131
- <tr>
132
- <td>42</td>
133
- <td class="td_block">
134
- <a href="/napal/lgsltpa/lgsltpaOngoing/view.do?lgsltPaId=PRC_TEST000" class="board_subject">테스트 법률안 (홍길동의원 등 5인)</a>
135
- </td>
136
- <td>의원</td>
137
- <td>법제사법위원회</td>
138
- <td></td>
139
- <td>
140
- <a href="#">버튼</a>
141
- <a href="https://example.com/file.hwp">hwp</a>
142
- <a href="https://example.com/file.pdf">pdf</a>
143
- </td>
144
- <td></td>
145
- <td>1,234</td>
146
- <td></td>
147
- </tr>
148
- </tbody>
149
- </table>
150
- `;
151
- // ─── NSM HTML Fixtures ────────────────────────────────────────────────────────
152
- /** 위원회에 배속된 법안 한 건 + 페이지네이션 */
153
- const NSM_LIST_HTML = `
154
- <p class="numBuild">총 <span>3,142</span>건</p>
155
- <p class="numBuild">현재 <em>2</em>/<span>315</span>쪽</p>
156
- <table>
157
- <tbody>
158
- <tr>
159
- <td data-th="의안명">
160
- <a href="/gcom/nsmLmSts/out/2219088/detailRP">인공지능 기본법 일부개정법률안</a>
161
- </td>
162
- <td data-th="제안자(제안일자)">
163
- <p>홍길동의원 등 10인</p>
164
- <p>(2026.05.01.)</p>
165
- </td>
166
- <td data-th="상임위원회(소관부처)">
167
- <p>과학기술정보방송통신위원회</p>
168
- <p>(과학기술정보통신부)</p>
169
- </td>
170
- <td data-th="국회현황(추진일자)">
171
- <p>위원회 회부</p>
172
- <p>(2026.05.03.)</p>
173
- </td>
174
- <td data-th="의결현황(의결일자)">
175
- <p>원안가결</p>
176
- <p>(2026.05.20.)</p>
177
- </td>
178
- </tr>
179
- </tbody>
180
- </table>
181
- `;
182
- /** 위원회 회부 이전 발의 상태 법안 (committee 빈 문자열) */
183
- const NSM_LIST_HTML_PENDING = `
184
- <p class="numBuild">총 <span>87</span>건</p>
185
- <p class="numBuild">현재 <em>1</em>/<span>9</span>쪽</p>
186
- <table>
187
- <tbody>
188
- <tr>
189
- <td data-th="의안명">
190
- <a href="/gcom/nsmLmSts/out/2219200/detailRP">개인정보 보호법 일부개정법률안</a>
191
- </td>
192
- <td data-th="제안자(제안일자)">
193
- <p>김철수의원 등 5인</p>
194
- <p>(2026.05.28.)</p>
195
- </td>
196
- <td data-th="상임위원회(소관부처)">
197
- <p>(개인정보보호위원회)</p>
198
- </td>
199
- <td data-th="국회현황(추진일자)">
200
- <p>발의</p>
201
- <p>(2026.05.28.)</p>
202
- </td>
203
- <td data-th="의결현황(의결일자)"></td>
204
- </tr>
205
- </tbody>
206
- </table>
207
- `;
208
- /** 목록에서 제목이 잘린 케이스 */
209
- const NSM_LIST_HTML_TRUNCATED_TITLE = `
210
- <p class="numBuild">총 <span>1</span>건</p>
211
- <p class="numBuild">현재 <em>1</em>/<span>1</span>쪽</p>
212
- <table>
213
- <tbody>
214
- <tr>
215
- <td data-th="의안명">
216
- <a href="/gcom/nsmLmSts/out/2219088/detailRP">인공지능 기본법 일부개정...</a>
217
- </td>
218
- <td data-th="제안자(제안일자)">
219
- <p>홍길동의원 등 10인</p>
220
- <p>(2026.05.01.)</p>
221
- </td>
222
- <td data-th="상임위원회(소관부처)">
223
- <p>과학기술정보방송통신위원회</p>
224
- <p>(과학기술정보통신부)</p>
225
- </td>
226
- <td data-th="국회현황(추진일자)">
227
- <p>위원회 회부</p>
228
- <p>(2026.05.03.)</p>
229
- </td>
230
- <td data-th="의결현황(의결일자)">
231
- <p>원안가결</p>
232
- <p>(2026.05.20.)</p>
233
- </td>
234
- </tr>
235
- </tbody>
236
- </table>
237
- `;
238
- /** 상세 페이지 */
239
- const NSM_DETAIL_HTML = `
240
- <h2></h2>
241
- <h2>인공지능 기본법 일부개정법률안</h2>
242
- <h3>기본정보</h3>
243
- <h3>국회입법현황</h3>
244
- <table>
245
- <tbody>
246
- <tr>
247
- <th>발의정보</th>
248
- <td>홍길동의원 등 10인, 제2219088호(2026. 5. 1.). 제435회 국회(임시회)</td>
249
- </tr>
250
- <tr>
251
- <th>의안원문</th>
252
- <td>
253
- <button onclick="fnDownload(99001)">인공지능기본법안.hwp<span class="a11y_hidden">다운로드</span></button>
254
- <button onclick="fnDownload(99002)">인공지능기본법안검토보고서.pdf<span class="a11y_hidden">다운로드</span></button>
255
- </td>
256
- </tr>
257
- <tr>
258
- <th>제안이유 및 주요내용</th>
259
- <td>
260
- <pre>인공지능 기술의 발전에 따라<br/>규제 체계를 정비하고자 함</pre>
261
- </td>
262
- </tr>
263
- </tbody>
264
- </table>
265
- `;
266
- /** 상세 페이지 - 제안이유 없음 (pre 태그 없는 경우) */
267
- const NSM_DETAIL_HTML_NO_REASON = `
268
- <h2>최소 법률안</h2>
269
- <table>
270
- <tbody>
271
- <tr>
272
- <th>발의정보</th>
273
- <td>이영희의원, 제2200001호(2026. 1. 5.). 제433회 국회(임시회)</td>
274
- </tr>
275
- <tr>
276
- <th>의안원문</th>
277
- <td></td>
278
- </tr>
279
- </tbody>
280
- </table>
281
- `;
282
- // ─── PalParser ──────────────────────────────────────────────────────────────────────────────
283
- describe('PalParser', () => {
284
- const parser = new parser_1.PalParser();
285
- describe('parseTable', () => {
286
- test('returns [] for invalid html', () => {
287
- expect(parser.parseTable('asdf')).toHaveLength(0);
288
- });
289
- test('returns [] for empty string', () => {
290
- expect(parser.parseTable('')).toHaveLength(0);
291
- });
292
- test('parses all fields from a valid table row', () => {
293
- const rows = parser.parseTable(TABLE_ROW_HTML);
294
- expect(rows).toHaveLength(1);
295
- const row = rows[0];
296
- expect(row.num).toBe(42);
297
- expect(row.subject).toBe('테스트 법률안 (홍길동의원 등 5인)');
298
- expect(row.proposerCategory).toBe('의원');
299
- expect(row.committee).toBe('법제사법위원회');
300
- expect(row.numComments).toBe(1234);
301
- expect(row.link).toContain('PRC_TEST000');
302
- expect(row.contentId).toBe('PRC_TEST000');
303
- expect(row.attachments.hwpFile).toBe('https://example.com/file.hwp');
304
- expect(row.attachments.pdfFile).toBe('https://example.com/file.pdf');
305
- });
306
- test('sets link to empty string and contentId to null when anchor has no href', () => {
307
- const rows = parser.parseTable(TABLE_ROW_NO_LINK_HTML);
308
- expect(rows).toHaveLength(1);
309
- expect(rows[0].link).toBe('');
310
- expect(rows[0].contentId).toBeNull();
311
- });
312
- test('parses numComments with comma formatting as a plain number', () => {
313
- const rows = parser.parseTable(TABLE_ROW_HTML);
314
- expect(rows[0].numComments).toBe(1234);
315
- });
316
- });
317
- describe('parseContent', () => {
318
- test('parses all fields from a full fixture', () => {
319
- const content = parser.parseContent(CONTENT_HTML);
320
- expect(content.title).toContain('조세특례제한법 일부개정법률안');
321
- expect(content.proposalReason).toBe('첫 번째 문장\n두 번째 문장');
322
- expect(content.billNumber).toBe('2218288');
323
- expect(content.proposer).toBe('윤한홍의원 등 10인');
324
- expect(content.proposalDate).toBe('2026-04-01');
325
- expect(content.committee).toBe('기획재정위원회');
326
- expect(content.referralDate).toBe('2026-04-02');
327
- expect(content.noticePeriod).toBe('2026-04-02~2026-04-11');
328
- expect(content.proposalSession).toBe('제22대(2024~2028) 제433회');
329
- });
330
- test('strips the repeated heading line from proposal reason', () => {
331
- const content = parser.parseContent(CONTENT_HTML);
332
- // The desc starts with "제안이유 및 주요내용" which matches the heading - it must be stripped.
333
- expect(content.proposalReason).not.toMatch(/제안이유/);
334
- });
335
- test('returns empty title and all nulls for empty html', () => {
336
- const content = parser.parseContent('');
337
- expect(content.title).toBe('');
338
- expect(content.proposalReason).toBeNull();
339
- expect(content.billNumber).toBeNull();
340
- expect(content.proposer).toBeNull();
341
- expect(content.proposalDate).toBeNull();
342
- expect(content.committee).toBeNull();
343
- expect(content.referralDate).toBeNull();
344
- expect(content.noticePeriod).toBeNull();
345
- expect(content.proposalSession).toBeNull();
346
- });
347
- test('falls back to h3 when .legislation-heading h3 is absent', () => {
348
- const content = parser.parseContent('<h3>대체 제목</h3>');
349
- expect(content.title).toBe('대체 제목');
350
- });
351
- test('returns null for all bill info fields when .board-added table is absent', () => {
352
- const content = parser.parseContent('<div class="legislation-heading"><h3>제목</h3></div>');
353
- expect(content.billNumber).toBeNull();
354
- expect(content.proposer).toBeNull();
355
- expect(content.proposalDate).toBeNull();
356
- expect(content.committee).toBeNull();
357
- expect(content.referralDate).toBeNull();
358
- expect(content.noticePeriod).toBeNull();
359
- expect(content.proposalSession).toBeNull();
360
- });
361
- test('returns null proposalReason when desc is empty', () => {
362
- const content = parser.parseContent(`
363
- <div class="card-wrap">
364
- <div class="item">
365
- <h4>제안이유 및 주요내용</h4>
366
- <div class="desc"></div>
367
- </div>
368
- </div>
369
- `);
370
- expect(content.proposalReason).toBeNull();
371
- });
372
- test('keeps proposal reason as-is when first line does not repeat the heading', () => {
373
- const content = parser.parseContent(`
374
- <div class="card-wrap">
375
- <div class="item">
376
- <h4>제안이유 및 주요내용</h4>
377
- <div class="desc">
378
- 본문 첫 줄
379
- <br/>본문 두 번째 줄
380
- </div>
381
- </div>
382
- </div>
383
- `);
384
- expect(content.proposalReason).toBe('본문 첫 줄\n본문 두 번째 줄');
385
- });
386
- test('ignores items without an h4 heading', () => {
387
- expect(() => parser.parseContent(`
388
- <div class="card-wrap">
389
- <div class="item">
390
- <div class="desc">heading 없는 항목</div>
391
- </div>
392
- </div>
393
- `)).not.toThrow();
394
- });
395
- });
396
- describe('parseSearchResult', () => {
397
- test('parses total, totalPages, currentPage, and items from fixture', () => {
398
- const result = parser.parseSearchResult(SEARCH_RESULT_HTML);
399
- expect(result.total).toBe(143);
400
- expect(result.totalPages).toBe(15);
401
- expect(result.currentPage).toBe(2);
402
- expect(result.items).toHaveLength(1);
403
- expect(result.items[0].subject).toBe('테스트 법률안 (홍길동의원 등 5인)');
404
- });
405
- test('returns zero total and empty items for empty html', () => {
406
- const result = parser.parseSearchResult('');
407
- expect(result.total).toBe(0);
408
- expect(result.totalPages).toBe(0);
409
- expect(result.currentPage).toBe(1);
410
- expect(result.items).toHaveLength(0);
411
- });
412
- test('falls back to items.length when count structure is absent', () => {
413
- const result = parser.parseSearchResult(TABLE_ROW_HTML);
414
- expect(result.total).toBe(1);
415
- expect(result.totalPages).toBe(1);
416
- expect(result.currentPage).toBe(1);
417
- });
418
- });
419
- });
420
- // ─── PalCrawl ─────────────────────────────────────────────────────────────────
421
- describe('PalCrawl', () => {
422
- const palCrawl = new pal_1.PalCrawl();
423
- // ── constructor ──────────────────────────────────────────────────────────────
424
- describe('constructor', () => {
425
- test('creates instance without config', () => {
426
- expect(new pal_1.PalCrawl()).toBeInstanceOf(pal_1.PalCrawl);
427
- });
428
- test('creates instance with empty config object', () => {
429
- expect(new pal_1.PalCrawl({})).toBeInstanceOf(pal_1.PalCrawl);
430
- });
431
- test('accepts custom userAgent', () => {
432
- expect(new pal_1.PalCrawl({ userAgent: 'Custom Agent' })).toBeInstanceOf(pal_1.PalCrawl);
433
- });
434
- test('accepts custom timeout', () => {
435
- expect(new pal_1.PalCrawl({ timeout: 5000 })).toBeInstanceOf(pal_1.PalCrawl);
436
- });
437
- test('accepts custom retryCount', () => {
438
- expect(new pal_1.PalCrawl({ retryCount: 5 })).toBeInstanceOf(pal_1.PalCrawl);
439
- });
440
- test('accepts custom headers', () => {
441
- expect(new pal_1.PalCrawl({ customHeaders: { 'Accept-Language': 'ko-KR' } })).toBeInstanceOf(pal_1.PalCrawl);
442
- });
443
- test('accepts all config options together', () => {
444
- const config = {
445
- userAgent: 'Custom Agent',
446
- timeout: 15000,
447
- retryCount: 2,
448
- customHeaders: { Accept: 'application/json' },
449
- };
450
- expect(new pal_1.PalCrawl(config)).toBeInstanceOf(pal_1.PalCrawl);
451
- });
452
- });
453
- // ── parseTable / parseContent (delegation) ───────────────────────────────────
454
- describe('parseTable', () => {
455
- test('returns [] for invalid html', () => {
456
- expect(palCrawl.parseTable('asdf')).toHaveLength(0);
457
- });
458
- test('delegates to PalParser and returns parsed rows', () => {
459
- const rows = palCrawl.parseTable(TABLE_ROW_HTML);
460
- expect(rows).toHaveLength(1);
461
- expect(rows[0].num).toBe(42);
462
- expect(rows[0].subject).toBe('테스트 법률안 (홍길동의원 등 5인)');
463
- expect(rows[0].contentId).toBe('PRC_TEST000');
464
- });
465
- });
466
- describe('parseContent', () => {
467
- test('delegates to PalParser and returns parsed content', () => {
468
- const content = palCrawl.parseContent(CONTENT_HTML);
469
- expect(content.title).toContain('조세특례제한법 일부개정법률안');
470
- expect(content.billNumber).toBe('2218288');
471
- expect(content.proposer).toBe('윤한홍의원 등 10인');
472
- });
473
- });
474
- // ── ongoing legislative notices (integration) ────────────────────────────────
475
- describe('ongoing legislative notices', () => {
476
- let listHtml = '';
477
- let ongoingTable = [];
478
- let contentHtml = '';
479
- let fixedContent;
480
- beforeAll(async () => {
481
- [listHtml, contentHtml] = await Promise.all([
482
- palCrawl.getPalHTML(),
483
- palCrawl.getContentHTML(FIXED_ONGOING_CONTENT_ID),
484
- ]);
485
- ongoingTable = palCrawl.parseTable(listHtml);
486
- fixedContent = palCrawl.parseContent(contentHtml);
487
- debugLog('ongoingTable', ongoingTable);
488
- debugLog('fixedContent', fixedContent);
489
- });
490
- test('getPalHTML: returns a non-garbled html string', () => {
491
- expect(typeof listHtml).toBe('string');
492
- expect(listHtml.length).toBeGreaterThan(0);
493
- expect(listHtml).not.toContain('�');
494
- });
495
- test('get: returns items (up to 10 per page)', async () => {
496
- const result = await palCrawl.get();
497
- expect(result.length).toBeGreaterThan(0);
498
- expect(result.length).toBeLessThanOrEqual(10);
499
- });
500
- test('get: each item has valid structure and no garbled text', () => {
501
- ongoingTable.forEach((e) => {
502
- expect(e.subject).not.toContain('�');
503
- expect(e.proposerCategory).not.toContain('�');
504
- expect(e.committee).not.toContain('�');
505
- expect(e.attachments.pdfFile).not.toBe('');
506
- expect(e.attachments.hwpFile).not.toBe('');
507
- expect(e.contentId).not.toBeNull();
508
- expect(e.contentId?.startsWith('PRC_')).toBe(true);
509
- });
510
- });
511
- test('getContentHTML: returns non-empty html for known id', () => {
512
- expect(typeof contentHtml).toBe('string');
513
- expect(contentHtml.length).toBeGreaterThan(0);
514
- expect(contentHtml).not.toContain('�');
515
- });
516
- test('getContent: parses all fields from known id without garbled text', () => {
517
- expect(fixedContent.title.length).toBeGreaterThan(0);
518
- expect(fixedContent.title).not.toContain('�');
519
- expect(fixedContent.proposalReason).not.toBeNull();
520
- expect(fixedContent.proposalReason?.length ?? 0).toBeGreaterThan(0);
521
- expect(fixedContent.proposalReason ?? '').not.toContain('�');
522
- expect(fixedContent.billNumber).not.toBeNull();
523
- expect(fixedContent.proposer).not.toBeNull();
524
- expect(fixedContent.proposalDate).not.toBeNull();
525
- expect(fixedContent.committee).not.toBeNull();
526
- expect(fixedContent.referralDate).not.toBeNull();
527
- expect(fixedContent.noticePeriod).not.toBeNull();
528
- expect(fixedContent.proposalSession).not.toBeNull();
529
- });
530
- test('getContentHTML: throws when id is an empty string', async () => {
531
- await expect(palCrawl.getContentHTML('')).rejects.toThrow('id is required');
532
- });
533
- test('getContentHTML: throws when id is whitespace only', async () => {
534
- await expect(palCrawl.getContentHTML(' ')).rejects.toThrow('id is required');
535
- });
536
- });
537
- // ── done legislative notices (integration) ───────────────────────────────────
538
- describe('done legislative notices', () => {
539
- let doneHtml = '';
540
- let doneTable = [];
541
- let doneContentHtml = '';
542
- let doneContent;
543
- beforeAll(async () => {
544
- [doneHtml, doneContentHtml] = await Promise.all([
545
- palCrawl.getDoneHTML(),
546
- palCrawl.getDoneContentHTML(FIXED_DONE_CONTENT_ID),
547
- ]);
548
- doneTable = palCrawl.parseTable(doneHtml);
549
- doneContent = palCrawl.parseContent(doneContentHtml);
550
- debugLog('doneTable', doneTable);
551
- debugLog('doneContent', doneContent);
552
- });
553
- test('getDoneHTML: returns a non-garbled html string', () => {
554
- expect(typeof doneHtml).toBe('string');
555
- expect(doneHtml.length).toBeGreaterThan(0);
556
- expect(doneHtml).not.toContain('�');
557
- });
558
- test('getDone: returns 10 items per page', async () => {
559
- const result = await palCrawl.getDone();
560
- expect(result).toHaveLength(10);
561
- });
562
- test('getDone: each item has valid structure and no garbled text', () => {
563
- doneTable.forEach((e) => {
564
- expect(e.subject).not.toContain('�');
565
- expect(e.proposerCategory).not.toContain('�');
566
- expect(e.committee).not.toContain('�');
567
- expect(e.contentId).not.toBeNull();
568
- expect(e.contentId?.startsWith('PRC_')).toBe(true);
569
- });
570
- });
571
- test('getDoneContentHTML: returns non-empty html for known id', () => {
572
- expect(typeof doneContentHtml).toBe('string');
573
- expect(doneContentHtml.length).toBeGreaterThan(0);
574
- expect(doneContentHtml).not.toContain('�');
575
- });
576
- test('getDoneContent: parses title and bill info from known id', () => {
577
- expect(doneContent.title.length).toBeGreaterThan(0);
578
- expect(doneContent.title).not.toContain('�');
579
- expect(doneContent.billNumber).not.toBeNull();
580
- expect(doneContent.proposer).not.toBeNull();
581
- expect(doneContent.proposalDate).not.toBeNull();
582
- expect(doneContent.committee).not.toBeNull();
583
- });
584
- test('getDoneContentHTML: throws when id is an empty string', async () => {
585
- await expect(palCrawl.getDoneContentHTML('')).rejects.toThrow('id is required');
586
- });
587
- test('getDoneContentHTML: throws when id is whitespace only', async () => {
588
- await expect(palCrawl.getDoneContentHTML(' ')).rejects.toThrow('id is required');
589
- });
590
- });
591
- // ── search / searchDone (integration) ─────────────────────────────────────────
592
- describe('search / searchDone', () => {
593
- test('search: returns ISearchResult with total, totalPages, currentPage', async () => {
594
- const result = await palCrawl.search();
595
- expect(result.total).toBeGreaterThan(0);
596
- expect(result.totalPages).toBeGreaterThan(0);
597
- expect(result.currentPage).toBe(1);
598
- expect(result.items.length).toBeGreaterThan(0);
599
- });
600
- test('search: billName filter returns matching items', async () => {
601
- const result = await palCrawl.search({ billName: '개정법률' });
602
- expect(result.items.length).toBeGreaterThan(0);
603
- result.items.forEach((item) => {
604
- expect(item.subject).toMatch(/개정법률/);
605
- });
606
- });
607
- test('search: sortCol=BILL_NAME sortGbn=ASC returns alphabetically sorted items', async () => {
608
- const result = await palCrawl.search({
609
- sortCol: 'BILL_NAME',
610
- sortGbn: 'ASC',
611
- pageUnit: 5,
612
- });
613
- expect(result.items.length).toBeGreaterThan(0);
614
- // First subject should start alphabetically early (가ㅠ)
615
- expect(result.items[0].subject).not.toContain('오류');
616
- });
617
- test('searchDone: returns ISearchResult with large total', async () => {
618
- const result = await palCrawl.searchDone();
619
- expect(result.total).toBeGreaterThan(1000);
620
- expect(result.totalPages).toBeGreaterThan(100);
621
- expect(result.currentPage).toBe(1);
622
- });
623
- test('searchDone: fromAge/toAge filter narrows results', async () => {
624
- const result = await palCrawl.searchDone({
625
- fromAge: 22,
626
- toAge: 22,
627
- pageUnit: 5,
628
- });
629
- // The site always reports the global total in the count area,
630
- // but the returned items are filtered to the specified session range.
631
- expect(result.items.length).toBeGreaterThan(0);
632
- expect(result.items.length).toBeLessThanOrEqual(5);
633
- });
634
- });
635
- // ── getPage / getDonePage (integration) ─────────────────────────────────────
636
- describe('getPage / getDonePage', () => {
637
- test('getPage(1): returns same items as get()', async () => {
638
- const [page1, legacy] = await Promise.all([
639
- palCrawl.getPage(1),
640
- palCrawl.get(),
641
- ]);
642
- expect(page1).toHaveLength(legacy.length);
643
- expect(page1[0].subject).toBe(legacy[0].subject);
644
- });
645
- test('getDonePage(2): returns different items from page 1', async () => {
646
- const [page1, page2] = await Promise.all([
647
- palCrawl.getDonePage(1),
648
- palCrawl.getDonePage(2),
649
- ]);
650
- expect(page1.length).toBeGreaterThan(0);
651
- expect(page2.length).toBeGreaterThan(0);
652
- expect(page1[0].subject).not.toBe(page2[0].subject);
653
- });
654
- test('getDonePage: pageUnit=20 returns up to 20 items', async () => {
655
- const items = await palCrawl.getDonePage(1, 20);
656
- expect(items.length).toBeLessThanOrEqual(20);
657
- expect(items.length).toBeGreaterThan(10);
658
- });
659
- test('getDonePage(2): returns items different from page 1', async () => {
660
- const [page1, page2] = await Promise.all([
661
- palCrawl.getDonePage(1),
662
- palCrawl.getDonePage(2),
663
- ]);
664
- expect(page1[0].subject).not.toBe(page2[0].subject);
665
- });
666
- });
667
- // ── getAllPages / getAllDonePages (integration) ──────────────────────────────
668
- describe('getAllPages / getAllDonePages', () => {
669
- test('getAllPages: yields exactly maxPages results', async () => {
670
- const pages = [];
671
- for await (const page of palCrawl.getAllPages({ pageUnit: 5 }, { maxPages: 2, delayMs: 0 })) {
672
- pages.push(page);
673
- }
674
- expect(pages).toHaveLength(2);
675
- expect(pages[0].items.length).toBeGreaterThan(0);
676
- expect(pages[1].items.length).toBeGreaterThan(0);
677
- });
678
- test('getAllDonePages: currentPage increments correctly', async () => {
679
- const pages = [];
680
- for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 3, delayMs: 0 })) {
681
- pages.push(page);
682
- }
683
- expect(pages[0].currentPage).toBe(1);
684
- expect(pages[1].currentPage).toBe(2);
685
- expect(pages[2].currentPage).toBe(3);
686
- });
687
- test('getAllDonePages: total and totalPages are consistent across pages', async () => {
688
- const pages = [];
689
- for await (const page of palCrawl.getAllDonePages({}, { maxPages: 2, delayMs: 0 })) {
690
- pages.push(page);
691
- }
692
- expect(pages[0].total).toBe(pages[1].total);
693
- expect(pages[0].totalPages).toBe(pages[1].totalPages);
694
- });
695
- test('getAllPages: page 2 items differ from page 1', async () => {
696
- const pages = [];
697
- for await (const page of palCrawl.getAllPages({ pageUnit: 5 }, { maxPages: 2, delayMs: 0 })) {
698
- pages.push(page);
699
- }
700
- expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
701
- });
702
- test('getAllDonePages: concurrency > 1 yields pages in order', async () => {
703
- const pages = [];
704
- for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
705
- pages.push(page);
706
- }
707
- expect(pages).toHaveLength(4);
708
- for (let i = 0; i < pages.length; i++) {
709
- expect(pages[i].currentPage).toBe(i + 1);
710
- }
711
- });
712
- test('getAllDonePages: yields 2 pages of done notices', async () => {
713
- const pages = [];
714
- for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 2, delayMs: 0 })) {
715
- pages.push(page);
716
- }
717
- expect(pages).toHaveLength(2);
718
- expect(pages[0].total).toBeGreaterThan(1000);
719
- expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
720
- });
721
- });
722
- // ── Screenshot functionality ─────────────────────────────────────────────────
723
- describe('Screenshot functionality', () => {
724
- test('creates instance with screenshot config', () => {
725
- const config = {
726
- screenshot: {
727
- enabled: true,
728
- fullPage: true,
729
- width: 1920,
730
- height: 1080,
731
- format: 'png',
732
- },
733
- };
734
- expect(new pal_1.PalCrawl(config)).toBeInstanceOf(pal_1.PalCrawl);
735
- });
736
- test('throws error when screenshot is disabled', async () => {
737
- const crawler = new pal_1.PalCrawl({ screenshot: { enabled: false } });
738
- await expect(crawler.takeScreenshot('https://example.com')).rejects.toThrow('Screenshot feature is not enabled');
739
- });
740
- test('accepts jpeg format with quality option', () => {
741
- const config = {
742
- screenshot: {
743
- enabled: true,
744
- format: 'jpeg',
745
- quality: 90,
746
- },
747
- };
748
- expect(new pal_1.PalCrawl(config)).toBeInstanceOf(pal_1.PalCrawl);
749
- });
750
- test('updateScreenshotConfig modifies screenshot settings', () => {
751
- const crawler = new pal_1.PalCrawl({
752
- screenshot: { enabled: true, format: 'png' },
753
- });
754
- crawler.updateScreenshotConfig({ format: 'jpeg', quality: 75 });
755
- expect(crawler).toBeInstanceOf(pal_1.PalCrawl);
756
- });
757
- test('initBrowser and closeBrowser can be called', async () => {
758
- const crawler = new pal_1.PalCrawl({
759
- screenshot: { enabled: true },
760
- });
761
- // Note: These tests don't actually require a browser to pass.
762
- // In a real scenario with HEADLESS_BROWSER env set, these would work.
763
- expect(crawler).toBeInstanceOf(pal_1.PalCrawl);
764
- });
765
- });
766
- });
767
- // ─── NsmLmStsParser ──────────────────────────────────────────────────────────
768
- describe('NsmLmStsParser', () => {
769
- const parser = new parser_1.NsmLmStsParser();
770
- // ── parseList ──────────────────────────────────────────────────────────────
771
- describe('parseList', () => {
772
- test('returns empty result for empty string', () => {
773
- const result = parser.parseList('');
774
- expect(result.total).toBe(0);
775
- expect(result.totalPages).toBe(0);
776
- expect(result.currentPage).toBe(1);
777
- expect(result.items).toHaveLength(0);
778
- });
779
- test('returns empty result for invalid html', () => {
780
- const result = parser.parseList('not html at all');
781
- expect(result.items).toHaveLength(0);
782
- });
783
- test('parses pagination: total, totalPages, currentPage', () => {
784
- const result = parser.parseList(NSM_LIST_HTML);
785
- expect(result.total).toBe(3142);
786
- expect(result.totalPages).toBe(315);
787
- expect(result.currentPage).toBe(2);
788
- });
789
- test('parses bill name and link', () => {
790
- const result = parser.parseList(NSM_LIST_HTML);
791
- expect(result.items).toHaveLength(1);
792
- const item = result.items[0];
793
- expect(item.billName).toBe('인공지능 기본법 일부개정법률안');
794
- expect(item.billNo).toBe('2219088');
795
- expect(item.link).toContain('2219088/detailRP');
796
- });
797
- test('parses proposer and proposalDate (strips parentheses)', () => {
798
- const item = parser.parseList(NSM_LIST_HTML).items[0];
799
- expect(item.proposer).toBe('홍길동의원 등 10인');
800
- expect(item.proposalDate).toBe('2026.05.01.');
801
- });
802
- test('parses committee and ministry', () => {
803
- const item = parser.parseList(NSM_LIST_HTML).items[0];
804
- expect(item.committee).toBe('과학기술정보방송통신위원회');
805
- expect(item.ministry).toBe('과학기술정보통신부');
806
- });
807
- test('parses progressStatus and progressDate', () => {
808
- const item = parser.parseList(NSM_LIST_HTML).items[0];
809
- expect(item.progressStatus).toBe('위원회 회부');
810
- expect(item.progressDate).toBe('2026.05.03.');
811
- });
812
- test('parses resolutionStatus and resolutionDate', () => {
813
- const item = parser.parseList(NSM_LIST_HTML).items[0];
814
- expect(item.resolutionStatus).toBe('원안가결');
815
- expect(item.resolutionDate).toBe('2026.05.20.');
816
- });
817
- test('pending bill: committee is empty string, ministry is set', () => {
818
- const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
819
- expect(item.committee).toBe('');
820
- expect(item.ministry).toBe('개인정보보호위원회');
821
- });
822
- test('pending bill: resolutionStatus and resolutionDate are empty strings', () => {
823
- const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
824
- expect(item.resolutionStatus).toBe('');
825
- expect(item.resolutionDate).toBe('');
826
- });
827
- test('pending bill: progressStatus is "발의"', () => {
828
- const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
829
- expect(item.progressStatus).toBe('발의');
830
- });
831
- test('falls back to items.length when pagination markup is absent', () => {
832
- const minimalHtml = `
833
- <table><tbody>
834
- <tr>
835
- <td data-th="의안명"><a href="/gcom/nsmLmSts/out/1/detailRP">A</a></td>
836
- <td data-th="제안자(제안일자)"><p>홍길동</p><p>(2026.01.01.)</p></td>
837
- <td data-th="상임위원회(소관부처)"></td>
838
- <td data-th="국회현황(추진일자)"><p>발의</p></td>
839
- <td data-th="의결현황(의결일자)"></td>
840
- </tr>
841
- </tbody></table>`;
842
- const result = parser.parseList(minimalHtml);
843
- expect(result.totalPages).toBe(1);
844
- expect(result.total).toBe(1);
845
- expect(result.currentPage).toBe(1);
846
- });
847
- test('skips rows without a bill name', () => {
848
- const htmlNoName = `
849
- <table><tbody>
850
- <tr>
851
- <td data-th="의안명"><a href="/gcom/nsmLmSts/out/1/detailRP"></a></td>
852
- <td data-th="제안자(제안일자)"></td>
853
- <td data-th="상임위원회(소관부처)"></td>
854
- <td data-th="국회현황(추진일자)"></td>
855
- <td data-th="의결현황(의결일자)"></td>
856
- </tr>
857
- </tbody></table>`;
858
- expect(parser.parseList(htmlNoName).items).toHaveLength(0);
859
- });
860
- });
861
- // ── parseDetail ────────────────────────────────────────────────────────────
862
- describe('parseDetail', () => {
863
- test('returns default empty object for empty string', () => {
864
- const detail = parser.parseDetail('');
865
- expect(detail.title).toBe('');
866
- expect(detail.billNo).toBe('');
867
- expect(detail.proposer).toBe('');
868
- expect(detail.proposalDate).toBe('');
869
- expect(detail.session).toBe('');
870
- expect(detail.proposalInfo).toBe('');
871
- expect(detail.proposalReason).toBeNull();
872
- expect(detail.attachments).toHaveLength(0);
873
- });
874
- test('picks title h2 (skips empty h2; h3 elements are section headers)', () => {
875
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
876
- expect(detail.title).toBe('인공지능 기본법 일부개정법률안');
877
- });
878
- test('parses proposalInfo raw text', () => {
879
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
880
- expect(detail.proposalInfo).toContain('제2219088호');
881
- expect(detail.proposalInfo).toContain('제435회 국회(임시회)');
882
- });
883
- test('extracts billNo from proposalInfo', () => {
884
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
885
- expect(detail.billNo).toBe('2219088');
886
- });
887
- test('extracts proposer from proposalInfo', () => {
888
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
889
- expect(detail.proposer).toBe('홍길동의원 등 10인');
890
- });
891
- test('extracts proposalDate from proposalInfo', () => {
892
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
893
- expect(detail.proposalDate).toBe('2026. 5. 1.');
894
- });
895
- test('extracts session from proposalInfo', () => {
896
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
897
- expect(detail.session).toBe('제435회 국회(임시회)');
898
- });
899
- test('parses attachments: fileId and filename (strips a11y_hidden span)', () => {
900
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
901
- expect(detail.attachments).toHaveLength(2);
902
- expect(detail.attachments[0].fileId).toBe('99001');
903
- expect(detail.attachments[0].filename).toBe('인공지능기본법안.hwp');
904
- expect(detail.attachments[1].fileId).toBe('99002');
905
- expect(detail.attachments[1].filename).toBe('인공지능기본법안검토보고서.pdf');
906
- });
907
- test('parses proposalReason from pre tag (br → newline)', () => {
908
- const detail = parser.parseDetail(NSM_DETAIL_HTML);
909
- expect(detail.proposalReason).toBe('인공지능 기술의 발전에 따라\n규제 체계를 정비하고자 함');
910
- });
911
- test('preserves blank lines in proposalReason', () => {
912
- const html = `
913
- <table>
914
- <tbody>
915
- <tr>
916
- <th>발의정보</th>
917
- <td>홍길동의원 등 10인, 제2219088호(2026. 5. 1.). 제435회 국회(임시회)</td>
918
- </tr>
919
- <tr>
920
- <th>제안이유</th>
921
- <td><pre>첫 번째 문장
922
-
923
- 두 번째 문장</pre></td>
924
- </tr>
925
- </tbody>
926
- </table>
927
- `;
928
- const detail = parser.parseDetail(html);
929
- expect(detail.proposalReason).toBe('첫 번째 문장\n\n두 번째 문장');
930
- });
931
- test('returns null proposalReason when pre tag is absent', () => {
932
- const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
933
- expect(detail.proposalReason).toBeNull();
934
- });
935
- test('returns empty attachments when no buttons are present', () => {
936
- const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
937
- expect(detail.attachments).toHaveLength(0);
938
- });
939
- test('single-proposer format parses without crash', () => {
940
- const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
941
- expect(detail.proposer).toBe('이영희의원');
942
- expect(detail.billNo).toBe('2200001');
943
- expect(detail.session).toBe('제433회 국회(임시회)');
944
- });
945
- });
946
- });
947
- // ─── NsmLmSts ─────────────────────────────────────────────────────────────────
948
- describe('NsmLmSts', () => {
949
- const nsm = new pal_1.NsmLmSts();
950
- describe('search title hydration', () => {
951
- test('skips detail requests when list title is not truncated', async () => {
952
- const instance = new pal_1.NsmLmSts();
953
- const httpClient = instance.httpClient;
954
- const mockGet = jest
955
- .spyOn(httpClient, 'get')
956
- .mockImplementation(async (...args) => {
957
- const url = args[0];
958
- const urlString = url.toString();
959
- if (urlString.includes('/detailRP')) {
960
- throw new Error('detail should not be requested for full title');
961
- }
962
- return NSM_LIST_HTML;
963
- });
964
- const result = await instance.search({ pageSize: 1 });
965
- expect(result.items).toHaveLength(1);
966
- expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
967
- expect(mockGet).toHaveBeenCalledTimes(1);
968
- mockGet.mockRestore();
969
- });
970
- test('uses detail html title when list title is truncated', async () => {
971
- const instance = new pal_1.NsmLmSts({ hydrateTruncatedTitles: true });
972
- const httpClient = instance.httpClient;
973
- const mockGet = jest
974
- .spyOn(httpClient, 'get')
975
- .mockImplementation(async (...args) => {
976
- const url = args[0];
977
- const urlString = url.toString();
978
- if (urlString.includes('/detailRP')) {
979
- return NSM_DETAIL_HTML;
980
- }
981
- return NSM_LIST_HTML_TRUNCATED_TITLE;
982
- });
983
- const result = await instance.search({ pageSize: 1 });
984
- expect(result.items).toHaveLength(1);
985
- expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
986
- mockGet.mockRestore();
987
- });
988
- test('keeps list title when detail request fails', async () => {
989
- const instance = new pal_1.NsmLmSts({ hydrateTruncatedTitles: true });
990
- const httpClient = instance.httpClient;
991
- const mockGet = jest
992
- .spyOn(httpClient, 'get')
993
- .mockImplementation(async (...args) => {
994
- const url = args[0];
995
- const urlString = url.toString();
996
- if (urlString.includes('/detailRP')) {
997
- throw new Error('detail fetch failed');
998
- }
999
- return NSM_LIST_HTML_TRUNCATED_TITLE;
1000
- });
1001
- const result = await instance.search({ pageSize: 1 });
1002
- expect(result.items).toHaveLength(1);
1003
- expect(result.items[0].billName).toBe('인공지능 기본법 일부개정...');
1004
- mockGet.mockRestore();
1005
- });
1006
- test('does not fetch details for truncated titles by default', async () => {
1007
- const instance = new pal_1.NsmLmSts();
1008
- const httpClient = instance.httpClient;
1009
- const mockGet = jest
1010
- .spyOn(httpClient, 'get')
1011
- .mockImplementation(async (...args) => {
1012
- const url = args[0];
1013
- expect(url.toString()).not.toContain('/detailRP');
1014
- return NSM_LIST_HTML_TRUNCATED_TITLE;
1015
- });
1016
- const result = await instance.search({ pageSize: 1 });
1017
- expect(result.items[0]?.billName).toBe('인공지능 기본법 일부개정...');
1018
- expect(mockGet).toHaveBeenCalledTimes(1);
1019
- mockGet.mockRestore();
1020
- });
1021
- });
1022
- // ── constructor ────────────────────────────────────────────────────────────
1023
- describe('constructor', () => {
1024
- test('creates instance without config', () => {
1025
- expect(new pal_1.NsmLmSts()).toBeInstanceOf(pal_1.NsmLmSts);
1026
- });
1027
- test('creates instance with empty config object', () => {
1028
- expect(new pal_1.NsmLmSts({})).toBeInstanceOf(pal_1.NsmLmSts);
1029
- });
1030
- test('accepts custom userAgent', () => {
1031
- expect(new pal_1.NsmLmSts({ userAgent: 'Custom Agent' })).toBeInstanceOf(pal_1.NsmLmSts);
1032
- });
1033
- test('accepts custom timeout and retryCount', () => {
1034
- expect(new pal_1.NsmLmSts({ timeout: 5000, retryCount: 2 })).toBeInstanceOf(pal_1.NsmLmSts);
1035
- });
1036
- test('accepts custom headers', () => {
1037
- expect(new pal_1.NsmLmSts({ customHeaders: { 'Accept-Language': 'ko-KR' } })).toBeInstanceOf(pal_1.NsmLmSts);
1038
- });
1039
- });
1040
- // ── parseList / parseDetail (delegation) ──────────────────────────────────
1041
- describe('parseList (delegation)', () => {
1042
- test('delegates to NsmLmStsParser and returns INsmSearchResult', () => {
1043
- const result = nsm.parseList(NSM_LIST_HTML);
1044
- expect(result.total).toBe(3142);
1045
- expect(result.items).toHaveLength(1);
1046
- expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
1047
- });
1048
- test('returns empty result for empty html', () => {
1049
- const result = nsm.parseList('');
1050
- expect(result.items).toHaveLength(0);
1051
- });
1052
- });
1053
- describe('parseDetail (delegation)', () => {
1054
- test('delegates to NsmLmStsParser and returns INsmBillDetail', () => {
1055
- const detail = nsm.parseDetail(NSM_DETAIL_HTML);
1056
- expect(detail.title).toBe('인공지능 기본법 일부개정법률안');
1057
- expect(detail.billNo).toBe('2219088');
1058
- expect(detail.attachments).toHaveLength(2);
1059
- });
1060
- });
1061
- // ── getDetailHTML validation ───────────────────────────────────────────────
1062
- describe('getDetailHTML input validation', () => {
1063
- test('throws when billNo is empty string', async () => {
1064
- await expect(nsm.getDetailHTML('')).rejects.toThrow('billNo is required');
1065
- });
1066
- test('throws when billNo is whitespace only', async () => {
1067
- await expect(nsm.getDetailHTML(' ')).rejects.toThrow('billNo is required');
1068
- });
1069
- });
1070
- // ── search integration ────────────────────────────────────────────────────
1071
- describe('search (integration)', () => {
1072
- let searchResult;
1073
- beforeAll(async () => {
1074
- searchResult = await nsm.search({ pageSize: 5 });
1075
- debugLog('nsm.search', searchResult);
1076
- });
1077
- test('returns non-garbled html string (getListHTML)', async () => {
1078
- const html = await nsm.getListHTML({ pageSize: 5 });
1079
- expect(typeof html).toBe('string');
1080
- expect(html.length).toBeGreaterThan(0);
1081
- expect(html).not.toContain('\ufffd');
1082
- });
1083
- test('returns INsmSearchResult with total > 0', () => {
1084
- expect(searchResult.total).toBeGreaterThan(0);
1085
- expect(searchResult.totalPages).toBeGreaterThan(0);
1086
- expect(searchResult.currentPage).toBe(1);
1087
- });
1088
- test('returns items with valid structure and no garbled text', () => {
1089
- expect(searchResult.items.length).toBeGreaterThan(0);
1090
- for (const item of searchResult.items) {
1091
- expect(item.billName).not.toContain('\ufffd');
1092
- expect(item.billNo).toMatch(/^\d+$/);
1093
- expect(item.link).toContain('/gcom/nsmLmSts/out/');
1094
- expect(item.proposer).not.toContain('\ufffd');
1095
- expect(item.proposer.length).toBeGreaterThan(0);
1096
- expect(item.proposalDate.length).toBeGreaterThan(0);
1097
- expect(item.progressStatus.length).toBeGreaterThan(0);
1098
- }
1099
- });
1100
- test('pageSize filter limits returned items', async () => {
1101
- const r5 = await nsm.search({ pageSize: 5 });
1102
- const r10 = await nsm.search({ pageSize: 10 });
1103
- expect(r5.items.length).toBeLessThanOrEqual(5);
1104
- expect(r10.items.length).toBeLessThanOrEqual(10);
1105
- });
1106
- test('pageIndex=2 returns different items from page 1', async () => {
1107
- const page1 = await nsm.search({ pageSize: 5, pageIndex: 1 });
1108
- const page2 = await nsm.search({ pageSize: 5, pageIndex: 2 });
1109
- expect(page1.items.length).toBeGreaterThan(0);
1110
- expect(page2.items.length).toBeGreaterThan(0);
1111
- expect(page1.items[0].billNo).not.toBe(page2.items[0].billNo);
1112
- });
1113
- });
1114
- // ── searchPending integration ─────────────────────────────────────────────
1115
- describe('searchPending (integration)', () => {
1116
- let pendingResult;
1117
- beforeAll(async () => {
1118
- pendingResult = await nsm.searchPending({ pageSize: 5 });
1119
- debugLog('nsm.searchPending', pendingResult);
1120
- });
1121
- test('returns INsmSearchResult with valid structure', () => {
1122
- expect(typeof pendingResult.total).toBe('number');
1123
- expect(typeof pendingResult.totalPages).toBe('number');
1124
- expect(pendingResult.currentPage).toBe(1);
1125
- });
1126
- test('all returned bills have progressStatus "발의"', () => {
1127
- for (const item of pendingResult.items) {
1128
- expect(item.progressStatus).toBe('발의');
1129
- }
1130
- });
1131
- test('all pending bills have no resolutionStatus', () => {
1132
- for (const item of pendingResult.items) {
1133
- expect(item.resolutionStatus).toBe('');
1134
- }
1135
- });
1136
- test('no garbled text in pending bill names', () => {
1137
- for (const item of pendingResult.items) {
1138
- expect(item.billName).not.toContain('\ufffd');
1139
- }
1140
- });
1141
- });
1142
- // ── getPage / getDetail integration ───────────────────────────────────────
1143
- describe('getPage / getDetail (integration)', () => {
1144
- let firstBillNo;
1145
- let pageItems;
1146
- beforeAll(async () => {
1147
- pageItems = await nsm.getPage(1, { pageSize: 5 });
1148
- firstBillNo = pageItems[0]?.billNo ?? '';
1149
- debugLog('nsm.getPage(1)', pageItems);
1150
- });
1151
- test('getPage(1): returns INsmBillItem array', () => {
1152
- expect(Array.isArray(pageItems)).toBe(true);
1153
- expect(pageItems.length).toBeGreaterThan(0);
1154
- });
1155
- test('getPage(2): returns different items from page 1', async () => {
1156
- const page2 = await nsm.getPage(2, { pageSize: 5 });
1157
- expect(page2.length).toBeGreaterThan(0);
1158
- expect(page2[0].billNo).not.toBe(pageItems[0].billNo);
1159
- });
1160
- test('getDetailHTML: returns non-empty, non-garbled html', async () => {
1161
- if (!firstBillNo)
1162
- return;
1163
- const html = await nsm.getDetailHTML(firstBillNo);
1164
- expect(typeof html).toBe('string');
1165
- expect(html.length).toBeGreaterThan(0);
1166
- expect(html).not.toContain('\ufffd');
1167
- });
1168
- test('getDetail: returns INsmBillDetail with valid fields', async () => {
1169
- if (!firstBillNo)
1170
- return;
1171
- const detail = await nsm.getDetail(firstBillNo);
1172
- debugLog('nsm.getDetail', detail);
1173
- expect(detail.title.length).toBeGreaterThan(0);
1174
- expect(detail.title).not.toContain('\ufffd');
1175
- expect(detail.billNo).toMatch(/^\d+$/);
1176
- expect(detail.proposer.length).toBeGreaterThan(0);
1177
- expect(detail.proposalDate.length).toBeGreaterThan(0);
1178
- });
1179
- });
1180
- // ── getAllPages / getAllPendingPages integration ───────────────────────────
1181
- describe('getAllPages / getAllPendingPages (integration)', () => {
1182
- test('getAllPages: yields exactly maxPages results', async () => {
1183
- const pages = [];
1184
- for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
1185
- pages.push(page);
1186
- }
1187
- expect(pages).toHaveLength(2);
1188
- expect(pages[0].items.length).toBeGreaterThan(0);
1189
- expect(pages[1].items.length).toBeGreaterThan(0);
1190
- });
1191
- test('getAllPages: currentPage increments correctly', async () => {
1192
- const pages = [];
1193
- for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 3, delayMs: 0 })) {
1194
- pages.push(page);
1195
- }
1196
- expect(pages[0].currentPage).toBe(1);
1197
- expect(pages[1].currentPage).toBe(2);
1198
- expect(pages[2].currentPage).toBe(3);
1199
- });
1200
- test('getAllPages: total is consistent across pages', async () => {
1201
- const pages = [];
1202
- for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
1203
- pages.push(page);
1204
- }
1205
- expect(pages[0].total).toBe(pages[1].total);
1206
- expect(pages[0].totalPages).toBe(pages[1].totalPages);
1207
- });
1208
- test('getAllPages: page 2 items differ from page 1', async () => {
1209
- const pages = [];
1210
- for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
1211
- pages.push(page);
1212
- }
1213
- expect(pages[0].items[0].billNo).not.toBe(pages[1].items[0].billNo);
1214
- });
1215
- test('getAllPages: concurrency > 1 yields pages in order', async () => {
1216
- const pages = [];
1217
- for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
1218
- pages.push(page);
1219
- }
1220
- expect(pages).toHaveLength(4);
1221
- for (let i = 0; i < pages.length; i++) {
1222
- expect(pages[i].currentPage).toBe(i + 1);
1223
- }
1224
- });
1225
- test('getAllPendingPages: yields at least 1 page of pending bills', async () => {
1226
- const pages = [];
1227
- for await (const page of nsm.getAllPendingPages({ pageSize: 5 }, { maxPages: 1, delayMs: 0 })) {
1228
- pages.push(page);
1229
- }
1230
- expect(pages.length).toBeGreaterThanOrEqual(1);
1231
- // Every item must be in 발의 상태
1232
- for (const page of pages) {
1233
- for (const item of page.items) {
1234
- expect(item.progressStatus).toBe('발의');
1235
- }
1236
- }
1237
- });
1238
- });
1239
- // ── Screenshot functionality ──────────────────────────────────────────────
1240
- describe('Screenshot functionality', () => {
1241
- test('creates instance with screenshot config', () => {
1242
- const instance = new pal_1.NsmLmSts({
1243
- screenshot: {
1244
- enabled: true,
1245
- fullPage: true,
1246
- width: 1280,
1247
- height: 800,
1248
- format: 'png',
1249
- },
1250
- });
1251
- expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1252
- });
1253
- test('throws error when screenshot is disabled', async () => {
1254
- const instance = new pal_1.NsmLmSts({ screenshot: { enabled: false } });
1255
- await expect(instance.takeScreenshot('https://example.com')).rejects.toThrow('Screenshot feature is not enabled');
1256
- });
1257
- test('accepts jpeg format with quality option', () => {
1258
- const instance = new pal_1.NsmLmSts({
1259
- screenshot: { enabled: true, format: 'jpeg', quality: 85 },
1260
- });
1261
- expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1262
- });
1263
- test('updateScreenshotConfig modifies screenshot settings', () => {
1264
- const instance = new pal_1.NsmLmSts({
1265
- screenshot: { enabled: true, format: 'png' },
1266
- });
1267
- instance.updateScreenshotConfig({ format: 'jpeg', quality: 70 });
1268
- expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1269
- });
1270
- test('initBrowser and closeBrowser can be called', async () => {
1271
- const instance = new pal_1.NsmLmSts({ screenshot: { enabled: true } });
1272
- expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1273
- });
1274
- });
1275
- });
1276
- //# sourceMappingURL=pal.test.js.map