mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Compare commits
5 commits
main
...
feature/#2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba4eadac37 | ||
|
|
19de41c166 | ||
|
|
bdba986e3e | ||
|
|
8bcd5fe7cf | ||
|
|
1fc242743c |
13 changed files with 1028 additions and 1 deletions
5
.changeset/local-election-candidate-search.md
Normal file
5
.changeset/local-election-candidate-search.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"local-election-candidate-search": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a public NEC local election candidate lookup skill and helper CLI.
|
||||||
|
|
@ -41,6 +41,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
||||||
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
|
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
|
||||||
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
|
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
|
||||||
| 창업진흥원 K-Startup 조회 | `kstartup-search` | 창업진흥원 K-Startup 통합공고 사업·지원사업 공고·창업 콘텐츠·통계보고서 조회 (공공데이터포털 15125364, 프록시 경유) | 불필요 | [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md) |
|
| 창업진흥원 K-Startup 조회 | `kstartup-search` | 창업진흥원 K-Startup 통합공고 사업·지원사업 공고·창업 콘텐츠·통계보고서 조회 (공공데이터포털 15125364, 프록시 경유) | 불필요 | [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md) |
|
||||||
|
| 지방선거 후보자 조회 | `local-election-candidate-search` | 중앙선거관리위원회 선거통계시스템 공개 통합검색으로 지방선거 후보자 이력·선거종류·정당·지역·득표 정보를 이름 기준으로 조회 | 불필요 | [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md) |
|
||||||
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
|
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
|
||||||
| 한국 개인정보처리방침·이용약관 자동 생성 | `korean-privacy-terms` | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
|
| 한국 개인정보처리방침·이용약관 자동 생성 | `korean-privacy-terms` | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
|
||||||
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
|
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
|
||||||
|
|
@ -157,6 +158,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
||||||
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
|
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
|
||||||
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
|
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
|
||||||
- [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md)
|
- [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md)
|
||||||
|
- [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md)
|
||||||
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
|
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
|
||||||
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
|
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
|
||||||
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)
|
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)
|
||||||
|
|
|
||||||
63
docs/features/local-election-candidate-search.md
Normal file
63
docs/features/local-election-candidate-search.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 지방선거 후보자 조회 가이드
|
||||||
|
|
||||||
|
`local-election-candidate-search`는 중앙선거관리위원회 선거통계시스템(`info.nec.go.kr`)의 공개 **통합검색** HTML 표면을 직접 조회하는 read-only 스킬이다. upstream이 인증/키 없이 열려 있는 공개 표면이므로 `k-skill-proxy`를 사용하지 않는다.
|
||||||
|
|
||||||
|
## 공개 접근 경로
|
||||||
|
|
||||||
|
- 진입점: `https://info.nec.go.kr/search/searchCandidate.xhtml`
|
||||||
|
- 방식: `POST searchKeyword=<정확한 후보자 성명>`
|
||||||
|
- 기본 정책: 지방선거 관련 선거코드만 반환
|
||||||
|
- `3` 시·도지사선거
|
||||||
|
- `4` 구·시·군의 장선거
|
||||||
|
- `5` 시·도의회의원선거
|
||||||
|
- `6` 구·시·군의회의원선거
|
||||||
|
- `8` 광역의원비례대표선거
|
||||||
|
- `9` 기초의원비례대표선거
|
||||||
|
- `11` 교육감선거
|
||||||
|
|
||||||
|
이 경로는 NEC 화면에 공개된 후보자 성명 기반 통합검색이며, 선거별 메뉴에서 모든 시도/구시군/선거구 조합을 먼저 선택하는 방식보다 조회 진입점이 좁고 안정적이다.
|
||||||
|
|
||||||
|
## CLI 사용
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node packages/local-election-candidate-search/src/cli.js 오세훈 --election 시도지사 --region 서울 --limit 5
|
||||||
|
node packages/local-election-candidate-search/src/cli.js 김동연 --date 2014 --election 기초의원 --region 동작
|
||||||
|
node packages/local-election-candidate-search/src/cli.js 이재명 --all --limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
패키지 설치 후에는 bin 이름을 사용할 수 있다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
local-election-candidate-search 오세훈 --election 시도지사 --region 서울
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node API
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { searchCandidates } = require("local-election-candidate-search")
|
||||||
|
|
||||||
|
const result = await searchCandidates({
|
||||||
|
name: "오세훈",
|
||||||
|
election: "시도지사",
|
||||||
|
region: "서울",
|
||||||
|
limit: 5
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 출력 필드
|
||||||
|
|
||||||
|
반환 JSON의 `items[]`에는 upstream HTML에 있는 범위에서 다음 필드가 포함된다.
|
||||||
|
|
||||||
|
- `name`, `hanja`, `birth_date`, `gender`
|
||||||
|
- `election_date`, `election_name`, `election_code`, `election_type`
|
||||||
|
- `party`, `district`, `votes`, `vote_share`, `elected`
|
||||||
|
- `job`, `education`, `career[]`
|
||||||
|
- `city_code`, `sgg_city_code`, `town_code`
|
||||||
|
|
||||||
|
## 실패 모드와 주의사항
|
||||||
|
|
||||||
|
- NEC 통합검색은 정확한 후보자명을 기준으로 동작하므로 동명이인이 나올 수 있다. 결과를 보여줄 때는 선거일·선거종류·지역을 함께 표시한다.
|
||||||
|
- 사용자가 범위를 좁히면 `--election`, `--date`, `--region` 필터를 적용한다.
|
||||||
|
- `--all`을 주지 않으면 지방선거 관련 선거코드만 반환한다.
|
||||||
|
- 빈 결과, NetFunnel 대기열, 점검/로그인/차단 페이지, upstream HTML 변경은 `warnings[]`에 명시한다.
|
||||||
|
- 로그인, CAPTCHA, 후보 등록/신고, 파일 다운로드, 정치 자금/선거 사무 자동화는 하지 않는다.
|
||||||
|
|
@ -124,6 +124,7 @@ bash scripts/check-setup.sh
|
||||||
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
|
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
|
||||||
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
|
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
|
||||||
- [창업진흥원 K-Startup 조회 가이드](features/kstartup-search.md)
|
- [창업진흥원 K-Startup 조회 가이드](features/kstartup-search.md)
|
||||||
|
- [지방선거 후보자 조회 가이드](features/local-election-candidate-search.md)
|
||||||
- [보안/시크릿 정책](security-and-secrets.md)
|
- [보안/시크릿 정책](security-and-secrets.md)
|
||||||
|
|
||||||
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.
|
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.
|
||||||
|
|
|
||||||
87
local-election-candidate-search/SKILL.md
Normal file
87
local-election-candidate-search/SKILL.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
---
|
||||||
|
name: local-election-candidate-search
|
||||||
|
description: 중앙선거관리위원회 선거통계시스템 공개 통합검색으로 한국 지방선거 후보자 정보를 이름/선거종류/지역 기준으로 조회한다.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
category: civic
|
||||||
|
locale: ko-KR
|
||||||
|
phase: v1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Local Election Candidate Search
|
||||||
|
|
||||||
|
## What this skill does
|
||||||
|
|
||||||
|
중앙선거관리위원회(NEC) 선거통계시스템의 공개 통합검색에서 후보자 이름을 조회하고, 지방선거 관련 후보자 이력만 기본으로 정리한다. 후보자명, 한자명, 생년월일/성별, 선거일, 선거명, 선거종류, 정당, 선거구, 득표, 직업, 학력, 경력 등을 반환한다.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- 사용자가 “지방선거 후보”, “시도지사 후보”, “기초의원 후보”, “교육감 후보” 등을 이름/지역/선거일 기준으로 찾아 달라고 할 때
|
||||||
|
- 중앙선관위 선거통계시스템에서 공개된 후보자 이력을 확인해야 할 때
|
||||||
|
- 동명이인이 있을 수 있어 후보자명 + 선거종류/지역/연도 필터가 필요한 때
|
||||||
|
|
||||||
|
## Public access path
|
||||||
|
|
||||||
|
Chosen path: NEC integrated candidate search.
|
||||||
|
|
||||||
|
- Entry page: `https://info.nec.go.kr/search/searchCandidate.xhtml`
|
||||||
|
- Method: unauthenticated public `POST`
|
||||||
|
- Required form field: `searchKeyword=<정확한 후보자 성명>`
|
||||||
|
- Helper package: `local-election-candidate-search`
|
||||||
|
|
||||||
|
Why this path: the visible NEC UI explicitly exposes candidate-name integrated search across recent and historical elections, and it returns the candidate result cards in server-rendered HTML. It is more stable than scraping per-election menu pages because it does not require selecting every city/town/constituency combo first.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Use the package CLI from this repository or installed workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx local-election-candidate-search 오세훈 --election 시도지사 --region 서울 --limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Narrow ambiguous/homonym results:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx local-election-candidate-search 김동연 --date 2014 --election 기초의원 --region 동작
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Include non-local races only when the user asks for all NEC integrated-search matches:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx local-election-candidate-search 이재명 --all --limit 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Candidate name: exact Korean name; required.
|
||||||
|
- `--election`: one of `시도지사`, `기초단체장`, `광역의원`, `기초의원`, `광역비례`, `기초비례`, `교육감`.
|
||||||
|
- `--date` / `--year`: `YYYY`, `YYYYMMDD`, or `YYYY.MM.DD`.
|
||||||
|
- `--region`: free text filter against parsed district/region text.
|
||||||
|
- `--limit`: max rows, capped at 100.
|
||||||
|
- `--all`: include non-local election results.
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
Return concise JSON. Each `items[]` row may include:
|
||||||
|
|
||||||
|
- `name`, `hanja`, `birth_date`, `gender`
|
||||||
|
- `election_date`, `election_name`, `election_code`, `election_type`
|
||||||
|
- `party`, `district`, `votes`, `vote_share`, `elected`
|
||||||
|
- `job`, `education`, `career[]`
|
||||||
|
- upstream code fields such as `city_code`, `sgg_city_code`, `town_code`
|
||||||
|
|
||||||
|
`summary.upstream_result_limit` shows the NEC row count requested before local client-side filters. Filtered searches request up to 100 upstream rows first, then apply exact-name matching, local/election/date/region filters, deduplication, and the final `--limit`.
|
||||||
|
|
||||||
|
## Failure modes
|
||||||
|
|
||||||
|
- `no candidate results`: NEC returned no matching card or filters removed all matches.
|
||||||
|
- `unexpected NEC search HTML`: upstream may be in maintenance, NetFunnel queue, login/blocked state, or markup changed.
|
||||||
|
- `NEC search page was capped`: filtered results are based on the maximum fetched page and may require upstream pagination for exhaustive coverage.
|
||||||
|
- Homonyms: the same name can appear across many elections; always show election date/type/district and apply user-provided filters.
|
||||||
|
- Future elections: candidate registration data may be incomplete until NEC publishes it.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Results are sourced from `info.nec.go.kr` public HTML.
|
||||||
|
- Local-election filtering is applied unless the user requested `--all`.
|
||||||
|
- Any warnings/failure modes are shown instead of silently claiming no results.
|
||||||
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -1090,6 +1090,10 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/local-election-candidate-search": {
|
||||||
|
"resolved": "packages/local-election-candidate-search",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
@ -1900,6 +1904,16 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/local-election-candidate-search": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"local-election-candidate-search": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/market-kurly-search": {
|
"packages/market-kurly-search": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run",
|
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
|
||||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"release:npm": "changeset publish"
|
"release:npm": "changeset publish"
|
||||||
|
|
|
||||||
47
packages/local-election-candidate-search/README.md
Normal file
47
packages/local-election-candidate-search/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# local-election-candidate-search
|
||||||
|
|
||||||
|
Public Korean local election candidate lookup client for the `local-election-candidate-search` k-skill.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
- Official public surface: 중앙선거관리위원회 선거통계시스템 통합검색 `https://info.nec.go.kr/search/searchCandidate.xhtml`
|
||||||
|
- Request method: unauthenticated `POST` with `searchKeyword=<exact candidate name>`.
|
||||||
|
- The NEC page states that integrated search looks up historical/recent preliminary candidates, candidates, and elected persons by exact name.
|
||||||
|
|
||||||
|
This client calls the public NEC HTML surface directly from the user's machine. No proxy, API key, login, CAPTCHA bypass, registration, or filing automation is used.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { searchCandidates } = require("local-election-candidate-search")
|
||||||
|
|
||||||
|
const result = await searchCandidates({
|
||||||
|
name: "오세훈",
|
||||||
|
election: "시도지사",
|
||||||
|
region: "서울",
|
||||||
|
limit: 5
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
local-election-candidate-search 오세훈 --election 시도지사 --region 서울 --limit 5
|
||||||
|
local-election-candidate-search 김동연 --date 2014 --election 기초의원
|
||||||
|
local-election-candidate-search 이재명 --all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Returned fields
|
||||||
|
|
||||||
|
Each item includes parsed candidate/profile and election fields when present: `name`, `hanja`, `birth_date`, `gender`, `election_date`, `election_name`, `election_code`, `election_type`, `party`, `district`, `votes`, `vote_share`, `job`, `education`, and `career`.
|
||||||
|
|
||||||
|
By default, the client filters to local-election-related NEC election codes: 시·도지사(3), 구·시·군의 장(4), 시·도의회의원(5), 구·시·군의회의원(6), 광역비례(8), 기초비례(9), 교육감(11). Use `--all` / `localOnly:false` to include non-local races from NEC integrated search.
|
||||||
|
|
||||||
|
`summary.upstream_result_limit` records how many NEC rows were requested before local client-side filters were applied. When election/date/region/local filters are active, the client fetches up to 100 upstream rows first and then applies the user-facing `limit` after exact-name matching, filtering, and deduplication.
|
||||||
|
|
||||||
|
## Boundaries and failure modes
|
||||||
|
|
||||||
|
- NEC integrated search works best with exact Korean candidate names and may return homonyms; use `--election`, `--date`, and `--region` to narrow results.
|
||||||
|
- The upstream is HTML, so parser warnings are returned for empty results, maintenance pages, NetFunnel queues, login prompts, or unexpected markup changes.
|
||||||
|
- If the fetched upstream page reaches the 100-row cap while client-side filters are active, the result includes a warning that additional matches may require pagination.
|
||||||
|
- This package does not automate NEC detail popups, file downloads, account login, CAPTCHA, political filing, or any privileged workflow.
|
||||||
35
packages/local-election-candidate-search/package.json
Normal file
35
packages/local-election-candidate-search/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "local-election-candidate-search",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Public NEC Korean local election candidate lookup client for k-skill",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"bin": {
|
||||||
|
"local-election-candidate-search": "src/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"k-skill",
|
||||||
|
"nec",
|
||||||
|
"korea",
|
||||||
|
"local-election",
|
||||||
|
"candidate"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"lint": "node --check src/index.js && node --check src/cli.js && node --check test/index.test.js",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
packages/local-election-candidate-search/src/cli.js
Executable file
69
packages/local-election-candidate-search/src/cli.js
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
const { searchCandidates } = require("./index")
|
||||||
|
|
||||||
|
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
|
||||||
|
if (options.help) {
|
||||||
|
printHelp(io)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await searchCandidates(options)
|
||||||
|
io.log(JSON.stringify(result, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {}
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i]
|
||||||
|
if (arg === "--name" || arg === "--query" || arg === "-q" || arg === "--keyword") options.name = argv[++i] || ""
|
||||||
|
else if (arg === "--election" || arg === "--type" || arg === "--election-code") options.election = argv[++i] || ""
|
||||||
|
else if (arg === "--date" || arg === "--year" || arg === "--election-date") options.electionDate = argv[++i] || ""
|
||||||
|
else if (arg === "--region" || arg === "--city" || arg === "--district") options.region = argv[++i] || ""
|
||||||
|
else if (arg === "--limit") options.limit = argv[++i] || ""
|
||||||
|
else if (arg === "--all" || arg === "--include-all") options.localOnly = false
|
||||||
|
else if (arg === "--local-only") options.localOnly = true
|
||||||
|
else if (arg === "--include-html") options.includeHtml = true
|
||||||
|
else if (arg === "--fixture") options.fixture = argv[++i] || ""
|
||||||
|
else if (arg === "--help" || arg === "-h") options.help = true
|
||||||
|
else if (!options.name) options.name = arg
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(io = console) {
|
||||||
|
io.log(`Usage: local-election-candidate-search <candidate-name> [options]
|
||||||
|
|
||||||
|
Search the official NEC integrated candidate search and return Korean local election candidate entries.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
local-election-candidate-search 오세훈 --election 시도지사 --region 서울 --limit 5
|
||||||
|
local-election-candidate-search 김동연 --date 2014 --election 기초의원
|
||||||
|
local-election-candidate-search 이재명 --all
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--name, -q <name> Exact candidate name (required; NEC search works best with exact names).
|
||||||
|
--election <type> 시도지사, 기초단체장, 광역의원, 기초의원, 광역비례, 기초비례, 교육감.
|
||||||
|
--date, --year <date> Election year or date (YYYY, YYYYMMDD, YYYY.MM.DD).
|
||||||
|
--region <text> Filter district/region text, e.g. 서울 or 동작.
|
||||||
|
--limit <number> Max returned entries (default 20; max 100).
|
||||||
|
--all Include non-local election results too.
|
||||||
|
--include-html Include raw upstream HTML for diagnostics.
|
||||||
|
--fixture <path> Parse a saved NEC HTML fixture instead of fetching.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(error) {
|
||||||
|
if (process.env.LOCAL_ELECTION_CANDIDATE_SEARCH_DEBUG && error && error.stack) return error.stack
|
||||||
|
if (error && error.message) return `Error: ${error.message}`
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(argv = process.argv.slice(2), io = console) {
|
||||||
|
return main(parseArgs(argv), io).catch((error) => {
|
||||||
|
io.error(formatError(error))
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) run()
|
||||||
|
|
||||||
|
module.exports = { parseArgs, printHelp, formatError, main, run }
|
||||||
407
packages/local-election-candidate-search/src/index.js
Normal file
407
packages/local-election-candidate-search/src/index.js
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
const fs = require("node:fs/promises")
|
||||||
|
|
||||||
|
const NEC_SEARCH_URL = "https://info.nec.go.kr/search/searchCandidate.xhtml"
|
||||||
|
const DEFAULT_TIMEOUT_MS = 20000
|
||||||
|
const DEFAULT_LIMIT = 20
|
||||||
|
const MAX_LIMIT = 100
|
||||||
|
const LOCAL_ELECTION_CODES = new Set(["3", "4", "5", "6", "8", "9", "11"])
|
||||||
|
|
||||||
|
const ELECTION_CODE_ALIASES = new Map([
|
||||||
|
["3", "3"], ["시도지사", "3"], ["시·도지사", "3"], ["시도지사선거", "3"], ["광역단체장", "3"], ["governor", "3"],
|
||||||
|
["4", "4"], ["구시군의장", "4"], ["구시군장", "4"], ["구·시·군의장", "4"], ["구·시·군의 장", "4"], ["기초단체장", "4"], ["mayor", "4"],
|
||||||
|
["5", "5"], ["시도의원", "5"], ["시도의회의원", "5"], ["광역의원", "5"], ["metro-council", "5"],
|
||||||
|
["6", "6"], ["구시군의원", "6"], ["구시군의회의원", "6"], ["기초의원", "6"], ["local-council", "6"],
|
||||||
|
["8", "8"], ["광역비례", "8"], ["광역의원비례", "8"], ["광역의원비례대표", "8"],
|
||||||
|
["9", "9"], ["기초비례", "9"], ["기초의원비례", "9"], ["기초의원비례대표", "9"],
|
||||||
|
["11", "11"], ["교육감", "11"], ["superintendent", "11"]
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeToken(value) {
|
||||||
|
return String(value == null ? "" : value).replace(/[\s·ㆍ,._-]+/g, "").trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtml(value) {
|
||||||
|
return String(value == null ? "" : value)
|
||||||
|
.replace(/&#(\d+);/g, (match, dec) => decodeNumericEntity(Number.parseInt(dec, 10), match))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (match, hex) => decodeNumericEntity(Number.parseInt(hex, 16), match))
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeNumericEntity(codePoint, fallback) {
|
||||||
|
try {
|
||||||
|
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return fallback
|
||||||
|
return String.fromCodePoint(codePoint)
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(html) {
|
||||||
|
return decodeHtml(String(html || "")
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, " ")
|
||||||
|
.replace(/<[^>]+>/g, " "))
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return decodeHtml(String(value == null ? "" : value)).replace(/\s+/g, " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHtmlAttr(attrs, name) {
|
||||||
|
const match = String(attrs || "").match(new RegExp(`\\b${name}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i"))
|
||||||
|
return match ? decodeHtml(match[2]) : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInteger(value, { defaultValue, min = 1, max = Number.MAX_SAFE_INTEGER, label }) {
|
||||||
|
if (value === undefined || value === null || String(value).trim() === "") return defaultValue
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (!/^\d+$/.test(text)) throw new Error(`Provide valid ${label}.`)
|
||||||
|
const parsed = Number.parseInt(text, 10)
|
||||||
|
if (parsed < min) return min
|
||||||
|
if (parsed > max) return max
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBoolean(value, defaultValue) {
|
||||||
|
if (value === undefined || value === null || value === "") return defaultValue
|
||||||
|
if (typeof value === "boolean") return value
|
||||||
|
const token = normalizeToken(value)
|
||||||
|
if (["1", "true", "yes", "y", "local", "지방", "지방선거"].includes(token)) return true
|
||||||
|
if (["0", "false", "no", "n", "all", "전체", "includeall"].includes(token)) return false
|
||||||
|
return Boolean(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeElectionCode(value) {
|
||||||
|
if (value === undefined || value === null || String(value).trim() === "") return null
|
||||||
|
const token = normalizeToken(value)
|
||||||
|
const code = ELECTION_CODE_ALIASES.get(token)
|
||||||
|
if (!code) throw new Error(`Unsupported local election type: ${value}`)
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeElectionDate(value) {
|
||||||
|
if (value === undefined || value === null || String(value).trim() === "") return null
|
||||||
|
const digits = String(value).replace(/\D/g, "")
|
||||||
|
if (/^\d{4}$/.test(digits)) return digits
|
||||||
|
if (/^\d{8}$/.test(digits)) return digits
|
||||||
|
throw new Error("electionDate must be YYYY or YYYYMMDD/ YYYY.MM.DD.")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchOptions(options = {}) {
|
||||||
|
const name = cleanText(options.name ?? options.keyword ?? options.q ?? options.query ?? options.searchKeyword)
|
||||||
|
if (!name) throw new Error("Provide a candidate name to search.")
|
||||||
|
if (name.length > 30) throw new Error("Candidate name must be 30 characters or fewer.")
|
||||||
|
const normalized = {
|
||||||
|
name,
|
||||||
|
localOnly: normalizeBoolean(options.localOnly ?? options.local ?? options.onlyLocal, true),
|
||||||
|
electionCode: normalizeElectionCode(options.electionCode ?? options.election ?? options.electionType ?? options.type),
|
||||||
|
electionDate: normalizeElectionDate(options.electionDate ?? options.date ?? options.year ?? options.electionName),
|
||||||
|
region: cleanText(options.region ?? options.city ?? options.district) || null,
|
||||||
|
limit: parsePositiveInteger(options.limit ?? options.pageSize, { defaultValue: DEFAULT_LIMIT, min: 1, max: MAX_LIMIT, label: "limit" }),
|
||||||
|
includeHtml: Boolean(options.includeHtml)
|
||||||
|
}
|
||||||
|
normalized.upstreamLimit = parsePositiveInteger(options.upstreamLimit ?? options.recordCountPerPage, {
|
||||||
|
defaultValue: hasClientSideFilters(normalized) ? MAX_LIMIT : normalized.limit,
|
||||||
|
min: normalized.limit,
|
||||||
|
max: MAX_LIMIT,
|
||||||
|
label: "upstream limit"
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasClientSideFilters(options) {
|
||||||
|
return Boolean(options.localOnly || options.electionCode || options.electionDate || options.region)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchRequest(options = {}) {
|
||||||
|
const normalized = normalizeSearchOptions(options)
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
searchKeyword: normalized.name,
|
||||||
|
pageIndex: "1",
|
||||||
|
firstIndex: "0",
|
||||||
|
recordCountPerPage: String(normalized.upstreamLimit)
|
||||||
|
}).toString()
|
||||||
|
return {
|
||||||
|
url: NEC_SEARCH_URL,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"user-agent": "Mozilla/5.0 (compatible; k-skill-local-election-candidate-search/0.1)",
|
||||||
|
referer: NEC_SEARCH_URL
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
options: normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBirthDateAndGender(text, attrs = "") {
|
||||||
|
const attrBirthday = getHtmlAttr(attrs, "data-birthday")
|
||||||
|
const dateMatch = String(text || "").match(/(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일\s*\(([^)]+)\)/)
|
||||||
|
const birthDate = dateMatch
|
||||||
|
? `${dateMatch[1]}-${dateMatch[2].padStart(2, "0")}-${dateMatch[3].padStart(2, "0")}`
|
||||||
|
: (/^\d{8}$/.test(attrBirthday) ? `${attrBirthday.slice(0, 4)}-${attrBirthday.slice(4, 6)}-${attrBirthday.slice(6, 8)}` : null)
|
||||||
|
const gender = dateMatch ? cleanText(dateMatch[4]) : null
|
||||||
|
return { birthDate, gender }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProfileFields(listHtml) {
|
||||||
|
const fields = {}
|
||||||
|
const cellRegex = /<td\b[^>]*class=(['"])th\1[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>[\s\S]*?<\/td>\s*<td\b[^>]*>([\s\S]*?)<\/td>/gi
|
||||||
|
for (const match of listHtml.matchAll(cellRegex)) {
|
||||||
|
const key = cleanText(stripTags(match[2]))
|
||||||
|
const rawValue = match[3]
|
||||||
|
const paragraphs = [...rawValue.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map((p) => stripTags(p[1])).filter(Boolean)
|
||||||
|
const value = paragraphs.length ? paragraphs : stripTags(rawValue)
|
||||||
|
if (key) fields[key] = value
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
job: asText(fields["직업"]),
|
||||||
|
education: asText(fields["학력"]),
|
||||||
|
career: asList(fields["경력"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asText(value) {
|
||||||
|
if (Array.isArray(value)) return value.join("; ") || null
|
||||||
|
return value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function asList(value) {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
return value ? [value] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTitle(titleHtml) {
|
||||||
|
const mark = titleHtml.match(/<mark[^>]*>\s*\[([0-9.]+)\]\s*([\s\S]*?)<\/mark>/i)
|
||||||
|
const electionDate = mark ? normalizeElectionDate(mark[1]) : null
|
||||||
|
const electionName = mark ? stripTags(mark[2]) : null
|
||||||
|
const text = stripTags(titleHtml)
|
||||||
|
const afterMark = mark ? stripTags(titleHtml.slice(mark.index + mark[0].length)) : text
|
||||||
|
const segments = afterMark.split("/").map((part) => cleanText(part)).filter(Boolean)
|
||||||
|
let party = segments[0] || null
|
||||||
|
let electionType = segments[1] || null
|
||||||
|
let district = segments[2] || null
|
||||||
|
let votes = null
|
||||||
|
let voteShare = null
|
||||||
|
let elected = /당선/.test(afterMark)
|
||||||
|
|
||||||
|
if (segments[0] && /선거$/.test(segments[0])) {
|
||||||
|
party = null
|
||||||
|
electionType = segments[0]
|
||||||
|
district = segments[1]
|
||||||
|
}
|
||||||
|
const voteSegment = segments.find((segment) => /표/.test(segment)) || ""
|
||||||
|
const voteMatch = voteSegment.match(/([0-9,]+)\s*표/)
|
||||||
|
if (voteMatch) votes = Number.parseInt(voteMatch[1].replace(/,/g, ""), 10)
|
||||||
|
const shareMatch = voteSegment.match(/\(([0-9.]+%)\)/)
|
||||||
|
if (shareMatch) voteShare = shareMatch[1]
|
||||||
|
if (district && /표/.test(district)) district = null
|
||||||
|
return { electionDate, electionName, party, electionType, district, votes, voteShare, elected, rawTitleText: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactObject(value) {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => {
|
||||||
|
if (entry === null || entry === undefined || entry === "") return false
|
||||||
|
if (Array.isArray(entry) && entry.length === 0) return false
|
||||||
|
return true
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnexpectedHtml(html) {
|
||||||
|
const text = stripTags(html)
|
||||||
|
return !/resultDiv|class=["']result|검색결과|fn_firstView/.test(html) && /NetFunnel|로그인|점검|대기열|접근|차단|서비스/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnparsedCandidateResults(html) {
|
||||||
|
if (!/resultDiv|검색결과|fn_firstView/.test(html)) return false
|
||||||
|
if (/<div\b[^>]*class=(['"])[^'"]*\bresult\b[^'"]*\1/i.test(html)) return false
|
||||||
|
const resultDiv = String(html || "").match(/<div\b[^>]*class=(['"])[^'"]*\bresultDiv\b[^'"]*\1[^>]*>([\s\S]*?)<\/div>/i)
|
||||||
|
if (!resultDiv) return false
|
||||||
|
return stripTags(resultDiv[2]).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterItem(item, options) {
|
||||||
|
if (options.localOnly && !item.is_local_election) return false
|
||||||
|
if (options.electionCode && item.election_code !== options.electionCode) return false
|
||||||
|
if (options.electionDate) {
|
||||||
|
const digits = (item.election_name_code || "").replace(/\D/g, "")
|
||||||
|
if (options.electionDate.length === 4) {
|
||||||
|
if (!digits.startsWith(options.electionDate)) return false
|
||||||
|
} else if (digits !== options.electionDate) return false
|
||||||
|
}
|
||||||
|
if (options.region) {
|
||||||
|
const haystack = `${item.district || ""} ${item.city_code || ""}`
|
||||||
|
if (!normalizeToken(haystack).includes(normalizeToken(options.region))) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCandidateElectionKey(item) {
|
||||||
|
return [
|
||||||
|
item.name,
|
||||||
|
item.birth_date,
|
||||||
|
item.election_name_code,
|
||||||
|
item.election_code,
|
||||||
|
item.party,
|
||||||
|
item.district,
|
||||||
|
item.votes,
|
||||||
|
item.vote_share
|
||||||
|
].map((value) => cleanText(value)).join("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSearchHtml(html, options = {}) {
|
||||||
|
const normalized = normalizeSearchOptions(options)
|
||||||
|
const warnings = []
|
||||||
|
const items = []
|
||||||
|
const itemKeys = new Set()
|
||||||
|
const source = { url: NEC_SEARCH_URL, method: "POST", surface: "NEC election statistics integrated candidate search" }
|
||||||
|
if (isUnexpectedHtml(html)) {
|
||||||
|
warnings.push(`unexpected NEC search HTML; possible NetFunnel 로그인 점검 block page: ${stripTags(html).slice(0, 160)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultRegex = /<div\b([^>]*)class=(['"])[^'"]*\bresult\b[^'"]*\2([^>]*)>([\s\S]*?)(?=<div\b[^>]*class=(['"])[^'"]*\bresult\b|<div\b[^>]*class=(['"])[^'"]*\bpage\b|<\/body>|$)/gi
|
||||||
|
let parsedResultCards = 0
|
||||||
|
let parsedElectionEntries = 0
|
||||||
|
for (const resultMatch of html.matchAll(resultRegex)) {
|
||||||
|
parsedResultCards += 1
|
||||||
|
const resultAttrs = `${resultMatch[1] || ""} ${resultMatch[3] || ""}`
|
||||||
|
const resultHtml = resultMatch[4]
|
||||||
|
const listRegex = /<div\b([^>]*)class=(['"])[^'"]*\blist\b[^'"]*\2([^>]*)>([\s\S]*?)(?=<div\b[^>]*class=(['"])[^'"]*\blist\b|<\/div>\s*<\/div>\s*(?:<div\b[^>]*class=(['"])[^'"]*\bresult\b|<\/div>|$))/gi
|
||||||
|
const listMatches = [...resultHtml.matchAll(listRegex)]
|
||||||
|
parsedElectionEntries += listMatches.length
|
||||||
|
const nameMatch = resultHtml.match(/<p\b[^>]*class=(['"])[^'"]*\bname\b[^'"]*\1[^>]*>([\s\S]*?)<\/p>/i)
|
||||||
|
const nameHtml = nameMatch ? nameMatch[2] : ""
|
||||||
|
const strongMatch = nameHtml.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i)
|
||||||
|
const hanjaMatch = nameHtml.match(/<span\b[^>]*class=(['"])[^'"]*\bhanja\b[^'"]*\1[^>]*>\s*\((.*?)\)\s*<\/span>/i)
|
||||||
|
const dateMatch = nameHtml.match(/<span\b[^>]*class=(['"])[^'"]*\bdate\b[^'"]*\1[^>]*>([\s\S]*?)<\/span>/i)
|
||||||
|
const personName = strongMatch ? stripTags(strongMatch[1]) : null
|
||||||
|
if (!personName) {
|
||||||
|
warnings.push("missing candidate name in NEC result card; skipped result because exact-name matching could not be verified")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (normalizeToken(personName) !== normalizeToken(normalized.name)) {
|
||||||
|
warnings.push(`candidate name mismatch in NEC result card; expected ${normalized.name} but found ${personName}; skipped result`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const hanja = hanjaMatch ? stripTags(hanjaMatch[2]) : null
|
||||||
|
const { birthDate, gender } = parseBirthDateAndGender(dateMatch ? stripTags(dateMatch[2]) : stripTags(nameHtml), resultAttrs)
|
||||||
|
|
||||||
|
for (const listMatch of listMatches) {
|
||||||
|
const listAttrs = `${listMatch[1] || ""} ${listMatch[3] || ""}`
|
||||||
|
const listHtml = listMatch[4]
|
||||||
|
const titleMatch = listHtml.match(/<div\b[^>]*class=(['"])[^'"]*\bt\b[^'"]*\1[^>]*>([\s\S]*?)(?:<button\b[^>]*class=(['"])[^'"]*\bmore\b|<div\b[^>]*class=(['"])[^'"]*\bbox\b|$)/i)
|
||||||
|
const title = parseTitle(titleMatch ? titleMatch[2] : listHtml)
|
||||||
|
const electionNameCode = getHtmlAttr(listAttrs, "data-election-name")
|
||||||
|
const electionCode = getHtmlAttr(listAttrs, "data-election-code")
|
||||||
|
const profile = parseProfileFields(listHtml)
|
||||||
|
const item = compactObject({
|
||||||
|
name: personName,
|
||||||
|
hanja,
|
||||||
|
birth_date: birthDate,
|
||||||
|
gender,
|
||||||
|
election_date: title.electionDate ? `${title.electionDate.slice(0, 4)}-${title.electionDate.slice(4, 6)}-${title.electionDate.slice(6, 8)}` : undefined,
|
||||||
|
election_name: title.electionName,
|
||||||
|
election_name_code: electionNameCode,
|
||||||
|
election_code: electionCode,
|
||||||
|
election_type: title.electionType,
|
||||||
|
is_local_election: LOCAL_ELECTION_CODES.has(electionCode) || /지방선거|시·도지사|구·시·군|의회의원|교육감/.test(`${title.electionName || ""} ${title.electionType || ""}`),
|
||||||
|
party: title.party,
|
||||||
|
district: title.district,
|
||||||
|
votes: title.votes,
|
||||||
|
vote_share: title.voteShare,
|
||||||
|
elected: title.elected || undefined,
|
||||||
|
city_code: getHtmlAttr(listAttrs, "data-city-code"),
|
||||||
|
sgg_city_code: getHtmlAttr(listAttrs, "data-sgg-city-code"),
|
||||||
|
town_code: getHtmlAttr(listAttrs, "data-town-code"),
|
||||||
|
...profile
|
||||||
|
})
|
||||||
|
if (filterItem(item, normalized)) {
|
||||||
|
const itemKey = getCandidateElectionKey(item)
|
||||||
|
if (!itemKeys.has(itemKey)) {
|
||||||
|
itemKeys.add(itemKey)
|
||||||
|
items.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedResultCards === 0 && hasUnparsedCandidateResults(html)) {
|
||||||
|
warnings.push("parser drift suspected: NEC search result markers were present but no supported result cards could be parsed")
|
||||||
|
}
|
||||||
|
if (hasClientSideFilters(normalized) && parsedElectionEntries >= normalized.upstreamLimit) {
|
||||||
|
warnings.push(`NEC search page was capped at ${normalized.upstreamLimit} upstream rows before client-side filters; additional matches may require pagination`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedItems = items.slice(0, normalized.limit)
|
||||||
|
if (limitedItems.length === 0 && warnings.length === 0) warnings.push("no candidate results matched the provided name/filters on the NEC search page")
|
||||||
|
const result = {
|
||||||
|
query: compactObject({
|
||||||
|
name: normalized.name,
|
||||||
|
local_only: normalized.localOnly,
|
||||||
|
election_code: normalized.electionCode,
|
||||||
|
election_date: normalized.electionDate,
|
||||||
|
region: normalized.region,
|
||||||
|
limit: normalized.limit
|
||||||
|
}),
|
||||||
|
summary: {
|
||||||
|
returned_count: limitedItems.length,
|
||||||
|
matched_before_limit: items.length,
|
||||||
|
upstream_result_limit: normalized.upstreamLimit,
|
||||||
|
local_only: normalized.localOnly
|
||||||
|
},
|
||||||
|
items: limitedItems,
|
||||||
|
warnings,
|
||||||
|
source
|
||||||
|
}
|
||||||
|
if (normalized.includeHtml) result.html = html
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCandidates(options = {}, deps = {}) {
|
||||||
|
const fixturePath = options.fixture || options.fixturePath
|
||||||
|
const request = buildSearchRequest(options)
|
||||||
|
if (fixturePath) {
|
||||||
|
const html = await fs.readFile(fixturePath, "utf8")
|
||||||
|
return parseSearchHtml(html, request.options)
|
||||||
|
}
|
||||||
|
const fetchImpl = deps.fetchImpl || globalThis.fetch
|
||||||
|
if (typeof fetchImpl !== "function") throw new Error("No fetch implementation is available. Use Node.js 18+ or provide fetchImpl.")
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), deps.timeoutMs || DEFAULT_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(request.url, {
|
||||||
|
method: request.method,
|
||||||
|
headers: request.headers,
|
||||||
|
body: request.body,
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
const html = await response.text()
|
||||||
|
if (!response.ok) throw new Error(`NEC candidate search failed with HTTP ${response.status}: ${html.slice(0, 160)}`)
|
||||||
|
return parseSearchHtml(html, request.options)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NEC_SEARCH_URL,
|
||||||
|
DEFAULT_TIMEOUT_MS,
|
||||||
|
LOCAL_ELECTION_CODES,
|
||||||
|
ELECTION_CODE_ALIASES,
|
||||||
|
buildSearchRequest,
|
||||||
|
cleanText,
|
||||||
|
decodeHtml,
|
||||||
|
normalizeSearchOptions,
|
||||||
|
parseSearchHtml,
|
||||||
|
searchCandidates,
|
||||||
|
stripTags
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<!doctype html><html><body><div class="resultDiv"><div class="result" data-birthday="19610104"><p class="name"><strong>오세훈</strong><span class="hanja">(吳世勲)</span> <span class="date"> 1961년 01월 04일(남) </span></p><div class="list" data-election-code="3" data-election-name="20260603" data-city-code="1100"><div class="t"><button><mark>[2026.06.03] 제9회 전국동시지방선거</mark></button>국민의힘<span class="slash"> /</span> 시·도지사선거 <span class="slash"> /</span> 서울특별시</div><div class="box"><table class="data"><tbody><tr><td class="th"><p>직업</p></td><td>서울특별시장</td><td class="th"><p>경력</p></td><td><p>(현)제39대 서울특별시장</p></td></tr><tr><td class="th"><p>학력</p></td><td>고려대학교 대학원 법학과 졸업(법학박사)</td></tr></tbody></table></div></div></div></div></body></html>
|
||||||
296
packages/local-election-candidate-search/test/index.test.js
Normal file
296
packages/local-election-candidate-search/test/index.test.js
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
const test = require("node:test")
|
||||||
|
const assert = require("node:assert/strict")
|
||||||
|
const { spawnSync } = require("node:child_process")
|
||||||
|
|
||||||
|
const {
|
||||||
|
ELECTION_CODE_ALIASES,
|
||||||
|
buildSearchRequest,
|
||||||
|
normalizeSearchOptions,
|
||||||
|
parseSearchHtml,
|
||||||
|
searchCandidates
|
||||||
|
} = require("../src/index")
|
||||||
|
|
||||||
|
const SEARCH_HTML = `<!doctype html><html><body>
|
||||||
|
<div class="resultDiv">
|
||||||
|
<div class="result" data-birthday="19610104">
|
||||||
|
<p class="name"><strong>오세훈</strong><span class="hanja">(吳世勲)</span> <span class="date"> 1961년 01월 04일(남) </span></p>
|
||||||
|
<div class="list" data-election-type="4" data-old-election-type="1"
|
||||||
|
data-election-code="3" data-election-name="20260603"
|
||||||
|
data-city-code="1100" data-sgg-city-code="3110000"
|
||||||
|
data-town-code="1" data-sgg-town-code="3110000"
|
||||||
|
data-town-code-from-sgg="1" data-proportional-representation-code="200"
|
||||||
|
data-date-code='0' data-time-code='0'>
|
||||||
|
<div class="t">
|
||||||
|
<button type="button" class="tt cursorPointer markClick" aria-expanded="false"><mark>[2026.06.03] 제9회 전국동시지방선거</mark></button>
|
||||||
|
국민의힘<span class="slash"> /</span>
|
||||||
|
시·도지사선거 <span class="slash"> /</span> 서울특별시
|
||||||
|
</div>
|
||||||
|
<button type="button" class="more">자세히보기</button>
|
||||||
|
<div class="box">
|
||||||
|
<table class="data"><tbody>
|
||||||
|
<tr><td class="th"><p>직업</p></td><td>서울특별시장</td><td class="th" rowspan="2"><p>경력</p></td><td rowspan="2"><p>(현)제39대 서울특별시장</p><p>(전)제16대 국회의원</p></td></tr>
|
||||||
|
<tr><td class="th"><p>학력</p></td><td>고려대학교 대학원 법학과 졸업(법학박사)</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result" data-birthday="19370604">
|
||||||
|
<p class="name"><strong>김동연</strong><span class="hanja">(金東蓮)</span> <span class="date"> 1937년 06월 04일(여) </span></p>
|
||||||
|
<div class="list" data-election-type="4" data-old-election-type="1"
|
||||||
|
data-election-code="6" data-election-name="20140604"
|
||||||
|
data-city-code="1100" data-sgg-city-code="6112001"
|
||||||
|
data-town-code="1120" data-sgg-town-code="6112001"
|
||||||
|
data-town-code-from-sgg="1120" data-proportional-representation-code="200"
|
||||||
|
data-date-code='0' data-time-code='0'>
|
||||||
|
<div class="t">
|
||||||
|
<button type="button" class="tt cursorPointer markClick" aria-expanded="false"><mark>[2014.06.04] 제6회 전국동시지방선거</mark></button>
|
||||||
|
새누리당<span class="slash"> /</span>
|
||||||
|
구·시·군의회의원선거<span class="slash"> /</span> 서울특별시(동작구가선거구)<span class="slash"> /</span> 2,371표 (9.55%)
|
||||||
|
</div>
|
||||||
|
<div class="box"><table class="data"><tbody>
|
||||||
|
<tr><td class="th"><p>직업</p></td><td>동작구의회의원</td><td class="th" rowspan="2"><p>경력</p></td><td rowspan="2"><p>(전)한나라당 동작구(갑) 여성부장</p><p>(현)동작구의회의원(5,6대)</p></td></tr>
|
||||||
|
<tr><td class="th"><p>학력</p></td><td>부산동래여자고등학교 졸업</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
</div>
|
||||||
|
<div class="list" data-election-code="2" data-election-name="20240410" data-city-code="4100">
|
||||||
|
<div class="t"><button><mark>[2024.04.10] 제22대 국회의원선거</mark></button>개혁신당<span class="slash"> /</span> 국회의원선거<span class="slash"> /</span> 경기도</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
const EMPTY_HTML = `<!doctype html><html><body><article class="content"><div class="resultDiv"></div><script>fn_firstView();</script></article></body></html>`
|
||||||
|
const BLOCKED_HTML = `<!doctype html><html><body><h1>서비스 점검 안내</h1><p>NetFunnel 대기열 또는 로그인 확인 후 다시 이용해 주세요.</p></body></html>`
|
||||||
|
const SUPERINTENDENT_HTML = `<!doctype html><html><body>
|
||||||
|
<div class="resultDiv">
|
||||||
|
<div class="result" data-birthday="19561006">
|
||||||
|
<p class="name"><strong>조희연</strong><span class="hanja">(曺喜昖)</span> <span class="date">1956년 10월 06일(남)</span></p>
|
||||||
|
<div class="list" data-election-code="11" data-election-name="20140604" data-city-code="1100">
|
||||||
|
<div class="t">
|
||||||
|
<button type="button"><mark>[2014.06.04] 제6회 전국동시지방선거</mark></button>
|
||||||
|
교육감선거<span class="slash"> /</span> 서울특별시<span class="slash"> /</span> 1,614,564표 (38.10%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`
|
||||||
|
|
||||||
|
test("normalizeSearchOptions requires an exact candidate name and defaults to local elections", () => {
|
||||||
|
const options = normalizeSearchOptions({ q: " 오세훈 ", limit: "200" })
|
||||||
|
|
||||||
|
assert.equal(options.name, "오세훈")
|
||||||
|
assert.equal(options.localOnly, true)
|
||||||
|
assert.equal(options.limit, 100)
|
||||||
|
assert.equal(options.electionCode, null)
|
||||||
|
assert.throws(() => normalizeSearchOptions({ q: "" }), /candidate name/)
|
||||||
|
assert.throws(() => normalizeSearchOptions({ q: "가".repeat(31) }), /30 characters/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizeSearchOptions maps Korean election aliases", () => {
|
||||||
|
const governor = normalizeSearchOptions({ name: "오세훈", election: "시도지사", city: "서울" })
|
||||||
|
const council = normalizeSearchOptions({ name: "김동연", electionCode: "기초의원" })
|
||||||
|
|
||||||
|
assert.equal(governor.electionCode, "3")
|
||||||
|
assert.equal(governor.region, "서울")
|
||||||
|
assert.equal(council.electionCode, "6")
|
||||||
|
assert.equal(ELECTION_CODE_ALIASES.get("교육감"), "11")
|
||||||
|
assert.throws(() => normalizeSearchOptions({ name: "오세훈", election: "대통령" }), /Unsupported local election type/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("buildSearchRequest posts to the official NEC integrated candidate search", () => {
|
||||||
|
const request = buildSearchRequest({ name: "오세훈" })
|
||||||
|
|
||||||
|
assert.equal(request.url, "https://info.nec.go.kr/search/searchCandidate.xhtml")
|
||||||
|
assert.equal(request.method, "POST")
|
||||||
|
assert.equal(request.headers["content-type"], "application/x-www-form-urlencoded;charset=UTF-8")
|
||||||
|
assert.equal(new URLSearchParams(request.body).get("searchKeyword"), "오세훈")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("buildSearchRequest fetches a full upstream page before client-side filters and output limit", () => {
|
||||||
|
const request = buildSearchRequest({ name: "조희연", election: "교육감", region: "서울", limit: 1 })
|
||||||
|
const body = new URLSearchParams(request.body)
|
||||||
|
|
||||||
|
assert.equal(body.get("recordCountPerPage"), "100")
|
||||||
|
assert.equal(request.options.limit, 1)
|
||||||
|
assert.equal(request.options.upstreamLimit, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml returns local election candidate entries with profile fields", () => {
|
||||||
|
const result = parseSearchHtml(SEARCH_HTML, { name: "오세훈" })
|
||||||
|
|
||||||
|
assert.equal(result.summary.returned_count, 1)
|
||||||
|
assert.equal(result.items[0].name, "오세훈")
|
||||||
|
assert.equal(result.items[0].hanja, "吳世勲")
|
||||||
|
assert.equal(result.items[0].birth_date, "1961-01-04")
|
||||||
|
assert.equal(result.items[0].gender, "남")
|
||||||
|
assert.equal(result.items[0].election_date, "2026-06-03")
|
||||||
|
assert.equal(result.items[0].election_name, "제9회 전국동시지방선거")
|
||||||
|
assert.equal(result.items[0].election_type, "시·도지사선거")
|
||||||
|
assert.equal(result.items[0].party, "국민의힘")
|
||||||
|
assert.equal(result.items[0].district, "서울특별시")
|
||||||
|
assert.equal(result.items[0].job, "서울특별시장")
|
||||||
|
assert.match(result.items[0].career.join("\n"), /제39대 서울특별시장/)
|
||||||
|
assert.equal(result.warnings.some((warning) => /candidate name mismatch.*김동연/i.test(warning)), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml enforces exact candidate-name matches on mixed result pages", () => {
|
||||||
|
const result = parseSearchHtml(SEARCH_HTML, { name: "오세훈", localOnly: false })
|
||||||
|
|
||||||
|
assert.deepEqual(result.items.map((item) => item.name), ["오세훈"])
|
||||||
|
assert.equal(result.summary.returned_count, 1)
|
||||||
|
assert.match(result.warnings.join("\n"), /candidate name mismatch.*김동연/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml skips result cards without a parsed candidate name", () => {
|
||||||
|
const missingNameHtml = SEARCH_HTML.replace("<strong>오세훈</strong>", "")
|
||||||
|
const result = parseSearchHtml(missingNameHtml, { name: "오세훈" })
|
||||||
|
|
||||||
|
assert.equal(result.items.length, 0)
|
||||||
|
assert.match(result.warnings.join("\n"), /missing candidate name/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml warns separately when result markers exist but no cards parse", () => {
|
||||||
|
const driftHtml = `<!doctype html><html><body><div class="resultDiv"><section class="candidate-card">오세훈</section></div></body></html>`
|
||||||
|
const result = parseSearchHtml(driftHtml, { name: "오세훈" })
|
||||||
|
|
||||||
|
assert.equal(result.items.length, 0)
|
||||||
|
assert.match(result.warnings.join("\n"), /parser drift/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml filters non-local elections by default and can include all", () => {
|
||||||
|
const local = parseSearchHtml(SEARCH_HTML, { name: "김동연" })
|
||||||
|
const all = parseSearchHtml(SEARCH_HTML, { name: "김동연", localOnly: false })
|
||||||
|
|
||||||
|
assert.equal(local.items.length, 1)
|
||||||
|
assert.equal(local.items.every((item) => item.is_local_election), true)
|
||||||
|
assert.equal(all.items.length, 2)
|
||||||
|
assert.equal(all.items.at(-1).election_type, "국회의원선거")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml supports election/date/region filters", () => {
|
||||||
|
const result = parseSearchHtml(SEARCH_HTML, { name: "김동연", electionCode: "기초의원", electionDate: "2014.06.04", region: "동작" })
|
||||||
|
|
||||||
|
assert.equal(result.items.length, 1)
|
||||||
|
assert.equal(result.items[0].election_code, "6")
|
||||||
|
assert.equal(result.items[0].district, "서울특별시(동작구가선거구)")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml parses no-party education superintendent vote rows for region filters", () => {
|
||||||
|
const result = parseSearchHtml(SUPERINTENDENT_HTML, { name: "조희연", election: "교육감", region: "서울", limit: 5 })
|
||||||
|
|
||||||
|
assert.equal(result.summary.returned_count, 1)
|
||||||
|
assert.equal(result.items[0].party, undefined)
|
||||||
|
assert.equal(result.items[0].election_type, "교육감선거")
|
||||||
|
assert.equal(result.items[0].district, "서울특별시")
|
||||||
|
assert.equal(result.items[0].votes, 1614564)
|
||||||
|
assert.equal(result.items[0].vote_share, "38.10%")
|
||||||
|
assert.equal(result.warnings.join("\n"), "")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("searchCandidates applies output limit after fetching enough upstream rows for filters", async () => {
|
||||||
|
const calls = []
|
||||||
|
const result = await searchCandidates({ name: "조희연", election: "교육감", region: "서울", limit: 1 }, {
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url, init })
|
||||||
|
return { ok: true, status: 200, text: async () => SUPERINTENDENT_HTML }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(new URLSearchParams(calls[0].init.body).get("recordCountPerPage"), "100")
|
||||||
|
assert.equal(result.summary.returned_count, 1)
|
||||||
|
assert.equal(result.summary.matched_before_limit, 1)
|
||||||
|
assert.equal(result.summary.upstream_result_limit, 100)
|
||||||
|
assert.equal(result.items[0].name, "조희연")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml warns when a filtered upstream page reaches the fetched row cap", () => {
|
||||||
|
const cappedHtml = SEARCH_HTML.replace("오세훈", "다른후보").replace("김동연", "다른사람")
|
||||||
|
const result = parseSearchHtml(cappedHtml, {
|
||||||
|
name: "오세훈",
|
||||||
|
election: "시도지사",
|
||||||
|
region: "서울",
|
||||||
|
limit: 1,
|
||||||
|
upstreamLimit: 2
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.items.length, 0)
|
||||||
|
assert.match(result.warnings.join("\n"), /capped at 2 upstream rows/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml deduplicates repeated candidate election entries before applying limit", () => {
|
||||||
|
const duplicateList = SEARCH_HTML.match(/<div class="list" data-election-type="4"[\s\S]*?<\/div>\s*<\/div>\s*<div class="list" data-election-code="2"/)[0]
|
||||||
|
.replace(/\s*<div class="list" data-election-code="2"$/, "")
|
||||||
|
const duplicateHtml = SEARCH_HTML.replace(duplicateList, `${duplicateList}\n${duplicateList}`)
|
||||||
|
const result = parseSearchHtml(duplicateHtml, {
|
||||||
|
name: "김동연",
|
||||||
|
electionCode: "기초의원",
|
||||||
|
electionDate: "2014",
|
||||||
|
region: "동작",
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(result.summary.returned_count, 1)
|
||||||
|
assert.equal(result.summary.matched_before_limit, 1)
|
||||||
|
assert.deepEqual(result.items.map((item) => item.district), ["서울특별시(동작구가선거구)"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parseSearchHtml reports empty and blocked pages as explicit failure modes", () => {
|
||||||
|
const empty = parseSearchHtml(EMPTY_HTML, { name: "없는후보" })
|
||||||
|
const blocked = parseSearchHtml(BLOCKED_HTML, { name: "오세훈" })
|
||||||
|
|
||||||
|
assert.equal(empty.items.length, 0)
|
||||||
|
assert.match(empty.warnings.join("\n"), /no candidate results/i)
|
||||||
|
assert.equal(blocked.items.length, 0)
|
||||||
|
assert.match(blocked.warnings.join("\n"), /unexpected NEC search HTML.*NetFunnel.*로그인.*점검/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("searchCandidates uses injectable fetch for deterministic behavior", async () => {
|
||||||
|
const calls = []
|
||||||
|
const result = await searchCandidates({ name: "오세훈" }, {
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url, init })
|
||||||
|
return { ok: true, status: 200, text: async () => SEARCH_HTML }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(calls[0].url, "https://info.nec.go.kr/search/searchCandidate.xhtml")
|
||||||
|
assert.equal(calls[0].init.method, "POST")
|
||||||
|
assert.equal(result.items[0].name, "오세훈")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("CLI prints JSON search results", () => {
|
||||||
|
const cli = require.resolve("../src/cli")
|
||||||
|
const proc = spawnSync(process.execPath, [cli, "오세훈", "--fixture", "test/fixture-search.html", "--limit", "1"], {
|
||||||
|
cwd: require("node:path").join(__dirname, ".."),
|
||||||
|
encoding: "utf8"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(proc.status, 0, proc.stderr)
|
||||||
|
const data = JSON.parse(proc.stdout)
|
||||||
|
assert.equal(data.items.length, 1)
|
||||||
|
assert.equal(data.items[0].name, "오세훈")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("CLI --help exits successfully and prints usage", () => {
|
||||||
|
const cli = require.resolve("../src/cli")
|
||||||
|
const proc = spawnSync(process.execPath, [cli, "--help"], {
|
||||||
|
cwd: require("node:path").join(__dirname, ".."),
|
||||||
|
encoding: "utf8"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(proc.status, 0, proc.stderr)
|
||||||
|
assert.match(proc.stdout, /Usage: local-election-candidate-search/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("CLI expected validation errors print concise messages without stack traces", () => {
|
||||||
|
const cli = require.resolve("../src/cli")
|
||||||
|
const proc = spawnSync(process.execPath, [cli], {
|
||||||
|
cwd: require("node:path").join(__dirname, ".."),
|
||||||
|
encoding: "utf8"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(proc.status, 1)
|
||||||
|
assert.match(proc.stderr, /Provide a candidate name to search\./)
|
||||||
|
assert.doesNotMatch(proc.stderr, /\n\s+at /)
|
||||||
|
assert.equal(proc.stdout, "")
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue