pal-crawl 1.8.0 → 2.0.1
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/README.md +361 -0
- package/dist/config.d.ts +3 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/pal.d.ts +153 -43
- package/dist/pal.js +239 -58
- package/dist/pal.js.map +1 -1
- package/dist/pal.test.js +623 -17
- package/dist/pal.test.js.map +1 -1
- package/dist/parser.d.ts +56 -0
- package/dist/parser.js +187 -1
- package/dist/parser.js.map +1 -1
- package/package.json +1 -1
package/dist/pal.test.js
CHANGED
|
@@ -148,6 +148,137 @@ const SEARCH_RESULT_HTML = `
|
|
|
148
148
|
</tbody>
|
|
149
149
|
</table>
|
|
150
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
|
+
`;
|
|
151
282
|
// ─── PalParser ──────────────────────────────────────────────────────────────────────────────
|
|
152
283
|
describe('PalParser', () => {
|
|
153
284
|
const parser = new parser_1.PalParser();
|
|
@@ -198,7 +329,7 @@ describe('PalParser', () => {
|
|
|
198
329
|
});
|
|
199
330
|
test('strips the repeated heading line from proposal reason', () => {
|
|
200
331
|
const content = parser.parseContent(CONTENT_HTML);
|
|
201
|
-
// The desc starts with "제안이유 및 주요내용" which matches the heading
|
|
332
|
+
// The desc starts with "제안이유 및 주요내용" which matches the heading - it must be stripped.
|
|
202
333
|
expect(content.proposalReason).not.toMatch(/제안이유/);
|
|
203
334
|
});
|
|
204
335
|
test('returns empty title and all nulls for empty html', () => {
|
|
@@ -361,9 +492,10 @@ describe('PalCrawl', () => {
|
|
|
361
492
|
expect(listHtml.length).toBeGreaterThan(0);
|
|
362
493
|
expect(listHtml).not.toContain('�');
|
|
363
494
|
});
|
|
364
|
-
test('get: returns 10
|
|
495
|
+
test('get: returns items (up to 10 per page)', async () => {
|
|
365
496
|
const result = await palCrawl.get();
|
|
366
|
-
expect(result).
|
|
497
|
+
expect(result.length).toBeGreaterThan(0);
|
|
498
|
+
expect(result.length).toBeLessThanOrEqual(10);
|
|
367
499
|
});
|
|
368
500
|
test('get: each item has valid structure and no garbled text', () => {
|
|
369
501
|
ongoingTable.forEach((e) => {
|
|
@@ -466,10 +598,10 @@ describe('PalCrawl', () => {
|
|
|
466
598
|
expect(result.items.length).toBeGreaterThan(0);
|
|
467
599
|
});
|
|
468
600
|
test('search: billName filter returns matching items', async () => {
|
|
469
|
-
const result = await palCrawl.search({ billName: '
|
|
601
|
+
const result = await palCrawl.search({ billName: '개정법률' });
|
|
470
602
|
expect(result.items.length).toBeGreaterThan(0);
|
|
471
603
|
result.items.forEach((item) => {
|
|
472
|
-
expect(item.subject).toMatch(
|
|
604
|
+
expect(item.subject).toMatch(/개정법률/);
|
|
473
605
|
});
|
|
474
606
|
});
|
|
475
607
|
test('search: sortCol=BILL_NAME sortGbn=ASC returns alphabetically sorted items', async () => {
|
|
@@ -510,17 +642,17 @@ describe('PalCrawl', () => {
|
|
|
510
642
|
expect(page1).toHaveLength(legacy.length);
|
|
511
643
|
expect(page1[0].subject).toBe(legacy[0].subject);
|
|
512
644
|
});
|
|
513
|
-
test('
|
|
645
|
+
test('getDonePage(2): returns different items from page 1', async () => {
|
|
514
646
|
const [page1, page2] = await Promise.all([
|
|
515
|
-
palCrawl.
|
|
516
|
-
palCrawl.
|
|
647
|
+
palCrawl.getDonePage(1),
|
|
648
|
+
palCrawl.getDonePage(2),
|
|
517
649
|
]);
|
|
518
650
|
expect(page1.length).toBeGreaterThan(0);
|
|
519
651
|
expect(page2.length).toBeGreaterThan(0);
|
|
520
652
|
expect(page1[0].subject).not.toBe(page2[0].subject);
|
|
521
653
|
});
|
|
522
|
-
test('
|
|
523
|
-
const items = await palCrawl.
|
|
654
|
+
test('getDonePage: pageUnit=20 returns up to 20 items', async () => {
|
|
655
|
+
const items = await palCrawl.getDonePage(1, 20);
|
|
524
656
|
expect(items.length).toBeLessThanOrEqual(20);
|
|
525
657
|
expect(items.length).toBeGreaterThan(10);
|
|
526
658
|
});
|
|
@@ -543,18 +675,18 @@ describe('PalCrawl', () => {
|
|
|
543
675
|
expect(pages[0].items.length).toBeGreaterThan(0);
|
|
544
676
|
expect(pages[1].items.length).toBeGreaterThan(0);
|
|
545
677
|
});
|
|
546
|
-
test('
|
|
678
|
+
test('getAllDonePages: currentPage increments correctly', async () => {
|
|
547
679
|
const pages = [];
|
|
548
|
-
for await (const page of palCrawl.
|
|
680
|
+
for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 3, delayMs: 0 })) {
|
|
549
681
|
pages.push(page);
|
|
550
682
|
}
|
|
551
683
|
expect(pages[0].currentPage).toBe(1);
|
|
552
684
|
expect(pages[1].currentPage).toBe(2);
|
|
553
685
|
expect(pages[2].currentPage).toBe(3);
|
|
554
686
|
});
|
|
555
|
-
test('
|
|
687
|
+
test('getAllDonePages: total and totalPages are consistent across pages', async () => {
|
|
556
688
|
const pages = [];
|
|
557
|
-
for await (const page of palCrawl.
|
|
689
|
+
for await (const page of palCrawl.getAllDonePages({}, { maxPages: 2, delayMs: 0 })) {
|
|
558
690
|
pages.push(page);
|
|
559
691
|
}
|
|
560
692
|
expect(pages[0].total).toBe(pages[1].total);
|
|
@@ -567,9 +699,9 @@ describe('PalCrawl', () => {
|
|
|
567
699
|
}
|
|
568
700
|
expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
|
|
569
701
|
});
|
|
570
|
-
test('
|
|
702
|
+
test('getAllDonePages: concurrency > 1 yields pages in order', async () => {
|
|
571
703
|
const pages = [];
|
|
572
|
-
for await (const page of palCrawl.
|
|
704
|
+
for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
|
|
573
705
|
pages.push(page);
|
|
574
706
|
}
|
|
575
707
|
expect(pages).toHaveLength(4);
|
|
@@ -587,7 +719,7 @@ describe('PalCrawl', () => {
|
|
|
587
719
|
expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
|
|
588
720
|
});
|
|
589
721
|
});
|
|
590
|
-
// ──
|
|
722
|
+
// ── Screenshot functionality ─────────────────────────────────────────────────
|
|
591
723
|
describe('Screenshot functionality', () => {
|
|
592
724
|
test('creates instance with screenshot config', () => {
|
|
593
725
|
const config = {
|
|
@@ -632,4 +764,478 @@ describe('PalCrawl', () => {
|
|
|
632
764
|
});
|
|
633
765
|
});
|
|
634
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('returns null proposalReason when pre tag is absent', () => {
|
|
912
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
|
|
913
|
+
expect(detail.proposalReason).toBeNull();
|
|
914
|
+
});
|
|
915
|
+
test('returns empty attachments when no buttons are present', () => {
|
|
916
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
|
|
917
|
+
expect(detail.attachments).toHaveLength(0);
|
|
918
|
+
});
|
|
919
|
+
test('single-proposer format parses without crash', () => {
|
|
920
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
|
|
921
|
+
expect(detail.proposer).toBe('이영희의원');
|
|
922
|
+
expect(detail.billNo).toBe('2200001');
|
|
923
|
+
expect(detail.session).toBe('제433회 국회(임시회)');
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
// ─── NsmLmSts ─────────────────────────────────────────────────────────────────
|
|
928
|
+
describe('NsmLmSts', () => {
|
|
929
|
+
const nsm = new pal_1.NsmLmSts();
|
|
930
|
+
describe('search title hydration', () => {
|
|
931
|
+
test('skips detail requests when list title is not truncated', async () => {
|
|
932
|
+
const instance = new pal_1.NsmLmSts();
|
|
933
|
+
const httpClient = instance.httpClient;
|
|
934
|
+
const mockGet = jest
|
|
935
|
+
.spyOn(httpClient, 'get')
|
|
936
|
+
.mockImplementation(async (...args) => {
|
|
937
|
+
const url = args[0];
|
|
938
|
+
const urlString = url.toString();
|
|
939
|
+
if (urlString.includes('/detailRP')) {
|
|
940
|
+
throw new Error('detail should not be requested for full title');
|
|
941
|
+
}
|
|
942
|
+
return NSM_LIST_HTML;
|
|
943
|
+
});
|
|
944
|
+
const result = await instance.search({ pageSize: 1 });
|
|
945
|
+
expect(result.items).toHaveLength(1);
|
|
946
|
+
expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
|
|
947
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
948
|
+
mockGet.mockRestore();
|
|
949
|
+
});
|
|
950
|
+
test('uses detail html title when list title is truncated', async () => {
|
|
951
|
+
const instance = new pal_1.NsmLmSts();
|
|
952
|
+
const httpClient = instance.httpClient;
|
|
953
|
+
const mockGet = jest
|
|
954
|
+
.spyOn(httpClient, 'get')
|
|
955
|
+
.mockImplementation(async (...args) => {
|
|
956
|
+
const url = args[0];
|
|
957
|
+
const urlString = url.toString();
|
|
958
|
+
if (urlString.includes('/detailRP')) {
|
|
959
|
+
return NSM_DETAIL_HTML;
|
|
960
|
+
}
|
|
961
|
+
return NSM_LIST_HTML_TRUNCATED_TITLE;
|
|
962
|
+
});
|
|
963
|
+
const result = await instance.search({ pageSize: 1 });
|
|
964
|
+
expect(result.items).toHaveLength(1);
|
|
965
|
+
expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
|
|
966
|
+
mockGet.mockRestore();
|
|
967
|
+
});
|
|
968
|
+
test('keeps list title when detail request fails', async () => {
|
|
969
|
+
const instance = new pal_1.NsmLmSts();
|
|
970
|
+
const httpClient = instance.httpClient;
|
|
971
|
+
const mockGet = jest
|
|
972
|
+
.spyOn(httpClient, 'get')
|
|
973
|
+
.mockImplementation(async (...args) => {
|
|
974
|
+
const url = args[0];
|
|
975
|
+
const urlString = url.toString();
|
|
976
|
+
if (urlString.includes('/detailRP')) {
|
|
977
|
+
throw new Error('detail fetch failed');
|
|
978
|
+
}
|
|
979
|
+
return NSM_LIST_HTML_TRUNCATED_TITLE;
|
|
980
|
+
});
|
|
981
|
+
const result = await instance.search({ pageSize: 1 });
|
|
982
|
+
expect(result.items).toHaveLength(1);
|
|
983
|
+
expect(result.items[0].billName).toBe('인공지능 기본법 일부개정...');
|
|
984
|
+
mockGet.mockRestore();
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
// ── constructor ────────────────────────────────────────────────────────────
|
|
988
|
+
describe('constructor', () => {
|
|
989
|
+
test('creates instance without config', () => {
|
|
990
|
+
expect(new pal_1.NsmLmSts()).toBeInstanceOf(pal_1.NsmLmSts);
|
|
991
|
+
});
|
|
992
|
+
test('creates instance with empty config object', () => {
|
|
993
|
+
expect(new pal_1.NsmLmSts({})).toBeInstanceOf(pal_1.NsmLmSts);
|
|
994
|
+
});
|
|
995
|
+
test('accepts custom userAgent', () => {
|
|
996
|
+
expect(new pal_1.NsmLmSts({ userAgent: 'Custom Agent' })).toBeInstanceOf(pal_1.NsmLmSts);
|
|
997
|
+
});
|
|
998
|
+
test('accepts custom timeout and retryCount', () => {
|
|
999
|
+
expect(new pal_1.NsmLmSts({ timeout: 5000, retryCount: 2 })).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1000
|
+
});
|
|
1001
|
+
test('accepts custom headers', () => {
|
|
1002
|
+
expect(new pal_1.NsmLmSts({ customHeaders: { 'Accept-Language': 'ko-KR' } })).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
// ── parseList / parseDetail (delegation) ──────────────────────────────────
|
|
1006
|
+
describe('parseList (delegation)', () => {
|
|
1007
|
+
test('delegates to NsmLmStsParser and returns INsmSearchResult', () => {
|
|
1008
|
+
const result = nsm.parseList(NSM_LIST_HTML);
|
|
1009
|
+
expect(result.total).toBe(3142);
|
|
1010
|
+
expect(result.items).toHaveLength(1);
|
|
1011
|
+
expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
|
|
1012
|
+
});
|
|
1013
|
+
test('returns empty result for empty html', () => {
|
|
1014
|
+
const result = nsm.parseList('');
|
|
1015
|
+
expect(result.items).toHaveLength(0);
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
describe('parseDetail (delegation)', () => {
|
|
1019
|
+
test('delegates to NsmLmStsParser and returns INsmBillDetail', () => {
|
|
1020
|
+
const detail = nsm.parseDetail(NSM_DETAIL_HTML);
|
|
1021
|
+
expect(detail.title).toBe('인공지능 기본법 일부개정법률안');
|
|
1022
|
+
expect(detail.billNo).toBe('2219088');
|
|
1023
|
+
expect(detail.attachments).toHaveLength(2);
|
|
1024
|
+
});
|
|
1025
|
+
});
|
|
1026
|
+
// ── getDetailHTML validation ───────────────────────────────────────────────
|
|
1027
|
+
describe('getDetailHTML input validation', () => {
|
|
1028
|
+
test('throws when billNo is empty string', async () => {
|
|
1029
|
+
await expect(nsm.getDetailHTML('')).rejects.toThrow('billNo is required');
|
|
1030
|
+
});
|
|
1031
|
+
test('throws when billNo is whitespace only', async () => {
|
|
1032
|
+
await expect(nsm.getDetailHTML(' ')).rejects.toThrow('billNo is required');
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
// ── search integration ────────────────────────────────────────────────────
|
|
1036
|
+
describe('search (integration)', () => {
|
|
1037
|
+
let searchResult;
|
|
1038
|
+
beforeAll(async () => {
|
|
1039
|
+
searchResult = await nsm.search({ pageSize: 5 });
|
|
1040
|
+
debugLog('nsm.search', searchResult);
|
|
1041
|
+
});
|
|
1042
|
+
test('returns non-garbled html string (getListHTML)', async () => {
|
|
1043
|
+
const html = await nsm.getListHTML({ pageSize: 5 });
|
|
1044
|
+
expect(typeof html).toBe('string');
|
|
1045
|
+
expect(html.length).toBeGreaterThan(0);
|
|
1046
|
+
expect(html).not.toContain('\ufffd');
|
|
1047
|
+
});
|
|
1048
|
+
test('returns INsmSearchResult with total > 0', () => {
|
|
1049
|
+
expect(searchResult.total).toBeGreaterThan(0);
|
|
1050
|
+
expect(searchResult.totalPages).toBeGreaterThan(0);
|
|
1051
|
+
expect(searchResult.currentPage).toBe(1);
|
|
1052
|
+
});
|
|
1053
|
+
test('returns items with valid structure and no garbled text', () => {
|
|
1054
|
+
expect(searchResult.items.length).toBeGreaterThan(0);
|
|
1055
|
+
for (const item of searchResult.items) {
|
|
1056
|
+
expect(item.billName).not.toContain('\ufffd');
|
|
1057
|
+
expect(item.billNo).toMatch(/^\d+$/);
|
|
1058
|
+
expect(item.link).toContain('/gcom/nsmLmSts/out/');
|
|
1059
|
+
expect(item.proposer).not.toContain('\ufffd');
|
|
1060
|
+
expect(item.proposer.length).toBeGreaterThan(0);
|
|
1061
|
+
expect(item.proposalDate.length).toBeGreaterThan(0);
|
|
1062
|
+
expect(item.progressStatus.length).toBeGreaterThan(0);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
test('pageSize filter limits returned items', async () => {
|
|
1066
|
+
const r5 = await nsm.search({ pageSize: 5 });
|
|
1067
|
+
const r10 = await nsm.search({ pageSize: 10 });
|
|
1068
|
+
expect(r5.items.length).toBeLessThanOrEqual(5);
|
|
1069
|
+
expect(r10.items.length).toBeLessThanOrEqual(10);
|
|
1070
|
+
});
|
|
1071
|
+
test('pageIndex=2 returns different items from page 1', async () => {
|
|
1072
|
+
const page1 = await nsm.search({ pageSize: 5, pageIndex: 1 });
|
|
1073
|
+
const page2 = await nsm.search({ pageSize: 5, pageIndex: 2 });
|
|
1074
|
+
expect(page1.items.length).toBeGreaterThan(0);
|
|
1075
|
+
expect(page2.items.length).toBeGreaterThan(0);
|
|
1076
|
+
expect(page1.items[0].billNo).not.toBe(page2.items[0].billNo);
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
// ── searchPending integration ─────────────────────────────────────────────
|
|
1080
|
+
describe('searchPending (integration)', () => {
|
|
1081
|
+
let pendingResult;
|
|
1082
|
+
beforeAll(async () => {
|
|
1083
|
+
pendingResult = await nsm.searchPending({ pageSize: 5 });
|
|
1084
|
+
debugLog('nsm.searchPending', pendingResult);
|
|
1085
|
+
});
|
|
1086
|
+
test('returns INsmSearchResult with valid structure', () => {
|
|
1087
|
+
expect(typeof pendingResult.total).toBe('number');
|
|
1088
|
+
expect(typeof pendingResult.totalPages).toBe('number');
|
|
1089
|
+
expect(pendingResult.currentPage).toBe(1);
|
|
1090
|
+
});
|
|
1091
|
+
test('all returned bills have progressStatus "발의"', () => {
|
|
1092
|
+
for (const item of pendingResult.items) {
|
|
1093
|
+
expect(item.progressStatus).toBe('발의');
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
test('all pending bills have no resolutionStatus', () => {
|
|
1097
|
+
for (const item of pendingResult.items) {
|
|
1098
|
+
expect(item.resolutionStatus).toBe('');
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
test('no garbled text in pending bill names', () => {
|
|
1102
|
+
for (const item of pendingResult.items) {
|
|
1103
|
+
expect(item.billName).not.toContain('\ufffd');
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
});
|
|
1107
|
+
// ── getPage / getDetail integration ───────────────────────────────────────
|
|
1108
|
+
describe('getPage / getDetail (integration)', () => {
|
|
1109
|
+
let firstBillNo;
|
|
1110
|
+
let pageItems;
|
|
1111
|
+
beforeAll(async () => {
|
|
1112
|
+
pageItems = await nsm.getPage(1, { pageSize: 5 });
|
|
1113
|
+
firstBillNo = pageItems[0]?.billNo ?? '';
|
|
1114
|
+
debugLog('nsm.getPage(1)', pageItems);
|
|
1115
|
+
});
|
|
1116
|
+
test('getPage(1): returns INsmBillItem array', () => {
|
|
1117
|
+
expect(Array.isArray(pageItems)).toBe(true);
|
|
1118
|
+
expect(pageItems.length).toBeGreaterThan(0);
|
|
1119
|
+
});
|
|
1120
|
+
test('getPage(2): returns different items from page 1', async () => {
|
|
1121
|
+
const page2 = await nsm.getPage(2, { pageSize: 5 });
|
|
1122
|
+
expect(page2.length).toBeGreaterThan(0);
|
|
1123
|
+
expect(page2[0].billNo).not.toBe(pageItems[0].billNo);
|
|
1124
|
+
});
|
|
1125
|
+
test('getDetailHTML: returns non-empty, non-garbled html', async () => {
|
|
1126
|
+
if (!firstBillNo)
|
|
1127
|
+
return;
|
|
1128
|
+
const html = await nsm.getDetailHTML(firstBillNo);
|
|
1129
|
+
expect(typeof html).toBe('string');
|
|
1130
|
+
expect(html.length).toBeGreaterThan(0);
|
|
1131
|
+
expect(html).not.toContain('\ufffd');
|
|
1132
|
+
});
|
|
1133
|
+
test('getDetail: returns INsmBillDetail with valid fields', async () => {
|
|
1134
|
+
if (!firstBillNo)
|
|
1135
|
+
return;
|
|
1136
|
+
const detail = await nsm.getDetail(firstBillNo);
|
|
1137
|
+
debugLog('nsm.getDetail', detail);
|
|
1138
|
+
expect(detail.title.length).toBeGreaterThan(0);
|
|
1139
|
+
expect(detail.title).not.toContain('\ufffd');
|
|
1140
|
+
expect(detail.billNo).toMatch(/^\d+$/);
|
|
1141
|
+
expect(detail.proposer.length).toBeGreaterThan(0);
|
|
1142
|
+
expect(detail.proposalDate.length).toBeGreaterThan(0);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
// ── getAllPages / getAllPendingPages integration ───────────────────────────
|
|
1146
|
+
describe('getAllPages / getAllPendingPages (integration)', () => {
|
|
1147
|
+
test('getAllPages: yields exactly maxPages results', async () => {
|
|
1148
|
+
const pages = [];
|
|
1149
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
|
|
1150
|
+
pages.push(page);
|
|
1151
|
+
}
|
|
1152
|
+
expect(pages).toHaveLength(2);
|
|
1153
|
+
expect(pages[0].items.length).toBeGreaterThan(0);
|
|
1154
|
+
expect(pages[1].items.length).toBeGreaterThan(0);
|
|
1155
|
+
});
|
|
1156
|
+
test('getAllPages: currentPage increments correctly', async () => {
|
|
1157
|
+
const pages = [];
|
|
1158
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 3, delayMs: 0 })) {
|
|
1159
|
+
pages.push(page);
|
|
1160
|
+
}
|
|
1161
|
+
expect(pages[0].currentPage).toBe(1);
|
|
1162
|
+
expect(pages[1].currentPage).toBe(2);
|
|
1163
|
+
expect(pages[2].currentPage).toBe(3);
|
|
1164
|
+
});
|
|
1165
|
+
test('getAllPages: total is consistent across pages', async () => {
|
|
1166
|
+
const pages = [];
|
|
1167
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
|
|
1168
|
+
pages.push(page);
|
|
1169
|
+
}
|
|
1170
|
+
expect(pages[0].total).toBe(pages[1].total);
|
|
1171
|
+
expect(pages[0].totalPages).toBe(pages[1].totalPages);
|
|
1172
|
+
});
|
|
1173
|
+
test('getAllPages: page 2 items differ from page 1', async () => {
|
|
1174
|
+
const pages = [];
|
|
1175
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
|
|
1176
|
+
pages.push(page);
|
|
1177
|
+
}
|
|
1178
|
+
expect(pages[0].items[0].billNo).not.toBe(pages[1].items[0].billNo);
|
|
1179
|
+
});
|
|
1180
|
+
test('getAllPages: concurrency > 1 yields pages in order', async () => {
|
|
1181
|
+
const pages = [];
|
|
1182
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
|
|
1183
|
+
pages.push(page);
|
|
1184
|
+
}
|
|
1185
|
+
expect(pages).toHaveLength(4);
|
|
1186
|
+
for (let i = 0; i < pages.length; i++) {
|
|
1187
|
+
expect(pages[i].currentPage).toBe(i + 1);
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
test('getAllPendingPages: yields at least 1 page of pending bills', async () => {
|
|
1191
|
+
const pages = [];
|
|
1192
|
+
for await (const page of nsm.getAllPendingPages({ pageSize: 5 }, { maxPages: 1, delayMs: 0 })) {
|
|
1193
|
+
pages.push(page);
|
|
1194
|
+
}
|
|
1195
|
+
expect(pages.length).toBeGreaterThanOrEqual(1);
|
|
1196
|
+
// Every item must be in 발의 상태
|
|
1197
|
+
for (const page of pages) {
|
|
1198
|
+
for (const item of page.items) {
|
|
1199
|
+
expect(item.progressStatus).toBe('발의');
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
// ── Screenshot functionality ──────────────────────────────────────────────
|
|
1205
|
+
describe('Screenshot functionality', () => {
|
|
1206
|
+
test('creates instance with screenshot config', () => {
|
|
1207
|
+
const instance = new pal_1.NsmLmSts({
|
|
1208
|
+
screenshot: {
|
|
1209
|
+
enabled: true,
|
|
1210
|
+
fullPage: true,
|
|
1211
|
+
width: 1280,
|
|
1212
|
+
height: 800,
|
|
1213
|
+
format: 'png',
|
|
1214
|
+
},
|
|
1215
|
+
});
|
|
1216
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1217
|
+
});
|
|
1218
|
+
test('throws error when screenshot is disabled', async () => {
|
|
1219
|
+
const instance = new pal_1.NsmLmSts({ screenshot: { enabled: false } });
|
|
1220
|
+
await expect(instance.takeScreenshot('https://example.com')).rejects.toThrow('Screenshot feature is not enabled');
|
|
1221
|
+
});
|
|
1222
|
+
test('accepts jpeg format with quality option', () => {
|
|
1223
|
+
const instance = new pal_1.NsmLmSts({
|
|
1224
|
+
screenshot: { enabled: true, format: 'jpeg', quality: 85 },
|
|
1225
|
+
});
|
|
1226
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1227
|
+
});
|
|
1228
|
+
test('updateScreenshotConfig modifies screenshot settings', () => {
|
|
1229
|
+
const instance = new pal_1.NsmLmSts({
|
|
1230
|
+
screenshot: { enabled: true, format: 'png' },
|
|
1231
|
+
});
|
|
1232
|
+
instance.updateScreenshotConfig({ format: 'jpeg', quality: 70 });
|
|
1233
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1234
|
+
});
|
|
1235
|
+
test('initBrowser and closeBrowser can be called', async () => {
|
|
1236
|
+
const instance = new pal_1.NsmLmSts({ screenshot: { enabled: true } });
|
|
1237
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
});
|
|
635
1241
|
//# sourceMappingURL=pal.test.js.map
|