Enable public local-election candidate lookups

Add an NEC integrated-search skill and helper package so agents can answer 지방선거 후보자 lookup requests without credentials or proxy routes.

Constraint: Issue #256 requested TDD, Ralph completion, branch feature/#256, and PR targeting dev.

Rejected: k-skill-proxy route | NEC integrated candidate search is public and requires no API key.

Confidence: high

Scope-risk: moderate

Directive: Keep the helper read-only and do not automate NEC login, CAPTCHA, filing, or privileged election workflows.

Tested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; node packages/local-election-candidate-search/src/cli.js 오세훈 --election 시도지사 --region 서울 --limit 1; PATH=/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0a6JueA:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli npm run ci

Not-tested: Exhaustive NEC markup variants for every historical election type.

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-18 15:59:23 +09:00
commit 1fc242743c
13 changed files with 830 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
"local-election-candidate-search": minor
---
Add a public NEC local election candidate lookup skill and helper CLI.

View file

@ -41,6 +41,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.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) |
| 지방선거 후보자 조회 | `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-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) |
@ -156,6 +157,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
- [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md)
- [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md)
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)

View 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, 후보 등록/신고, 파일 다운로드, 정치 자금/선거 사무 자동화는 하지 않는다.

View file

@ -124,6 +124,7 @@ bash scripts/check-setup.sh
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
- [창업진흥원 K-Startup 조회 가이드](features/kstartup-search.md)
- [지방선거 후보자 조회 가이드](features/local-election-candidate-search.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

View file

@ -0,0 +1,84 @@
---
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`
## 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.
- 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
View file

@ -1090,6 +1090,10 @@
],
"license": "MIT"
},
"node_modules/local-election-candidate-search": {
"resolved": "packages/local-election-candidate-search",
"link": true
},
"node_modules/locate-path": {
"version": "5.0.0",
"dev": true,
@ -1900,6 +1904,16 @@
"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": {
"version": "0.2.0",
"license": "MIT",

View file

@ -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/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 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",
"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_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",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,44 @@
# 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.
## 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.
- This package does not automate NEC detail popups, file downloads, account login, CAPTCHA, political filing, or any privileged workflow.

View 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"
}
}

View file

@ -0,0 +1,65 @@
#!/usr/bin/env node
const { searchCandidates } = require("./index")
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
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") {
printHelp(io)
process.exit(0)
} 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) {
return error && error.stack ? error.stack : 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 }

View file

@ -0,0 +1,347 @@
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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
.replace(/&nbsp;/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.")
return {
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)
}
}
function buildSearchRequest(options = {}) {
const normalized = normalizeSearchOptions(options)
const body = new URLSearchParams({
searchKeyword: normalized.name,
pageIndex: "1",
firstIndex: "0",
recordCountPerPage: String(normalized.limit)
}).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.length === 2 && /선거$/.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 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 parseSearchHtml(html, options = {}) {
const normalized = normalizeSearchOptions(options)
const warnings = []
const items = []
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
for (const resultMatch of html.matchAll(resultRegex)) {
const resultAttrs = `${resultMatch[1] || ""} ${resultMatch[3] || ""}`
const resultHtml = resultMatch[4]
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]) : normalized.name
const hanja = hanjaMatch ? stripTags(hanjaMatch[2]) : null
const { birthDate, gender } = parseBirthDateAndGender(dateMatch ? stripTags(dateMatch[2]) : stripTags(nameHtml), resultAttrs)
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
for (const listMatch of resultHtml.matchAll(listRegex)) {
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)) items.push(item)
}
}
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,
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
}

View file

@ -0,0 +1 @@
<!doctype html><html><body><div class="resultDiv"><div class="result" data-birthday="19610104"><p class="name"><strong>오세훈</strong><span class="hanja">(吳世&#21234;)</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>

View file

@ -0,0 +1,168 @@
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">(&#21234;)</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>`
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("parseSearchHtml returns local election candidate entries with profile fields", () => {
const result = parseSearchHtml(SEARCH_HTML, { name: "오세훈" })
assert.equal(result.summary.returned_count, 2)
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.items[1].votes, 2371)
assert.equal(result.items[1].vote_share, "9.55%")
})
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, 2)
assert.equal(local.items.every((item) => item.is_local_election), true)
assert.equal(all.items.length, 3)
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 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, "오세훈")
})