pal-crawl 1.7.0 → 2.0.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/README.md +457 -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 +160 -11
- package/dist/pal.js +264 -11
- package/dist/pal.js.map +1 -1
- package/dist/pal.test.js +586 -16
- 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 +3 -2
package/dist/pal.test.js
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const pal_1 = require("./pal");
|
|
4
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);
|
|
5
12
|
const FIXED_ONGOING_CONTENT_ID = 'PRC_W2W6V0D4D0B9C1B4B4Z6V2W0U7V2T9';
|
|
6
13
|
const FIXED_DONE_CONTENT_ID = 'PRC_S2R6N0M3K2J3K1J5K3S8R3P4O3P5X6';
|
|
7
14
|
const SHOULD_DEBUG = process.env.PAL_CRAWL_TEST_DEBUG === '1';
|
|
@@ -141,6 +148,107 @@ const SEARCH_RESULT_HTML = `
|
|
|
141
148
|
</tbody>
|
|
142
149
|
</table>
|
|
143
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_DETAIL_HTML = `
|
|
210
|
+
<h2></h2>
|
|
211
|
+
<h2>인공지능 기본법 일부개정법률안</h2>
|
|
212
|
+
<h3>기본정보</h3>
|
|
213
|
+
<h3>국회입법현황</h3>
|
|
214
|
+
<table>
|
|
215
|
+
<tbody>
|
|
216
|
+
<tr>
|
|
217
|
+
<th>발의정보</th>
|
|
218
|
+
<td>홍길동의원 등 10인, 제2219088호(2026. 5. 1.). 제435회 국회(임시회)</td>
|
|
219
|
+
</tr>
|
|
220
|
+
<tr>
|
|
221
|
+
<th>의안원문</th>
|
|
222
|
+
<td>
|
|
223
|
+
<button onclick="fnDownload(99001)">인공지능기본법안.hwp<span class="a11y_hidden">다운로드</span></button>
|
|
224
|
+
<button onclick="fnDownload(99002)">인공지능기본법안검토보고서.pdf<span class="a11y_hidden">다운로드</span></button>
|
|
225
|
+
</td>
|
|
226
|
+
</tr>
|
|
227
|
+
<tr>
|
|
228
|
+
<th>제안이유 및 주요내용</th>
|
|
229
|
+
<td>
|
|
230
|
+
<pre>인공지능 기술의 발전에 따라<br/>규제 체계를 정비하고자 함</pre>
|
|
231
|
+
</td>
|
|
232
|
+
</tr>
|
|
233
|
+
</tbody>
|
|
234
|
+
</table>
|
|
235
|
+
`;
|
|
236
|
+
/** 상세 페이지 - 제안이유 없음 (pre 태그 없는 경우) */
|
|
237
|
+
const NSM_DETAIL_HTML_NO_REASON = `
|
|
238
|
+
<h2>최소 법률안</h2>
|
|
239
|
+
<table>
|
|
240
|
+
<tbody>
|
|
241
|
+
<tr>
|
|
242
|
+
<th>발의정보</th>
|
|
243
|
+
<td>이영희의원, 제2200001호(2026. 1. 5.). 제433회 국회(임시회)</td>
|
|
244
|
+
</tr>
|
|
245
|
+
<tr>
|
|
246
|
+
<th>의안원문</th>
|
|
247
|
+
<td></td>
|
|
248
|
+
</tr>
|
|
249
|
+
</tbody>
|
|
250
|
+
</table>
|
|
251
|
+
`;
|
|
144
252
|
// ─── PalParser ──────────────────────────────────────────────────────────────────────────────
|
|
145
253
|
describe('PalParser', () => {
|
|
146
254
|
const parser = new parser_1.PalParser();
|
|
@@ -191,7 +299,7 @@ describe('PalParser', () => {
|
|
|
191
299
|
});
|
|
192
300
|
test('strips the repeated heading line from proposal reason', () => {
|
|
193
301
|
const content = parser.parseContent(CONTENT_HTML);
|
|
194
|
-
// The desc starts with "제안이유 및 주요내용" which matches the heading
|
|
302
|
+
// The desc starts with "제안이유 및 주요내용" which matches the heading - it must be stripped.
|
|
195
303
|
expect(content.proposalReason).not.toMatch(/제안이유/);
|
|
196
304
|
});
|
|
197
305
|
test('returns empty title and all nulls for empty html', () => {
|
|
@@ -354,9 +462,10 @@ describe('PalCrawl', () => {
|
|
|
354
462
|
expect(listHtml.length).toBeGreaterThan(0);
|
|
355
463
|
expect(listHtml).not.toContain('�');
|
|
356
464
|
});
|
|
357
|
-
test('get: returns 10
|
|
465
|
+
test('get: returns items (up to 10 per page)', async () => {
|
|
358
466
|
const result = await palCrawl.get();
|
|
359
|
-
expect(result).
|
|
467
|
+
expect(result.length).toBeGreaterThan(0);
|
|
468
|
+
expect(result.length).toBeLessThanOrEqual(10);
|
|
360
469
|
});
|
|
361
470
|
test('get: each item has valid structure and no garbled text', () => {
|
|
362
471
|
ongoingTable.forEach((e) => {
|
|
@@ -459,10 +568,10 @@ describe('PalCrawl', () => {
|
|
|
459
568
|
expect(result.items.length).toBeGreaterThan(0);
|
|
460
569
|
});
|
|
461
570
|
test('search: billName filter returns matching items', async () => {
|
|
462
|
-
const result = await palCrawl.search({ billName: '
|
|
571
|
+
const result = await palCrawl.search({ billName: '개정법률' });
|
|
463
572
|
expect(result.items.length).toBeGreaterThan(0);
|
|
464
573
|
result.items.forEach((item) => {
|
|
465
|
-
expect(item.subject).toMatch(
|
|
574
|
+
expect(item.subject).toMatch(/개정법률/);
|
|
466
575
|
});
|
|
467
576
|
});
|
|
468
577
|
test('search: sortCol=BILL_NAME sortGbn=ASC returns alphabetically sorted items', async () => {
|
|
@@ -503,17 +612,17 @@ describe('PalCrawl', () => {
|
|
|
503
612
|
expect(page1).toHaveLength(legacy.length);
|
|
504
613
|
expect(page1[0].subject).toBe(legacy[0].subject);
|
|
505
614
|
});
|
|
506
|
-
test('
|
|
615
|
+
test('getDonePage(2): returns different items from page 1', async () => {
|
|
507
616
|
const [page1, page2] = await Promise.all([
|
|
508
|
-
palCrawl.
|
|
509
|
-
palCrawl.
|
|
617
|
+
palCrawl.getDonePage(1),
|
|
618
|
+
palCrawl.getDonePage(2),
|
|
510
619
|
]);
|
|
511
620
|
expect(page1.length).toBeGreaterThan(0);
|
|
512
621
|
expect(page2.length).toBeGreaterThan(0);
|
|
513
622
|
expect(page1[0].subject).not.toBe(page2[0].subject);
|
|
514
623
|
});
|
|
515
|
-
test('
|
|
516
|
-
const items = await palCrawl.
|
|
624
|
+
test('getDonePage: pageUnit=20 returns up to 20 items', async () => {
|
|
625
|
+
const items = await palCrawl.getDonePage(1, 20);
|
|
517
626
|
expect(items.length).toBeLessThanOrEqual(20);
|
|
518
627
|
expect(items.length).toBeGreaterThan(10);
|
|
519
628
|
});
|
|
@@ -536,18 +645,18 @@ describe('PalCrawl', () => {
|
|
|
536
645
|
expect(pages[0].items.length).toBeGreaterThan(0);
|
|
537
646
|
expect(pages[1].items.length).toBeGreaterThan(0);
|
|
538
647
|
});
|
|
539
|
-
test('
|
|
648
|
+
test('getAllDonePages: currentPage increments correctly', async () => {
|
|
540
649
|
const pages = [];
|
|
541
|
-
for await (const page of palCrawl.
|
|
650
|
+
for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 3, delayMs: 0 })) {
|
|
542
651
|
pages.push(page);
|
|
543
652
|
}
|
|
544
653
|
expect(pages[0].currentPage).toBe(1);
|
|
545
654
|
expect(pages[1].currentPage).toBe(2);
|
|
546
655
|
expect(pages[2].currentPage).toBe(3);
|
|
547
656
|
});
|
|
548
|
-
test('
|
|
657
|
+
test('getAllDonePages: total and totalPages are consistent across pages', async () => {
|
|
549
658
|
const pages = [];
|
|
550
|
-
for await (const page of palCrawl.
|
|
659
|
+
for await (const page of palCrawl.getAllDonePages({}, { maxPages: 2, delayMs: 0 })) {
|
|
551
660
|
pages.push(page);
|
|
552
661
|
}
|
|
553
662
|
expect(pages[0].total).toBe(pages[1].total);
|
|
@@ -560,9 +669,9 @@ describe('PalCrawl', () => {
|
|
|
560
669
|
}
|
|
561
670
|
expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
|
|
562
671
|
});
|
|
563
|
-
test('
|
|
672
|
+
test('getAllDonePages: concurrency > 1 yields pages in order', async () => {
|
|
564
673
|
const pages = [];
|
|
565
|
-
for await (const page of palCrawl.
|
|
674
|
+
for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
|
|
566
675
|
pages.push(page);
|
|
567
676
|
}
|
|
568
677
|
expect(pages).toHaveLength(4);
|
|
@@ -580,5 +689,466 @@ describe('PalCrawl', () => {
|
|
|
580
689
|
expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
|
|
581
690
|
});
|
|
582
691
|
});
|
|
692
|
+
// ── Screenshot functionality ─────────────────────────────────────────────────
|
|
693
|
+
describe('Screenshot functionality', () => {
|
|
694
|
+
test('creates instance with screenshot config', () => {
|
|
695
|
+
const config = {
|
|
696
|
+
screenshot: {
|
|
697
|
+
enabled: true,
|
|
698
|
+
fullPage: true,
|
|
699
|
+
width: 1920,
|
|
700
|
+
height: 1080,
|
|
701
|
+
format: 'png',
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
expect(new pal_1.PalCrawl(config)).toBeInstanceOf(pal_1.PalCrawl);
|
|
705
|
+
});
|
|
706
|
+
test('throws error when screenshot is disabled', async () => {
|
|
707
|
+
const crawler = new pal_1.PalCrawl({ screenshot: { enabled: false } });
|
|
708
|
+
await expect(crawler.takeScreenshot('https://example.com')).rejects.toThrow('Screenshot feature is not enabled');
|
|
709
|
+
});
|
|
710
|
+
test('accepts jpeg format with quality option', () => {
|
|
711
|
+
const config = {
|
|
712
|
+
screenshot: {
|
|
713
|
+
enabled: true,
|
|
714
|
+
format: 'jpeg',
|
|
715
|
+
quality: 90,
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
expect(new pal_1.PalCrawl(config)).toBeInstanceOf(pal_1.PalCrawl);
|
|
719
|
+
});
|
|
720
|
+
test('updateScreenshotConfig modifies screenshot settings', () => {
|
|
721
|
+
const crawler = new pal_1.PalCrawl({
|
|
722
|
+
screenshot: { enabled: true, format: 'png' },
|
|
723
|
+
});
|
|
724
|
+
crawler.updateScreenshotConfig({ format: 'jpeg', quality: 75 });
|
|
725
|
+
expect(crawler).toBeInstanceOf(pal_1.PalCrawl);
|
|
726
|
+
});
|
|
727
|
+
test('initBrowser and closeBrowser can be called', async () => {
|
|
728
|
+
const crawler = new pal_1.PalCrawl({
|
|
729
|
+
screenshot: { enabled: true },
|
|
730
|
+
});
|
|
731
|
+
// Note: These tests don't actually require a browser to pass.
|
|
732
|
+
// In a real scenario with HEADLESS_BROWSER env set, these would work.
|
|
733
|
+
expect(crawler).toBeInstanceOf(pal_1.PalCrawl);
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
// ─── NsmLmStsParser ──────────────────────────────────────────────────────────
|
|
738
|
+
describe('NsmLmStsParser', () => {
|
|
739
|
+
const parser = new parser_1.NsmLmStsParser();
|
|
740
|
+
// ── parseList ──────────────────────────────────────────────────────────────
|
|
741
|
+
describe('parseList', () => {
|
|
742
|
+
test('returns empty result for empty string', () => {
|
|
743
|
+
const result = parser.parseList('');
|
|
744
|
+
expect(result.total).toBe(0);
|
|
745
|
+
expect(result.totalPages).toBe(0);
|
|
746
|
+
expect(result.currentPage).toBe(1);
|
|
747
|
+
expect(result.items).toHaveLength(0);
|
|
748
|
+
});
|
|
749
|
+
test('returns empty result for invalid html', () => {
|
|
750
|
+
const result = parser.parseList('not html at all');
|
|
751
|
+
expect(result.items).toHaveLength(0);
|
|
752
|
+
});
|
|
753
|
+
test('parses pagination: total, totalPages, currentPage', () => {
|
|
754
|
+
const result = parser.parseList(NSM_LIST_HTML);
|
|
755
|
+
expect(result.total).toBe(3142);
|
|
756
|
+
expect(result.totalPages).toBe(315);
|
|
757
|
+
expect(result.currentPage).toBe(2);
|
|
758
|
+
});
|
|
759
|
+
test('parses bill name and link', () => {
|
|
760
|
+
const result = parser.parseList(NSM_LIST_HTML);
|
|
761
|
+
expect(result.items).toHaveLength(1);
|
|
762
|
+
const item = result.items[0];
|
|
763
|
+
expect(item.billName).toBe('인공지능 기본법 일부개정법률안');
|
|
764
|
+
expect(item.billNo).toBe('2219088');
|
|
765
|
+
expect(item.link).toContain('2219088/detailRP');
|
|
766
|
+
});
|
|
767
|
+
test('parses proposer and proposalDate (strips parentheses)', () => {
|
|
768
|
+
const item = parser.parseList(NSM_LIST_HTML).items[0];
|
|
769
|
+
expect(item.proposer).toBe('홍길동의원 등 10인');
|
|
770
|
+
expect(item.proposalDate).toBe('2026.05.01.');
|
|
771
|
+
});
|
|
772
|
+
test('parses committee and ministry', () => {
|
|
773
|
+
const item = parser.parseList(NSM_LIST_HTML).items[0];
|
|
774
|
+
expect(item.committee).toBe('과학기술정보방송통신위원회');
|
|
775
|
+
expect(item.ministry).toBe('과학기술정보통신부');
|
|
776
|
+
});
|
|
777
|
+
test('parses progressStatus and progressDate', () => {
|
|
778
|
+
const item = parser.parseList(NSM_LIST_HTML).items[0];
|
|
779
|
+
expect(item.progressStatus).toBe('위원회 회부');
|
|
780
|
+
expect(item.progressDate).toBe('2026.05.03.');
|
|
781
|
+
});
|
|
782
|
+
test('parses resolutionStatus and resolutionDate', () => {
|
|
783
|
+
const item = parser.parseList(NSM_LIST_HTML).items[0];
|
|
784
|
+
expect(item.resolutionStatus).toBe('원안가결');
|
|
785
|
+
expect(item.resolutionDate).toBe('2026.05.20.');
|
|
786
|
+
});
|
|
787
|
+
test('pending bill: committee is empty string, ministry is set', () => {
|
|
788
|
+
const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
|
|
789
|
+
expect(item.committee).toBe('');
|
|
790
|
+
expect(item.ministry).toBe('개인정보보호위원회');
|
|
791
|
+
});
|
|
792
|
+
test('pending bill: resolutionStatus and resolutionDate are empty strings', () => {
|
|
793
|
+
const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
|
|
794
|
+
expect(item.resolutionStatus).toBe('');
|
|
795
|
+
expect(item.resolutionDate).toBe('');
|
|
796
|
+
});
|
|
797
|
+
test('pending bill: progressStatus is "발의"', () => {
|
|
798
|
+
const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
|
|
799
|
+
expect(item.progressStatus).toBe('발의');
|
|
800
|
+
});
|
|
801
|
+
test('falls back to items.length when pagination markup is absent', () => {
|
|
802
|
+
const minimalHtml = `
|
|
803
|
+
<table><tbody>
|
|
804
|
+
<tr>
|
|
805
|
+
<td data-th="의안명"><a href="/gcom/nsmLmSts/out/1/detailRP">A</a></td>
|
|
806
|
+
<td data-th="제안자(제안일자)"><p>홍길동</p><p>(2026.01.01.)</p></td>
|
|
807
|
+
<td data-th="상임위원회(소관부처)"></td>
|
|
808
|
+
<td data-th="국회현황(추진일자)"><p>발의</p></td>
|
|
809
|
+
<td data-th="의결현황(의결일자)"></td>
|
|
810
|
+
</tr>
|
|
811
|
+
</tbody></table>`;
|
|
812
|
+
const result = parser.parseList(minimalHtml);
|
|
813
|
+
expect(result.totalPages).toBe(1);
|
|
814
|
+
expect(result.total).toBe(1);
|
|
815
|
+
expect(result.currentPage).toBe(1);
|
|
816
|
+
});
|
|
817
|
+
test('skips rows without a bill name', () => {
|
|
818
|
+
const htmlNoName = `
|
|
819
|
+
<table><tbody>
|
|
820
|
+
<tr>
|
|
821
|
+
<td data-th="의안명"><a href="/gcom/nsmLmSts/out/1/detailRP"></a></td>
|
|
822
|
+
<td data-th="제안자(제안일자)"></td>
|
|
823
|
+
<td data-th="상임위원회(소관부처)"></td>
|
|
824
|
+
<td data-th="국회현황(추진일자)"></td>
|
|
825
|
+
<td data-th="의결현황(의결일자)"></td>
|
|
826
|
+
</tr>
|
|
827
|
+
</tbody></table>`;
|
|
828
|
+
expect(parser.parseList(htmlNoName).items).toHaveLength(0);
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
// ── parseDetail ────────────────────────────────────────────────────────────
|
|
832
|
+
describe('parseDetail', () => {
|
|
833
|
+
test('returns default empty object for empty string', () => {
|
|
834
|
+
const detail = parser.parseDetail('');
|
|
835
|
+
expect(detail.title).toBe('');
|
|
836
|
+
expect(detail.billNo).toBe('');
|
|
837
|
+
expect(detail.proposer).toBe('');
|
|
838
|
+
expect(detail.proposalDate).toBe('');
|
|
839
|
+
expect(detail.session).toBe('');
|
|
840
|
+
expect(detail.proposalInfo).toBe('');
|
|
841
|
+
expect(detail.proposalReason).toBeNull();
|
|
842
|
+
expect(detail.attachments).toHaveLength(0);
|
|
843
|
+
});
|
|
844
|
+
test('picks title h2 (skips empty h2; h3 elements are section headers)', () => {
|
|
845
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
846
|
+
expect(detail.title).toBe('인공지능 기본법 일부개정법률안');
|
|
847
|
+
});
|
|
848
|
+
test('parses proposalInfo raw text', () => {
|
|
849
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
850
|
+
expect(detail.proposalInfo).toContain('제2219088호');
|
|
851
|
+
expect(detail.proposalInfo).toContain('제435회 국회(임시회)');
|
|
852
|
+
});
|
|
853
|
+
test('extracts billNo from proposalInfo', () => {
|
|
854
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
855
|
+
expect(detail.billNo).toBe('2219088');
|
|
856
|
+
});
|
|
857
|
+
test('extracts proposer from proposalInfo', () => {
|
|
858
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
859
|
+
expect(detail.proposer).toBe('홍길동의원 등 10인');
|
|
860
|
+
});
|
|
861
|
+
test('extracts proposalDate from proposalInfo', () => {
|
|
862
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
863
|
+
expect(detail.proposalDate).toBe('2026. 5. 1.');
|
|
864
|
+
});
|
|
865
|
+
test('extracts session from proposalInfo', () => {
|
|
866
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
867
|
+
expect(detail.session).toBe('제435회 국회(임시회)');
|
|
868
|
+
});
|
|
869
|
+
test('parses attachments: fileId and filename (strips a11y_hidden span)', () => {
|
|
870
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
871
|
+
expect(detail.attachments).toHaveLength(2);
|
|
872
|
+
expect(detail.attachments[0].fileId).toBe('99001');
|
|
873
|
+
expect(detail.attachments[0].filename).toBe('인공지능기본법안.hwp');
|
|
874
|
+
expect(detail.attachments[1].fileId).toBe('99002');
|
|
875
|
+
expect(detail.attachments[1].filename).toBe('인공지능기본법안검토보고서.pdf');
|
|
876
|
+
});
|
|
877
|
+
test('parses proposalReason from pre tag (br → newline)', () => {
|
|
878
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML);
|
|
879
|
+
expect(detail.proposalReason).toBe('인공지능 기술의 발전에 따라\n규제 체계를 정비하고자 함');
|
|
880
|
+
});
|
|
881
|
+
test('returns null proposalReason when pre tag is absent', () => {
|
|
882
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
|
|
883
|
+
expect(detail.proposalReason).toBeNull();
|
|
884
|
+
});
|
|
885
|
+
test('returns empty attachments when no buttons are present', () => {
|
|
886
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
|
|
887
|
+
expect(detail.attachments).toHaveLength(0);
|
|
888
|
+
});
|
|
889
|
+
test('single-proposer format parses without crash', () => {
|
|
890
|
+
const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
|
|
891
|
+
expect(detail.proposer).toBe('이영희의원');
|
|
892
|
+
expect(detail.billNo).toBe('2200001');
|
|
893
|
+
expect(detail.session).toBe('제433회 국회(임시회)');
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
// ─── NsmLmSts ─────────────────────────────────────────────────────────────────
|
|
898
|
+
describe('NsmLmSts', () => {
|
|
899
|
+
const nsm = new pal_1.NsmLmSts();
|
|
900
|
+
// ── constructor ────────────────────────────────────────────────────────────
|
|
901
|
+
describe('constructor', () => {
|
|
902
|
+
test('creates instance without config', () => {
|
|
903
|
+
expect(new pal_1.NsmLmSts()).toBeInstanceOf(pal_1.NsmLmSts);
|
|
904
|
+
});
|
|
905
|
+
test('creates instance with empty config object', () => {
|
|
906
|
+
expect(new pal_1.NsmLmSts({})).toBeInstanceOf(pal_1.NsmLmSts);
|
|
907
|
+
});
|
|
908
|
+
test('accepts custom userAgent', () => {
|
|
909
|
+
expect(new pal_1.NsmLmSts({ userAgent: 'Custom Agent' })).toBeInstanceOf(pal_1.NsmLmSts);
|
|
910
|
+
});
|
|
911
|
+
test('accepts custom timeout and retryCount', () => {
|
|
912
|
+
expect(new pal_1.NsmLmSts({ timeout: 5000, retryCount: 2 })).toBeInstanceOf(pal_1.NsmLmSts);
|
|
913
|
+
});
|
|
914
|
+
test('accepts custom headers', () => {
|
|
915
|
+
expect(new pal_1.NsmLmSts({ customHeaders: { 'Accept-Language': 'ko-KR' } })).toBeInstanceOf(pal_1.NsmLmSts);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
// ── parseList / parseDetail (delegation) ──────────────────────────────────
|
|
919
|
+
describe('parseList (delegation)', () => {
|
|
920
|
+
test('delegates to NsmLmStsParser and returns INsmSearchResult', () => {
|
|
921
|
+
const result = nsm.parseList(NSM_LIST_HTML);
|
|
922
|
+
expect(result.total).toBe(3142);
|
|
923
|
+
expect(result.items).toHaveLength(1);
|
|
924
|
+
expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
|
|
925
|
+
});
|
|
926
|
+
test('returns empty result for empty html', () => {
|
|
927
|
+
const result = nsm.parseList('');
|
|
928
|
+
expect(result.items).toHaveLength(0);
|
|
929
|
+
});
|
|
930
|
+
});
|
|
931
|
+
describe('parseDetail (delegation)', () => {
|
|
932
|
+
test('delegates to NsmLmStsParser and returns INsmBillDetail', () => {
|
|
933
|
+
const detail = nsm.parseDetail(NSM_DETAIL_HTML);
|
|
934
|
+
expect(detail.title).toBe('인공지능 기본법 일부개정법률안');
|
|
935
|
+
expect(detail.billNo).toBe('2219088');
|
|
936
|
+
expect(detail.attachments).toHaveLength(2);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
// ── getDetailHTML validation ───────────────────────────────────────────────
|
|
940
|
+
describe('getDetailHTML input validation', () => {
|
|
941
|
+
test('throws when billNo is empty string', async () => {
|
|
942
|
+
await expect(nsm.getDetailHTML('')).rejects.toThrow('billNo is required');
|
|
943
|
+
});
|
|
944
|
+
test('throws when billNo is whitespace only', async () => {
|
|
945
|
+
await expect(nsm.getDetailHTML(' ')).rejects.toThrow('billNo is required');
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
// ── search integration ────────────────────────────────────────────────────
|
|
949
|
+
describe('search (integration)', () => {
|
|
950
|
+
let searchResult;
|
|
951
|
+
beforeAll(async () => {
|
|
952
|
+
searchResult = await nsm.search({ pageSize: 5 });
|
|
953
|
+
debugLog('nsm.search', searchResult);
|
|
954
|
+
});
|
|
955
|
+
test('returns non-garbled html string (getListHTML)', async () => {
|
|
956
|
+
const html = await nsm.getListHTML({ pageSize: 5 });
|
|
957
|
+
expect(typeof html).toBe('string');
|
|
958
|
+
expect(html.length).toBeGreaterThan(0);
|
|
959
|
+
expect(html).not.toContain('\ufffd');
|
|
960
|
+
});
|
|
961
|
+
test('returns INsmSearchResult with total > 0', () => {
|
|
962
|
+
expect(searchResult.total).toBeGreaterThan(0);
|
|
963
|
+
expect(searchResult.totalPages).toBeGreaterThan(0);
|
|
964
|
+
expect(searchResult.currentPage).toBe(1);
|
|
965
|
+
});
|
|
966
|
+
test('returns items with valid structure and no garbled text', () => {
|
|
967
|
+
expect(searchResult.items.length).toBeGreaterThan(0);
|
|
968
|
+
for (const item of searchResult.items) {
|
|
969
|
+
expect(item.billName).not.toContain('\ufffd');
|
|
970
|
+
expect(item.billNo).toMatch(/^\d+$/);
|
|
971
|
+
expect(item.link).toContain('/gcom/nsmLmSts/out/');
|
|
972
|
+
expect(item.proposer).not.toContain('\ufffd');
|
|
973
|
+
expect(item.proposer.length).toBeGreaterThan(0);
|
|
974
|
+
expect(item.proposalDate.length).toBeGreaterThan(0);
|
|
975
|
+
expect(item.progressStatus.length).toBeGreaterThan(0);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
test('pageSize filter limits returned items', async () => {
|
|
979
|
+
const r5 = await nsm.search({ pageSize: 5 });
|
|
980
|
+
const r10 = await nsm.search({ pageSize: 10 });
|
|
981
|
+
expect(r5.items.length).toBeLessThanOrEqual(5);
|
|
982
|
+
expect(r10.items.length).toBeLessThanOrEqual(10);
|
|
983
|
+
});
|
|
984
|
+
test('pageIndex=2 returns different items from page 1', async () => {
|
|
985
|
+
const page1 = await nsm.search({ pageSize: 5, pageIndex: 1 });
|
|
986
|
+
const page2 = await nsm.search({ pageSize: 5, pageIndex: 2 });
|
|
987
|
+
expect(page1.items.length).toBeGreaterThan(0);
|
|
988
|
+
expect(page2.items.length).toBeGreaterThan(0);
|
|
989
|
+
expect(page1.items[0].billNo).not.toBe(page2.items[0].billNo);
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
// ── searchPending integration ─────────────────────────────────────────────
|
|
993
|
+
describe('searchPending (integration)', () => {
|
|
994
|
+
let pendingResult;
|
|
995
|
+
beforeAll(async () => {
|
|
996
|
+
pendingResult = await nsm.searchPending({ pageSize: 5 });
|
|
997
|
+
debugLog('nsm.searchPending', pendingResult);
|
|
998
|
+
});
|
|
999
|
+
test('returns INsmSearchResult with valid structure', () => {
|
|
1000
|
+
expect(typeof pendingResult.total).toBe('number');
|
|
1001
|
+
expect(typeof pendingResult.totalPages).toBe('number');
|
|
1002
|
+
expect(pendingResult.currentPage).toBe(1);
|
|
1003
|
+
});
|
|
1004
|
+
test('all returned bills have progressStatus "발의"', () => {
|
|
1005
|
+
for (const item of pendingResult.items) {
|
|
1006
|
+
expect(item.progressStatus).toBe('발의');
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
test('all pending bills have no resolutionStatus', () => {
|
|
1010
|
+
for (const item of pendingResult.items) {
|
|
1011
|
+
expect(item.resolutionStatus).toBe('');
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
test('no garbled text in pending bill names', () => {
|
|
1015
|
+
for (const item of pendingResult.items) {
|
|
1016
|
+
expect(item.billName).not.toContain('\ufffd');
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
// ── getPage / getDetail integration ───────────────────────────────────────
|
|
1021
|
+
describe('getPage / getDetail (integration)', () => {
|
|
1022
|
+
let firstBillNo;
|
|
1023
|
+
let pageItems;
|
|
1024
|
+
beforeAll(async () => {
|
|
1025
|
+
pageItems = await nsm.getPage(1, { pageSize: 5 });
|
|
1026
|
+
firstBillNo = pageItems[0]?.billNo ?? '';
|
|
1027
|
+
debugLog('nsm.getPage(1)', pageItems);
|
|
1028
|
+
});
|
|
1029
|
+
test('getPage(1): returns INsmBillItem array', () => {
|
|
1030
|
+
expect(Array.isArray(pageItems)).toBe(true);
|
|
1031
|
+
expect(pageItems.length).toBeGreaterThan(0);
|
|
1032
|
+
});
|
|
1033
|
+
test('getPage(2): returns different items from page 1', async () => {
|
|
1034
|
+
const page2 = await nsm.getPage(2, { pageSize: 5 });
|
|
1035
|
+
expect(page2.length).toBeGreaterThan(0);
|
|
1036
|
+
expect(page2[0].billNo).not.toBe(pageItems[0].billNo);
|
|
1037
|
+
});
|
|
1038
|
+
test('getDetailHTML: returns non-empty, non-garbled html', async () => {
|
|
1039
|
+
if (!firstBillNo)
|
|
1040
|
+
return;
|
|
1041
|
+
const html = await nsm.getDetailHTML(firstBillNo);
|
|
1042
|
+
expect(typeof html).toBe('string');
|
|
1043
|
+
expect(html.length).toBeGreaterThan(0);
|
|
1044
|
+
expect(html).not.toContain('\ufffd');
|
|
1045
|
+
});
|
|
1046
|
+
test('getDetail: returns INsmBillDetail with valid fields', async () => {
|
|
1047
|
+
if (!firstBillNo)
|
|
1048
|
+
return;
|
|
1049
|
+
const detail = await nsm.getDetail(firstBillNo);
|
|
1050
|
+
debugLog('nsm.getDetail', detail);
|
|
1051
|
+
expect(detail.title.length).toBeGreaterThan(0);
|
|
1052
|
+
expect(detail.title).not.toContain('\ufffd');
|
|
1053
|
+
expect(detail.billNo).toMatch(/^\d+$/);
|
|
1054
|
+
expect(detail.proposer.length).toBeGreaterThan(0);
|
|
1055
|
+
expect(detail.proposalDate.length).toBeGreaterThan(0);
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
// ── getAllPages / getAllPendingPages integration ───────────────────────────
|
|
1059
|
+
describe('getAllPages / getAllPendingPages (integration)', () => {
|
|
1060
|
+
test('getAllPages: yields exactly maxPages results', async () => {
|
|
1061
|
+
const pages = [];
|
|
1062
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
|
|
1063
|
+
pages.push(page);
|
|
1064
|
+
}
|
|
1065
|
+
expect(pages).toHaveLength(2);
|
|
1066
|
+
expect(pages[0].items.length).toBeGreaterThan(0);
|
|
1067
|
+
expect(pages[1].items.length).toBeGreaterThan(0);
|
|
1068
|
+
});
|
|
1069
|
+
test('getAllPages: currentPage increments correctly', async () => {
|
|
1070
|
+
const pages = [];
|
|
1071
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 3, delayMs: 0 })) {
|
|
1072
|
+
pages.push(page);
|
|
1073
|
+
}
|
|
1074
|
+
expect(pages[0].currentPage).toBe(1);
|
|
1075
|
+
expect(pages[1].currentPage).toBe(2);
|
|
1076
|
+
expect(pages[2].currentPage).toBe(3);
|
|
1077
|
+
});
|
|
1078
|
+
test('getAllPages: total is consistent across pages', async () => {
|
|
1079
|
+
const pages = [];
|
|
1080
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
|
|
1081
|
+
pages.push(page);
|
|
1082
|
+
}
|
|
1083
|
+
expect(pages[0].total).toBe(pages[1].total);
|
|
1084
|
+
expect(pages[0].totalPages).toBe(pages[1].totalPages);
|
|
1085
|
+
});
|
|
1086
|
+
test('getAllPages: page 2 items differ from page 1', async () => {
|
|
1087
|
+
const pages = [];
|
|
1088
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
|
|
1089
|
+
pages.push(page);
|
|
1090
|
+
}
|
|
1091
|
+
expect(pages[0].items[0].billNo).not.toBe(pages[1].items[0].billNo);
|
|
1092
|
+
});
|
|
1093
|
+
test('getAllPages: concurrency > 1 yields pages in order', async () => {
|
|
1094
|
+
const pages = [];
|
|
1095
|
+
for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
|
|
1096
|
+
pages.push(page);
|
|
1097
|
+
}
|
|
1098
|
+
expect(pages).toHaveLength(4);
|
|
1099
|
+
for (let i = 0; i < pages.length; i++) {
|
|
1100
|
+
expect(pages[i].currentPage).toBe(i + 1);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
test('getAllPendingPages: yields at least 1 page of pending bills', async () => {
|
|
1104
|
+
const pages = [];
|
|
1105
|
+
for await (const page of nsm.getAllPendingPages({ pageSize: 5 }, { maxPages: 1, delayMs: 0 })) {
|
|
1106
|
+
pages.push(page);
|
|
1107
|
+
}
|
|
1108
|
+
expect(pages.length).toBeGreaterThanOrEqual(1);
|
|
1109
|
+
// Every item must be in 발의 상태
|
|
1110
|
+
for (const page of pages) {
|
|
1111
|
+
for (const item of page.items) {
|
|
1112
|
+
expect(item.progressStatus).toBe('발의');
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
// ── Screenshot functionality ──────────────────────────────────────────────
|
|
1118
|
+
describe('Screenshot functionality', () => {
|
|
1119
|
+
test('creates instance with screenshot config', () => {
|
|
1120
|
+
const instance = new pal_1.NsmLmSts({
|
|
1121
|
+
screenshot: {
|
|
1122
|
+
enabled: true,
|
|
1123
|
+
fullPage: true,
|
|
1124
|
+
width: 1280,
|
|
1125
|
+
height: 800,
|
|
1126
|
+
format: 'png',
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1130
|
+
});
|
|
1131
|
+
test('throws error when screenshot is disabled', async () => {
|
|
1132
|
+
const instance = new pal_1.NsmLmSts({ screenshot: { enabled: false } });
|
|
1133
|
+
await expect(instance.takeScreenshot('https://example.com')).rejects.toThrow('Screenshot feature is not enabled');
|
|
1134
|
+
});
|
|
1135
|
+
test('accepts jpeg format with quality option', () => {
|
|
1136
|
+
const instance = new pal_1.NsmLmSts({
|
|
1137
|
+
screenshot: { enabled: true, format: 'jpeg', quality: 85 },
|
|
1138
|
+
});
|
|
1139
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1140
|
+
});
|
|
1141
|
+
test('updateScreenshotConfig modifies screenshot settings', () => {
|
|
1142
|
+
const instance = new pal_1.NsmLmSts({
|
|
1143
|
+
screenshot: { enabled: true, format: 'png' },
|
|
1144
|
+
});
|
|
1145
|
+
instance.updateScreenshotConfig({ format: 'jpeg', quality: 70 });
|
|
1146
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1147
|
+
});
|
|
1148
|
+
test('initBrowser and closeBrowser can be called', async () => {
|
|
1149
|
+
const instance = new pal_1.NsmLmSts({ screenshot: { enabled: true } });
|
|
1150
|
+
expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
583
1153
|
});
|
|
584
1154
|
//# sourceMappingURL=pal.test.js.map
|