pal-crawl 2.2.1 → 2.3.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 CHANGED
@@ -41,6 +41,17 @@
41
41
  - [getAllPendingPages](#getallpendingpagesquery-options--asyncgeneratorinsmsearchresult)
42
42
  - [Screenshot (NsmLmSts)](#getnsmlisscreenshotquery--promisebuffer)
43
43
  - [LikmsCrawler - 국회 의안정보시스템 제안이유 조회](#likmscrawler---국회-의안정보시스템-제안이유-조회)
44
+ - [LikmsStateCrawler - 국회 의안정보시스템 계류의안](#likmsstatecrawler---국회-의안정보시스템-계류의안)
45
+ - [LikmsStateCrawler Configuration](#likmsstatecrawler-configuration)
46
+ - [LikmsStateCrawler Types](#likmsstatecrawler-types)
47
+ - [search (LikmsStateCrawler)](#searchquery-ilikmsstatequery--promiseilikmsstatesearchresult)
48
+ - [findCommittees](#findcommitteesagefrom-ageto--promiseilikmscommittee)
49
+ - [getPage (LikmsStateCrawler)](#getpagepageindex-number-query--promiseilikmsstatebillitem)
50
+ - [getAllPages (LikmsStateCrawler)](#getallpagesquery-options--asyncgeneratorilikmsstatesearchresult)
51
+ - [LikmsNsmAdapter - NsmLmSts 호환 어댑터](#likmsnsmadapter---nsmlmsts-호환-어댑터)
52
+ - [발의 상태 조회 (`searchPending` / `getAllPendingPages`)](#발의-상태-조회-searchpending--getallpendingpages)
53
+ - [상세 조회 (`INsmDetailCrawler`)](#상세-조회-insmdetailcrawler)
54
+ - [쿼리 매핑](#쿼리-매핑)
44
55
  - [Examples](#examples)
45
56
  - [License](#license)
46
57
 
@@ -52,6 +63,8 @@
52
63
 
53
64
  국민참여입법센터 국회입법현황(opinion.lawmaking.go.kr)에서 법안을 크롤링하는 `NsmLmSts` 클래스도 포함되어 있습니다. 위원회 회부 이전에 발의된 상태로 대기 중인 법안까지 조회할 수 있습니다.
54
65
 
66
+ 국회 의안정보시스템(likms.assembly.go.kr)의 계류의안 목록을 수집하는 `LikmsStateCrawler`와, 제안이유를 조회하는 `LikmsCrawler`도 포함되어 있습니다. `LikmsStateCrawler`는 `NsmLmSts`의 계류 법안 조회를 대체할 수 있습니다.
67
+
55
68
  ---
56
69
 
57
70
  ## Installation
@@ -64,13 +77,13 @@ npm install pal-crawl
64
77
 
65
78
  ## Configuration
66
79
 
67
- `PalCrawl` 클래스는 생성자에서 선택적 설정 객체를 받을 수 있습니다.
80
+ `PalCrawl` 클래스는 생성자에서 선택적 설정 객체를 받을 수 있습니다. `retryCount`는 기존 동작과 같이 최대 요청 시도 횟수를 뜻하며, `0`이면 요청을 한 번 수행하고 재시도하지 않습니다. 음수·소수·무한대 등 유효하지 않은 값은 생성 시 오류로 알립니다.
68
81
 
69
82
  ```typescript
70
83
  interface PalCrawlConfig {
71
84
  userAgent?: string; // 사용자 정의 User-Agent (기본값: Chrome User-Agent)
72
85
  timeout?: number; // HTTP 요청 타임아웃 (밀리초, 기본값: 10000)
73
- retryCount?: number; // 재시도 횟수 (기본값: 3)
86
+ retryCount?: number; // Maximum attempts (default: 3; 0 makes one attempt)
74
87
  customHeaders?: Record<string, string>; // 사용자 정의 헤더
75
88
  screenshot?: ScreenshotOptions; // 헤드리스 크로뮴 스크린샷 옵션
76
89
  hydrateTruncatedTitles?: boolean; // 잘린 제목 상세 조회 여부 (기본값: false)
@@ -178,11 +191,7 @@ interface ISearchQuery {
178
191
  proposers?: string; // 공동발의자 검색어 (쉼표 구분 AND 조건)
179
192
  ppslRsonMnCn?: string; // 제안이유 검색어
180
193
  sortCol?:
181
- | 'BILL_NO'
182
- | 'BILL_NAME'
183
- | 'CURR_COMMITTEE'
184
- | 'OPN_CNT'
185
- | 'LGSLT_PA_RG_DT';
194
+ 'BILL_NO' | 'BILL_NAME' | 'CURR_COMMITTEE' | 'OPN_CNT' | 'LGSLT_PA_RG_DT';
186
195
  sortGbn?: 'DESC' | 'ASC';
187
196
  // 종료된 입법예고 전용:
188
197
  fromAge?: number; // 시작 대수 (예: 21)
@@ -482,7 +491,7 @@ for await (const page of palCrawl.getAllDonePages(
482
491
 
483
492
  ## NsmLmSts - 국민참여입법센터 국회입법현황
484
493
 
485
- `NsmLmSts` 클래스는 [국민참여입법센터 국회입법현황](https://opinion.lawmaking.go.kr/gcom/nsmLmSts/out)에서 법안 목록과 상세 정보를 크롤링합니다. 위원회 회부 이전 발의 상태 법안까지 필터링할 수 있는 것이 특징입니다. 목록 검색은 기본적으로 목록 페이지만 요청하며, `...`으로 잘린 제목을 상세 페이지에서 보정하려면 `hydrateTruncatedTitles: true`를 명시해야 합니다.
494
+ `NsmLmSts` 클래스는 [국민참여입법센터 국회입법현황](https://opinion.lawmaking.go.kr/gcom/nsmLmSts/out)에서 법안 목록과 상세 정보를 크롤링합니다. 위원회 회부 이전 발의 상태 법안까지 필터링할 수 있는 것이 특징입니다. 목록 검색은 기본적으로 목록 페이지만 요청하며, `...`으로 잘린 제목을 상세 페이지에서 보정하려면 `hydrateTruncatedTitles: true`를 명시해야 합니다. 제목 보정 요청은 순차적으로 실행하며, 실패한 항목의 기존 제목은 유지합니다.
486
495
 
487
496
  ---
488
497
 
@@ -506,10 +515,14 @@ const nsm = new NsmLmSts({
506
515
 
507
516
  `INsmSearchQuery`는 목록 검색 필터 옵션입니다.
508
517
 
518
+ > 제안일자는 **`YYYYMMDD`(8자리)** 로 보내야 한다(실측 2026-10-13: `20260901` → 888건,
519
+ > `20260901`~`20260910` → 346건). 하이픈 형식(`2026-09-01`)은 시작일만 주면 5,903건으로
520
+ > 범위가 어긋나고, 시작·종료를 함께 주면 0건이 된다.
521
+
509
522
  ```typescript
510
523
  interface INsmSearchQuery {
511
524
  pageIndex?: number; // 페이지 번호 (기본값: 1)
512
- pageSize?: number; // 페이지당 건수 (10 | 20 | 50 | 100)
525
+ pageSize?: number; // 페이지당 건수 (기본 20, 임의 값 반영)
513
526
  sugCd?: string; // 제안대수 시작 (예: "22" → 제22대)
514
527
  endSugCd?: string; // 제안대수 끝
515
528
  sgtCls?: NsmProposerType; // 발의구분
@@ -517,8 +530,8 @@ interface INsmSearchQuery {
517
530
  rslRsltNmL?: NsmProgressStatus; // 국회현황 코드
518
531
  rslRsltNmR?: NsmResolutionStatus; // 의결현황 코드
519
532
  scCptPpostCmt?: string; // 상임위 (예: "법제사법위원회")
520
- searchStDtNew?: string; // 제안일자 시작 (YYYY-MM-DD)
521
- searchEdDtNew?: string; // 제안일자 종료 (YYYY-MM-DD)
533
+ searchStDtNew?: string; // 제안일자 시작 (YYYYMMDD — 하이픈 형식은 범위가 어긋난다)
534
+ searchEdDtNew?: string; // 제안일자 종료 (YYYYMMDD, 시작일과 함께 줄 때만 적용)
522
535
  scPpsUsr?: string; // 제안자
523
536
  issLawitmYn?: 'Y'; // 규제 신설·강화 해당 법안만
524
537
  stDt?: string; // 추진일자 시작 (YYYY-MM-DD)
@@ -797,6 +810,411 @@ console.log(likms.parseProposalReason(fragmentHtml));
797
810
 
798
811
  ---
799
812
 
813
+ ## LikmsStateCrawler - 국회 의안정보시스템 계류의안
814
+
815
+ `LikmsStateCrawler`는 [국회 의안정보시스템](https://likms.assembly.go.kr)의 **의안현황 > 계류의안** 목록(`mooringBillPage.do`)을 수집합니다. 국민참여입법센터 `NsmLmSts`의 계류 법안 조회를 대체할 수 있도록 `search` / `getPage` / `getAllPages` 구조를 그대로 제공합니다.
816
+
817
+ 이 페이지는 목록을 AJAX(POST)로 채우고 Spring Security CSRF 토큰을 요구합니다. 크롤러는 첫 요청에서 페이지의 `<meta name="_csrf">` 토큰·세션 쿠키와 `#form` 숨김 필드를 확보해 캐시하고, 검색 본문은 그 숨김 필드를 기본값으로 깔고 요청 조건으로 덮어씁니다(브라우저가 폼을 직렬화해 보내는 방식과 동일). 토큰은 `X-CSRF-TOKEN` 헤더로 전송하며, 세션/CSRF 거부(401·403) 시 1회 자동 재발급·재시도합니다.
818
+
819
+ > 숨김 필드를 함께 보내는 이유: 최소 파라미터만 보내면 서버가 **발의자 필터를 조용히 무시**합니다. 발의자 필터는 **의원 ID**(`representId`) + `representKindCd`를 함께 보내야 실제로 걸립니다(실측 확인). 서버도 크롤러도 **제안자명을 해석하지 않습니다** — 이름만으로는 어느 의원인지 확정할 수 없기 때문입니다. 이름밖에 모를 때는 [`findRepresentCandidates`](#발의자-후보-조회-findrepresentcandidates)로 사이트가 제시하는 후보를 보고 `membId`를 고릅니다.
820
+
821
+ > 참고: 실측 결과 이 엔드포인트는 현재 CSRF 토큰을 서버에서 검증하지 않습니다(잘못된 토큰이나 헤더 없음도 정상 응답). 크롤러는 브라우저와 동일하게 토큰을 전송하되, 서버가 검증을 켜는 경우에도 동작하도록 세션 재발급 경로를 유지합니다.
822
+
823
+ ```typescript
824
+ import { LikmsStateCrawler } from 'pal-crawl';
825
+
826
+ const crawler = new LikmsStateCrawler();
827
+
828
+ // 1페이지(50건) 조회
829
+ const result = await crawler.search();
830
+ console.log(result.total, result.totalPages);
831
+ console.log(result.items[0]);
832
+ // {
833
+ // billNo: '2221880',
834
+ // billId: 'PRC_EC79D21C54024C16A5D06122914938', // PAL contentId와 동일
835
+ // billName: '국가재정법 일부개정법률안(장종태의원 등 10인)',
836
+ // proposer: '장종태의원 등 10인',
837
+ // proposerKind: '의원',
838
+ // proposalDate: '2026-10-13',
839
+ // committee: '',
840
+ // progressStatus: '접수',
841
+ // hasProposalReason: true,
842
+ // proposalReason: null, // hydrateProposalReason 옵션을 켜면 채워진다
843
+ // link: 'https://likms.assembly.go.kr/bill/bi/billDetailPage.do?billId=PRC_...'
844
+ // }
845
+ ```
846
+
847
+ ### LikmsStateCrawler Configuration
848
+
849
+ `PalCrawl`과 동일한 네트워크 옵션을 받습니다.
850
+
851
+ ```typescript
852
+ const crawler = new LikmsStateCrawler({
853
+ userAgent: 'Custom Agent',
854
+ timeout: 15000,
855
+ retryCount: 2,
856
+ customHeaders: { 'Accept-Language': 'ko-KR' },
857
+ hydrateProposalReason: true, // 목록의 제안이유를 의안상세에서 채움 (기본 false)
858
+ });
859
+ ```
860
+
861
+ #### 제안이유 보정 (`hydrateProposalReason`)
862
+
863
+ 목록에는 제안이유 본문이 없고 `hasProposalReason`(팝업 링크 노출)만 알 수 있다. 이 옵션을 켜면 `hasProposalReason`인 법안만 [`LikmsCrawler`](#likmscrawler---국회-의안정보시스템-제안이유-조회)로 의안상세를 조회해 `proposalReason`을 채운다.
864
+
865
+ - **기본값은 `false`** — 건당 2회(상세 페이지 + `billInfo.do`) 요청이 추가되기 때문이다.
866
+ - `hasProposalReason`가 false인 건은 조회하지 않고, 조회·파싱 실패 건은 기존 값(`null`)을 유지하며 예외를 던지지 않는다.
867
+ - `search` / `getPage` / `getAllPages` 모두에 적용되며, `getAllPages`는 페이지마다 보정한다.
868
+ - 속도는 `hydrateProposalReasons(items, { delayMs, concurrency })`를 직접 호출해 조절한다(기본 `delayMs: 500`, `concurrency: 1` = 순차 지연 요청).
869
+
870
+ ```typescript
871
+ const crawler = new LikmsStateCrawler();
872
+ const { items } = await crawler.search({ ageFrom: 22, ageTo: 22 });
873
+
874
+ // 필요한 페이지만 골라서 보정 (입력 배열은 변경되지 않고 복사본이 반환된다)
875
+ const hydrated = await crawler.hydrateProposalReasons(items, {
876
+ concurrency: 2,
877
+ delayMs: 300,
878
+ });
879
+ console.log(hydrated[0].proposalReason);
880
+ ```
881
+
882
+ ### 발의자 후보 조회 (`findRepresentCandidates`)
883
+
884
+ likms 검색은 제안자 **이름을 받지 않고 의원 ID(`representId`)만** 받는다. 이름 하나를 ID로 자동 확정하지 않는 이유는 이름이 확정 값을 주지 못하기 때문이다 — 같은 이름이 여러 대수에 있거나 **같은 대수에** 둘 이상일 수 있고(예: 21대 이수진 2인), 한자는 동일인 키가 아니다(22대·17대 김문수 사례). 그래서 이름밖에 없을 때는 사이트가 제시하는 **후보 목록**을 받아 호출부가 고른다.
885
+
886
+ ```typescript
887
+ import { LikmsStateCrawler } from 'pal-crawl';
888
+
889
+ const crawler = new LikmsStateCrawler();
890
+
891
+ const candidates = await crawler.findRepresentCandidates('이수진');
892
+ // [
893
+ // { membId: 'D4L60530', name: '이수진', age: '21대', hanja: '李秀眞', party: '무소속' },
894
+ // { membId: '0R68099X', name: '이수진', age: '21대', hanja: '李壽珍', party: '더불어민주당' },
895
+ // ]
896
+
897
+ const picked = candidates[1]; // 대수·한자명·정당명으로 사람을 가린다
898
+ const result = await crawler.search({
899
+ representId: picked.membId, // 후보의 membId = representId
900
+ representKindCd: 'REPR', // 대표발의만 (기본은 '전체' = 대표 + 공동발의)
901
+ });
902
+ ```
903
+
904
+ - 같은 이름이 **같은 대수에** 둘 이상일 수도 있어(예: 21대 이수진 2인) 대수만으로는 풀리지 않는다 → 정당·한자명이 단서다.
905
+ - `ageFrom`/`ageTo`를 주면 그 범위로 좁혀 조회한다(기본 `01`–`99`).
906
+ - 명단에 없으면 빈 배열을 반환한다. 이 엔드포인트는 GET이라 CSRF 토큰·세션이 필요 없다(실측: 쿠키 없이 200).
907
+ - `LikmsNsmAdapter`에서는 `adapter.likms.findRepresentCandidates(...)`로 쓴다.
908
+
909
+ ```typescript
910
+ interface ILikmsRepresentCandidate {
911
+ membId: string; // representId로 그대로 사용
912
+ name: string;
913
+ age: string; // 예: '22대'
914
+ hanja: string;
915
+ party: string;
916
+ }
917
+ ```
918
+
919
+ ### LikmsStateCrawler Types
920
+
921
+ ```typescript
922
+ interface ILikmsStateQuery {
923
+ pageIndex?: number;
924
+ pageSize?: number; // 페이지당 건수 (`rows`, 기본 50)
925
+ stateId?: 'mooring' | 'finish' | 'suggest' | 'beforePropose'; // 기본 'mooring'
926
+ stateDtlDivCd?: string; // 탭 구분 (기본 'all')
927
+ billNm?: string; // 의안명
928
+ billNo?: string; // 의안번호
929
+ representKindCd?: 'REPR' | 'SING' | 'COMM' | '전체'; // 발의구분
930
+ representId?: string; // 발의자 의원 ID (representKindCd와 셋트, 기본 '전체')
931
+ // 이름은 받지 않는다 → findRepresentCandidates로 후보를 확인
932
+ ageCmtId?: string; // 소관위원회 코드 (이름밖에 모르면 findCommittees로 확인)
933
+ cmtResultCd?: 'PRC_0000' | 'PRC_0011' | 'PRC_0013' | 'PRC_0014';
934
+ // 소관위 처리결과: 계류/폐기/가결/부결 (NSM 의결현황과 축이 다름)
935
+ jntPrpslYn?: 'Y' | 'N'; // 공동발의 여부
936
+ ageFrom?: string | number; // 제안대수 시작
937
+ ageTo?: string | number; // 제안대수 끝
938
+ billKind?: string; // 의안종류
939
+ listOrd?: string; // 정렬 컬럼
940
+ ordCd?: 'ASC' | 'DESC'; // 정렬 방향
941
+ fromPropDt?: string; // 제안일자 시작 (toPropDt와 함께 줘야 적용)
942
+ toPropDt?: string; // 제안일자 끝
943
+ nmReSchText?: string; // 결과 내 재검색어
944
+ }
945
+
946
+ interface ILikmsStateBillItem {
947
+ billNo: string;
948
+ billId: string; // PRC_... (= PAL contentId)
949
+ billName: string;
950
+ proposer: string;
951
+ proposerKind: string; // 제안자구분: 의원/정부/위원장
952
+ proposalDate: string;
953
+ committee: string;
954
+ progressStatus: string; // 심사진행상태: 접수/위원회 심사 등
955
+ hasProposalReason: boolean; // 목록에 제안이유 링크가 노출되는지
956
+ proposalReason: string | null; // 파서는 null — hydrateProposalReason으로 보정
957
+ link: string;
958
+ }
959
+
960
+ interface ILikmsStateSearchResult {
961
+ total: number;
962
+ totalPages: number;
963
+ currentPage: number;
964
+ items: ILikmsStateBillItem[];
965
+ }
966
+ ```
967
+
968
+ ### search(query?: ILikmsStateQuery) => Promise\<ILikmsStateSearchResult>
969
+
970
+ ```typescript
971
+ import { LikmsStateCrawler } from 'pal-crawl';
972
+
973
+ const crawler = new LikmsStateCrawler();
974
+ const result = await crawler.search({
975
+ billNm: '국가재정법',
976
+ ageFrom: 22,
977
+ ageTo: 22,
978
+ });
979
+ ```
980
+
981
+ ### findCommittees(ageFrom?, ageTo?) => Promise\<ILikmsCommittee[]>
982
+
983
+ 대수 범위의 **소관위원회 목록**(코드·이름·정렬순서)을 조회합니다. 검색 파라미터 `ageCmtId`는 이름이 아니라 코드를 받으므로, 이름밖에 모를 때 이 메서드로 확인합니다.
984
+
985
+ ```typescript
986
+ const committees = await crawler.findCommittees('22', '22');
987
+ // [ { comCd: '1000000', comCdName: '본회의', cdOrd: 1 }, ... { comCd: '9700006', comCdName: '법제사법위원회', cdOrd: 3002 }, ... ]
988
+
989
+ await crawler.search({ ageCmtId: '9700006', cmtResultCd: 'PRC_0000' }); // 계류 중인 법사위 소관 법안
990
+ ```
991
+
992
+ - `ageFrom`/`ageTo`는 **둘 다** 줘야 범위가 적용됩니다(한쪽만 주면 서버가 무시하므로 예외).
993
+ - 범위를 주지 않으면 전체 대수를 합친 목록(109건, 실측)을 주며, 이때 같은 이름은 `보건복지위원회(18대~22대)`처럼 대수 꼬리표로 구분됩니다.
994
+ - 응답이 JSON이 아니면 빈 배열(예외 없음), 네트워크 오류는 전파합니다.
995
+
996
+ ### findRepresentCandidates(name, ageFrom?, ageTo?) => Promise\<ILikmsRepresentCandidate[]>
997
+
998
+ 발의자 후보(의원 ID + 대수·한자명·정당명)를 팝업 엔드포인트에서 조회합니다. 크롤러가 이름을 ID로 확정하지 않으므로, 이름으로 좁히려면 이 후보에서 `membId`를 골라 `representId`로 넘깁니다. 위 [발의자 후보 조회](#발의자-후보-조회-findrepresentcandidates) 참고.
999
+
1000
+ ```typescript
1001
+ const candidates = await crawler.findRepresentCandidates('주진우');
1002
+ // [
1003
+ // { membId: 'GJK57930', name: '주진우', age: '22대', hanja: '朱晋佑', party: '국민의힘' },
1004
+ // { membId: 'IRT9826E', name: '주진우', age: '16대', hanja: '朱鎭旴', party: '한나라당' },
1005
+ // ]
1006
+ ```
1007
+
1008
+ ### getPage(pageIndex: number, query?) => Promise\<ILikmsStateBillItem[]>
1009
+
1010
+ ```typescript
1011
+ const items = await crawler.getPage(2);
1012
+ ```
1013
+
1014
+ ### getAllPages(query?, options?) => AsyncGenerator\<ILikmsStateSearchResult>
1015
+
1016
+ ```typescript
1017
+ for await (const page of crawler.getAllPages(
1018
+ {},
1019
+ { maxPages: 3, delayMs: 500 },
1020
+ )) {
1021
+ console.log(page.currentPage, page.items.length);
1022
+ }
1023
+ ```
1024
+
1025
+ ### 저수준 API (LikmsStateCrawler)
1026
+
1027
+ ```typescript
1028
+ const pageHtml = await crawler.getStatePageHTML(); // CSRF 토큰·세션 확보용
1029
+ const token = crawler.extractCsrfToken(pageHtml); // <meta name="_csrf">
1030
+ const fragment = await crawler.getStateListHTML(); // 목록 조각 HTML (AJAX)
1031
+ const parsed = crawler.parseList(fragment); // 목록 조각 → 객체
1032
+ ```
1033
+
1034
+ > 계류의안 목록의 `billId`는 PAL의 `contentId`와 동일하므로, 목록에 제안이유 링크가 있을 때(`hasProposalReason`) `LikmsCrawler.getProposalReason(billId)`로 제안이유를 조회할 수 있습니다.
1035
+
1036
+ ### 실제 네트워크 통합 테스트 (opt-in)
1037
+
1038
+ 기본 `npm test`는 PAL·NSM·LIKMS 응답 fixture와 HTTP mock을 사용하며 실사이트를 호출하지 않습니다. HTTP 재시도·응답 중단·인코딩 폴백도 mock 전송 계층에서 검증합니다.
1039
+
1040
+ `src/likms.live.test.ts`는 기본적으로 skip되며, 환경변수로만 실행됩니다(토큰 재발급, 페이지 경계, 필터 검증 포함).
1041
+
1042
+ ```bash
1043
+ LIKMS_LIVE_TEST=1 npx jest src/likms.live.test.ts
1044
+ ```
1045
+
1046
+ ### 빌드 산출물 런타임 검증
1047
+
1048
+ `scripts/verify-dist.js`는 **dist(tsc 산출물)를 그대로 require**해 export·파서·어댑터 매핑을 검증합니다. 테스트가 `src`(ts-jest)를 돌리는 것과 달리 배포물 자체를 확인하고, jest 없이 한 파일로 끝납니다.
1049
+
1050
+ ```bash
1051
+ npm run build
1052
+ npm run verify:dist # 네트워크 없이 (export·파서·매핑)
1053
+ npm run verify:dist:live # 실제 사이트 3곳까지 호출
1054
+ ```
1055
+
1056
+ 각 검사는 `[ok]`/`[FAIL]`로 출력되고 실패가 있으면 exit 1로 끝나므로 CI나 수동 점검에 그대로 쓸 수 있습니다.
1057
+
1058
+ ---
1059
+
1060
+ ## LikmsNsmAdapter - NsmLmSts 호환 어댑터
1061
+
1062
+ `LikmsNsmAdapter`는 `LikmsStateCrawler`를 국민참여입법센터 `NsmLmSts`와 **동일한 목록 조회 인터페이스**로 감싼다. `NsmLmSts`를 쓰던 호출부에서 생성자만 바꾸면 대부분 그대로 동작한다.
1063
+
1064
+ > 예외: 제안자명(`scPpsUsr`)은 likms가 이름을 받지 않으므로 **지원하지 않는다**(설정하면 예외). 이름을 ID로 확정하기 위해 사이트를 조회하는 것은 값이 확정되지 않는 추측이므로 어댑터가 하지 않는다 — 필요하면 `adapter.likms.findRepresentCandidates()`로 후보를 골라 `representId`로 조회한다.
1065
+ >
1066
+ > 상임위명(`scCptPpostCmt`)은 다르다. 사이트가 확정 값을 주므로(소관위 목록 JSON) 어댑터가 코드로 해석해 `ageCmtId`로 보낸다. 대수 범위(`sugCd`/`endSugCd`) 안에서 찾고, 이름이 목록에 없거나 같은 이름이 여러 코드면 예외를 던진다.
1067
+
1068
+ ```typescript
1069
+ // 기존: import { NsmLmSts } from 'pal-crawl'; const crawler = new NsmLmSts();
1070
+ import { LikmsNsmAdapter } from 'pal-crawl';
1071
+
1072
+ const crawler = new LikmsNsmAdapter();
1073
+
1074
+ const result = await crawler.search({
1075
+ pageIndex: 1,
1076
+ pageSize: 10,
1077
+ sugCd: '22',
1078
+ endSugCd: '22',
1079
+ });
1080
+ const items = await crawler.getPage(2);
1081
+ for await (const page of crawler.getAllPages({}, { maxPages: 3 })) {
1082
+ console.log(page.currentPage, page.items.length);
1083
+ }
1084
+
1085
+ // 제안자명(`scPpsUsr`)은 미지원 — likms가 이름을 받지 않으므로 예외를 던진다.
1086
+ // 후보를 골라 ID로 조회하려면 `adapter.likms`를 직접 쓴다(후보는 사이트가 제시하는 값).
1087
+ const candidates = await crawler.likms.findRepresentCandidates('문진석');
1088
+ const byMember = await crawler.likms.search({
1089
+ representId: candidates[0].membId,
1090
+ representKindCd: 'REPR',
1091
+ });
1092
+ ```
1093
+
1094
+ `INsmListCrawler` 포트를 `NsmLmSts`와 `LikmsNsmAdapter`가 모두 만족하므로, 두 구현을 같은 변수 타입으로 다룰 수 있다.
1095
+
1096
+ ```typescript
1097
+ import { NsmLmSts, LikmsNsmAdapter, type INsmListCrawler } from 'pal-crawl';
1098
+
1099
+ const crawler: INsmListCrawler = useLikms
1100
+ ? new LikmsNsmAdapter()
1101
+ : new NsmLmSts();
1102
+ ```
1103
+
1104
+ ### 발의 상태 조회 (`searchPending` / `getAllPendingPages`)
1105
+
1106
+ `NsmLmSts`의 `searchPending`(서버에서 `rslRsltNmL=900101` '발의'로 필터)에 대응하는 대안이다.
1107
+
1108
+ likms에는 심사진행상태를 좁히는 **유효한 서버 필터가 없다** — 파라미터 `srngPrgrsSttsCd`는
1109
+ 존재하고 서버가 실제로 조건을 걸지만(알 수 없는 값이면 0건), 사이트가 값 목록을 렌더하지 않아
1110
+ 코드를 얻을 수 없었다(실측: 상세 페이지의 `RECEIVE`/`CMT`/`EXAM`/`MAIN`/`GOVTRANS`/`DCS`, `PRC_`·`PSC_` 계열,
1111
+ 숫자, 한글 라벨 모두 0건). 그래서 목록을 앞에서부터 스캔하며 `progressStatus === '접수'`인
1112
+ 항목만 걸러낸다.
1113
+
1114
+ ```typescript
1115
+ import { LikmsNsmAdapter } from 'pal-crawl';
1116
+
1117
+ const crawler = new LikmsNsmAdapter();
1118
+
1119
+ // 접수가 나올 때까지 순차 스캔 (기본 10페이지 · 페이지당 100건)
1120
+ const pending = await crawler.searchPending({}, { maxPages: 5, delayMs: 500 });
1121
+ console.log(pending.items.map((item) => item.billNo));
1122
+
1123
+ // 전부 순회
1124
+ for await (const page of crawler.getAllPendingPages({}, { maxPages: 10 })) {
1125
+ console.log(page.items.length);
1126
+ }
1127
+ ```
1128
+
1129
+ 주의할 점:
1130
+
1131
+ - **스캔 비용**이 든다(서버 필터 없음). `delayMs`·`maxPages`로 범위를 조절하고, 찾지 못하면 빈 결과를 받는다.
1132
+ - 접수 건이 없는 페이지는 건너뛰므로 `currentPage`는 원본 페이지 번호가 아니고, `pageIndex`로 건너뛸 수 없다.
1133
+ - `items`는 접수 항목만이지만 `total`/`totalPages`는 **전체(접수 포함) 기준** 원본 값이다.
1134
+ - `progressStatus`는 likms 어휘 `'접수'`이며 NSM의 `'발의'`와 문자열이 다르다.
1135
+ - 실측 대조: likms `접수` 14건 중 12건이 NSM `발의` 조회에 포함되어 같은 부분집합임을 확인했다.
1136
+
1137
+ 저수준 API(`LikmsStateCrawler.filterPending` / `getAllPendingPages` / `searchPending`)도 같은 동작을 제공한다.
1138
+
1139
+ ### 상세 조회 (`INsmDetailCrawler`)
1140
+
1141
+ `getDetailHTML` / `parseDetail` / `getDetail`도 같은 포트(`INsmDetailCrawler`)로 교체할 수 있다.
1142
+
1143
+ ```typescript
1144
+ import { NsmLmSts, LikmsNsmAdapter, type INsmDetailCrawler } from 'pal-crawl';
1145
+
1146
+ const crawler: INsmDetailCrawler = useLikms
1147
+ ? new LikmsNsmAdapter()
1148
+ : new NsmLmSts();
1149
+
1150
+ // 의안번호(2221883)는 likms의 billId(PRC_...)로 자동 해석된다.
1151
+ const detail = await crawler.getDetail('2221883');
1152
+ console.log(detail.title, detail.proposalReason, detail.attachments);
1153
+
1154
+ // 저장해 둔 HTML을 오프라인으로 파싱 (네트워크 호출 없음)
1155
+ const offline = crawler.parseDetail(savedHtml);
1156
+ ```
1157
+
1158
+ likms 의안상세는 응답이 둘로 나뉜다(제목은 상세 페이지, 발의정보·제안이유는 심사정보 탭 AJAX).
1159
+ 어댑터의 `getDetail`은 두 요청을 합쳐 채우고, `parseDetail`은 넘긴 HTML에 들어 있는 값만 채운다.
1160
+
1161
+ | 항목 | NsmLmSts | LikmsNsmAdapter |
1162
+ | ---------------------- | -------------------------- | -------------------------------------------------------------- |
1163
+ | 인자 | 의안번호(`2219088`) | 의안번호(`2221883`) 또는 billId(`PRC_...`) |
1164
+ | `proposalInfo` | 발의정보 원문 한 줄 | 접수정보로 조합한 문자열 (`제안자, 제N호(제안일자). 제안회기`) |
1165
+ | `proposalDate` | `2026. 5. 1.` | `2026-10-13` |
1166
+ | `session` | `제435회 국회(임시회)` | `제22대(2024~2028)제439회` |
1167
+ | `attachments[].fileId` | 숫자 ID(`fnDownload` 인자) | 다운로드 URL |
1168
+
1169
+ 없는 의안번호면 `Error('bill not found: ...')`를 던진다(`NsmLmSts`는 빈 상세를 반환).
1170
+ `LikmsDetailParser`가 상세 페이지 HTML과 심사정보 조각 HTML을 모두 받아 파싱한다.
1171
+
1172
+ ### 쿼리 매핑
1173
+
1174
+ | INsmSearchQuery | ILikmsStateQuery | 비고 |
1175
+ | --------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1176
+ | `pageIndex` | `pageIndex` | |
1177
+ | `pageSize` | `pageSize`(`rows`) | 양쪽 다 임의 값 반영(실측). 기본값만 다르다 — NSM 20건 ↔ likms 50건 |
1178
+ | `sugCd` / `endSugCd` | `ageFrom` / `ageTo` | 제안대수 |
1179
+ | `sgtCls` | `proposerKind` | 900201→GOV, 900202→MEMASEMBY, 900203→CHRPR |
1180
+ | `scCptPpostCmt` | `ageCmtId` | 상임위 **이름**을 `/bi/bill/sch/findAgeCmt.do` 목록에서 코드로 해석(꼬리표·공백 무시). 대수 범위는 `sugCd`/`endSugCd`를 따른다 — 이름이 없거나 여러 코드면 예외 |
1181
+ | `scPpsUsr` | — (미지원) | 제안자명 — likms는 이름을 받지 않아 옮길 수 없다. 지정하면 예외를 던진다(조용히 무시하면 필터가 빠진 전체 결과가 나옴). `adapter.likms.findRepresentCandidates()`로 후보를 골라 `representId`로 조회 |
1182
+ | `searchStDtNew` / `searchEdDtNew` | `fromPropDt` / `toPropDt` | 제안일자 — likms는 **양쪽 모두** 줘야 서버가 적용(한쪽만이면 전체). NSM은 `YYYYMMDD`여야 하므로(`YYYY-MM-DD` 양쪽 → 0건) 형식을 그대로 옮기면 필터가 사라진다 |
1183
+ | `sortOrder` | `ordCd` | ASC/DESC |
1184
+ | `scBlNmSct` | `billNo`(숫자) 또는 `billNm`(문자) | 값 형태로 판별 |
1185
+
1186
+ 동일 축이 없어 무시되는 필드: `rslRsltNmL`, `rslRsltNmR`, `cptOfiOrgCd`, `sortCol`, `issLawitmYn`, `stDt`/`edDt`.
1187
+
1188
+ NSM 쪽에 대응 필드가 없는 likms 전용 축은 `adapter.likms.search()`로 직접 지정한다: `cmtResultCd`(소관위 처리결과), `jntPrpslYn`(공동발의 여부), `stateDtlDivCd`(탭), `nmReSchText`(결과 내 재검색).
1189
+
1190
+ `searchPending`/`getAllPendingPages`는 포트에 포함하지 않는다. `NsmLmSts`는 서버 필터로 좁히지만 likms는 전체를 스캔해야 해서 같은 이름 뒤에 비용 차이를 숨기지 않기 위해서다. 호출부가 명시적으로 고를 수 있도록 `LikmsNsmAdapter`가 같은 이름의 **스캔 기반 대안**을 제공한다(위 “발의 상태 조회” 절 참조). 스캔이라 `options.maxPages`(기본 10) 밖에서만 접수 항목이 나오면 빈 결과를 주고, NSM과 달리 `progressStatus === '접수'` 항목이 없는 페이지는 건너뛰므로 `currentPage`도 원본 페이지 번호가 아니다. 계류의안 전용 API(`hasProposalReason`, `billId`, `clearSession` 등)가 필요하면 `adapter.likms`(`LikmsStateCrawler`)를 직접 사용한다.
1191
+
1192
+ ### NsmLmSts에서 교체할 때 (호환성 감사 요약)
1193
+
1194
+ 생성자만 바꾸면 되는 범위는 **목록 조회와 상세 조회**다.
1195
+
1196
+ | `NsmLmSts` 멤버 | 교체 결과 |
1197
+ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
1198
+ | `getListHTML`, `parseList`, `search`, `getPage`, `getAllPages` | 그대로 동작(시그니처·결과 필드 동일) |
1199
+ | `getDetailHTML`, `parseDetail`, `getDetail` | 그대로 동작 — `INsmDetailCrawler` 포트. 값 형식은 다르다(위 표 참고) |
1200
+ | `searchPending`, `getAllPendingPages` | 스캔 기반 대안 제공 — 서버 필터가 없어 `progressStatus === '접수'`로 걸러낸다. 포트에는 없다(비용 차이를 드러내기 위해) |
1201
+ | `getNsmListScreenshot`, `getDetailScreenshot`, `initBrowser`, `closeBrowser`, `takeScreenshot`, `updateScreenshotConfig` | 없음 — 어댑터는 스크린샷 기반(`ScreenshotBase`)을 상속하지 않는다 |
1202
+
1203
+ 수정 없이 컴파일되지만 **결과·비용을 확인해야 하는 것** (실측 2026-10-13):
1204
+
1205
+ - 무시되는 필터: `rslRsltNmL`, `rslRsltNmR`, `cptOfiOrgCd`, `sortCol`, `issLawitmYn`, `stDt`/`edDt`
1206
+ - `progressStatus` 어휘가 다름(NSM `발의`/`위원회 회부` ↔ likms `접수`/`소관위접수`/`소관위심사`/`소관위상정`/`본회의부의안건` — 실측)
1207
+ - `billName`에 발의자 괄호가 포함되고, `link`는 다른 호스트·경로 체계다
1208
+ - `ministry`/`progressDate`/`resolutionStatus`/`resolutionDate`는 빈 문자열
1209
+ - 기본 페이지 크기가 다르므로(20건 ↔ 50건) 페이지당 건수에 의존하면 `pageSize`를 명시한다
1210
+ - 총계가 다르다: 같은 조건에서 likms 15,950건 vs NSM 21,336건(실측) — `total`/페이지 수를 두 구현 사이에서 비교하면 안 된다
1211
+ - 제안일자 형식이 다르다: NSM은 `YYYYMMDD`(ISO로 주면 0건), likms는 `YYYY-MM-DD`이고 양쪽을 다 줘야 적용된다
1212
+ - `hydrateProposalReason`를 켠 채 어댑터를 쓰면 목록 조회마다 상세 요청이 붙는다(3건 실측 10.9s vs 2.1s) — 그 값은 `INsmBillItem`에 담기지 않는다
1213
+
1214
+ > 결과의 `ministry`/`progressDate`/`resolutionStatus`/`resolutionDate`는 계류의안 목록에 없는 값이라 빈 문자열로 채워진다.
1215
+
1216
+ ---
1217
+
800
1218
  ## Examples
801
1219
 
802
1220
  ### 기본 사용법
package/dist/config.d.ts CHANGED
@@ -10,5 +10,27 @@ export declare enum Config {
10
10
  LIKMS_DOMAIN = "https://likms.assembly.go.kr",
11
11
  LIKMS_DETAIL_PAGE_URL = "/bill/bi/billDetailPage.do",
12
12
  LIKMS_BILL_INFO_URL = "/bill/bi/bill/detail/billInfo.do",
13
- LIKMS_CURR_MENU_NO = "2600044"
13
+ LIKMS_FIND_BILL_URL = "/bill/bi/common/findBillDetail.do",
14
+ LIKMS_CURR_MENU_NO = "2600044",
15
+ LIKMS_STATE_PAGE_URL = "/bill/bi/bill/state/mooringBillPage.do",
16
+ LIKMS_STATE_SEARCH_URL = "/bill/bi/bill/state/searchBillStatePaging.do",
17
+ /**
18
+ * 동명이인 선택 팝업 — 이름이 겹치는 의원의 대수·한자명·정당명과 의원 ID를
19
+ * 표로 돌려준다(브라우저는 `membCnt > 1`일 때 이 팝업을 띄운다).
20
+ * `ageFrom`/`ageTo`가 없으면 400이므로 항상 보내야 한다.
21
+ */
22
+ LIKMS_SAME_NM_POPUP_URL = "/bill/bi/popup/selSameNmMemb.do",
23
+ /**
24
+ * 대수별 소관위원회 목록(JSON 응답, `ageCmtList`). 검색 파라미터 `ageCmtId`는
25
+ * 이름이 아니라 코드를 받으므로 이름→코드 해석에 쓴다.
26
+ * `ageFrom`/`ageTo`를 **둘 다** 줘야 그 범위로 좁혀지고(한 쪽만 주면 무시),
27
+ * 아무것도 주지 않으면 전체 대수 목록(같은 이름은 `(18대~22대)` 꼬리표로 구분)을 준다.
28
+ */
29
+ LIKMS_FIND_AGE_CMT_URL = "/bill/bi/bill/sch/findAgeCmt.do",
30
+ /**
31
+ * 계류의안 목록의 심사진행단계 값 중 '위원회 회부 이전' 단계.
32
+ * NSM `searchPending`(rslRsltNmL=900101 '발의')에 대응하며, 실측에서 두 집합이 일치했다.
33
+ */
34
+ LIKMS_PENDING_PROGRESS_STATUS = "\uC811\uC218",
35
+ LIKMS_CSRF_HEADER = "X-CSRF-TOKEN"
14
36
  }
package/dist/config.js CHANGED
@@ -16,6 +16,31 @@ var Config;
16
16
  Config["LIKMS_DOMAIN"] = "https://likms.assembly.go.kr";
17
17
  Config["LIKMS_DETAIL_PAGE_URL"] = "/bill/bi/billDetailPage.do";
18
18
  Config["LIKMS_BILL_INFO_URL"] = "/bill/bi/bill/detail/billInfo.do";
19
+ // 의안번호 → billId 해석 (JSON 응답). `/bill` 컨텍스트 경로가 필요하다(없으면 307).
20
+ Config["LIKMS_FIND_BILL_URL"] = "/bill/bi/common/findBillDetail.do";
19
21
  Config["LIKMS_CURR_MENU_NO"] = "2600044";
22
+ // 국회 의안정보시스템 의안현황 > 계류의안 (AJAX POST + Spring CSRF 필요)
23
+ Config["LIKMS_STATE_PAGE_URL"] = "/bill/bi/bill/state/mooringBillPage.do";
24
+ Config["LIKMS_STATE_SEARCH_URL"] = "/bill/bi/bill/state/searchBillStatePaging.do";
25
+ /**
26
+ * 동명이인 선택 팝업 — 이름이 겹치는 의원의 대수·한자명·정당명과 의원 ID를
27
+ * 표로 돌려준다(브라우저는 `membCnt > 1`일 때 이 팝업을 띄운다).
28
+ * `ageFrom`/`ageTo`가 없으면 400이므로 항상 보내야 한다.
29
+ */
30
+ Config["LIKMS_SAME_NM_POPUP_URL"] = "/bill/bi/popup/selSameNmMemb.do";
31
+ /**
32
+ * 대수별 소관위원회 목록(JSON 응답, `ageCmtList`). 검색 파라미터 `ageCmtId`는
33
+ * 이름이 아니라 코드를 받으므로 이름→코드 해석에 쓴다.
34
+ * `ageFrom`/`ageTo`를 **둘 다** 줘야 그 범위로 좁혀지고(한 쪽만 주면 무시),
35
+ * 아무것도 주지 않으면 전체 대수 목록(같은 이름은 `(18대~22대)` 꼬리표로 구분)을 준다.
36
+ */
37
+ Config["LIKMS_FIND_AGE_CMT_URL"] = "/bill/bi/bill/sch/findAgeCmt.do";
38
+ /**
39
+ * 계류의안 목록의 심사진행단계 값 중 '위원회 회부 이전' 단계.
40
+ * NSM `searchPending`(rslRsltNmL=900101 '발의')에 대응하며, 실측에서 두 집합이 일치했다.
41
+ */
42
+ Config["LIKMS_PENDING_PROGRESS_STATUS"] = "\uC811\uC218";
43
+ // Spring Security CSRF 헤더명 (biInit.js/axios 인터셉터와 동일)
44
+ Config["LIKMS_CSRF_HEADER"] = "X-CSRF-TOKEN";
20
45
  })(Config || (exports.Config = Config = {}));
21
46
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":";;;AAAA,IAAY,MAeX;AAfD,WAAY,MAAM;IAChB,+CAAqC,CAAA;IACrC,4DAAkD,CAAA;IAClD,+DAAqD,CAAA;IACrD,8DAAoD,CAAA;IACpD,iEAAuD,CAAA;IACvD,uIAA6H,CAAA;IAC7H,4CAA4C;IAC5C,wDAA8C,CAAA;IAC9C,6CAAmC,CAAA;IACnC,oCAAoC;IACpC,uDAA6C,CAAA;IAC7C,8DAAoD,CAAA;IACpD,kEAAwD,CAAA;IACxD,wCAA8B,CAAA;AAChC,CAAC,EAfW,MAAM,sBAAN,MAAM,QAejB"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":";;;AAAA,IAAY,MAwCX;AAxCD,WAAY,MAAM;IAChB,+CAAqC,CAAA;IACrC,4DAAkD,CAAA;IAClD,+DAAqD,CAAA;IACrD,8DAAoD,CAAA;IACpD,iEAAuD,CAAA;IACvD,uIAA6H,CAAA;IAC7H,4CAA4C;IAC5C,wDAA8C,CAAA;IAC9C,6CAAmC,CAAA;IACnC,oCAAoC;IACpC,uDAA6C,CAAA;IAC7C,8DAAoD,CAAA;IACpD,kEAAwD,CAAA;IACxD,8DAA8D;IAC9D,mEAAyD,CAAA;IACzD,wCAA8B,CAAA;IAC9B,sDAAsD;IACtD,yEAA+D,CAAA;IAC/D,iFAAuE,CAAA;IACvE;;;;OAIG;IACH,qEAA2D,CAAA;IAC3D;;;;;OAKG;IACH,oEAA0D,CAAA;IAC1D;;;OAGG;IACH,wDAAoC,CAAA;IACpC,sDAAsD;IACtD,4CAAkC,CAAA;AACpC,CAAC,EAxCW,MAAM,sBAAN,MAAM,QAwCjB"}
@@ -10,16 +10,24 @@ export declare class HttpClient {
10
10
  private timeout;
11
11
  private retryCount;
12
12
  private customHeaders;
13
+ /** 응답의 Set-Cookie를 세션 동안 유지하기 위한 간단한 쿠키 저장소 */
14
+ private readonly cookies;
13
15
  constructor(config: HttpClientConfig);
16
+ private buildCookieHeader;
17
+ /**
18
+ * Set-Cookie 헤더를 쿠키 저장소에 반영합니다.
19
+ * 세션 쿠키(JSESSIONID 등)가 필요한 사이트(계류의안 AJAX 등)에서 사용합니다.
20
+ */
21
+ private storeCookies;
14
22
  private extractCharset;
15
23
  private normalizeCharset;
16
24
  private decodeBody;
17
25
  private makeRequest;
18
- get(url: URL): Promise<string>;
26
+ get(url: URL, headers?: Record<string, string>): Promise<string>;
19
27
  /**
20
28
  * URL-encoded 본문을 POST로 전송합니다.
21
29
  * 국회 의안정보시스템의 일부 조각(_fragment) 엔드포인트는 GET이 아닌 POST만 허용합니다.
22
30
  */
23
- post(url: URL, body: string): Promise<string>;
31
+ post(url: URL, body: string, headers?: Record<string, string>): Promise<string>;
24
32
  private requestWithRetry;
25
33
  }