Compare commits

...

5 commits

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
ba4eadac37 Merge remote-tracking branch 'origin/dev' into feature/#256
# Conflicts:
#	package.json
2026-05-18 21:21:30 +09:00
Jeffrey (Dongkyu) Kim
19de41c166 Prevent filtered NEC lookup false negatives
Fix the candidate parser so documented education-superintendent and filtered local-election lookups return bounded, evidence-backed results instead of silently dropping valid rows.

Constraint: PR #266 round-3 review required TDD, Ralph verification, and branch update for issue #256.

Rejected: Full NEC pagination in this follow-up | broader than the approved change; bounded 100-row fetch now avoids user-limit false negatives and warns when capped.

Confidence: high

Scope-risk: narrow

Directive: Preserve exact-name fail-closed parsing and count raw parsed upstream rows before cap-warning decisions.

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; live CLI smokes for 오세훈, 조희연, 김동연; CLI help/no-args checks; architect verification CLEAR.

Not-tested: Full npm run ci remains blocked by pre-existing repo-wide missing SKILL.md: ohou-today-deal.
2026-05-18 16:54:50 +09:00
Jeffrey (Dongkyu) Kim
bdba986e3e Preserve unique candidate lookup results
Deduplicate parsed NEC candidate/election rows before applying user limits, and make expected CLI validation failures concise by default while keeping an explicit debug stack escape hatch.

Constraint: PR #266 round-2 follow-up requested TDD fixes for duplicate NEC rows and CLI validation UX.\nRejected: Deduplicating after limit | would still allow duplicates to crowd out unique rows.\nRejected: Always printing stack traces | exposes local paths for normal user-input failures.\nConfidence: high\nScope-risk: narrow\nDirective: Keep dedupe keys stable enough to avoid collapsing legitimately distinct historical election rows.\nTested: 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; live 오세훈 smoke; live 김동연 duplicate repro; CLI no-args/help.\nNot-tested: Full npm run ci remains blocked by pre-existing missing SKILL.md: ohou-today-deal.
2026-05-18 16:35:33 +09:00
Jeffrey (Dongkyu) Kim
8bcd5fe7cf Enforce fail-closed candidate identity parsing
Constraint: PR #266 review required exact candidate-name matching and CLI help regression coverage.\nRejected: fallback-to-query-name on missing upstream markup | it can mislabel unrelated candidates as exact matches.\nConfidence: high\nScope-risk: narrow\nDirective: Keep NEC parser changes fail-closed when candidate identity cannot be parsed.\nTested: 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; live CLI smoke for 오세훈; CLI --help smoke.\nNot-tested: repo-wide npm run ci remains blocked by pre-existing missing SKILL.md: ohou-today-deal.
2026-05-18 16:24:12 +09:00
Jeffrey (Dongkyu) Kim
1fc242743c 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>
2026-05-18 15:59:23 +09:00
13 changed files with 1028 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) | | 법인등기 신청 컨설팅 | `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)

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-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)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다. 설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

View 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
View file

@ -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",

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/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"

View 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.

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,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 }

View 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(/&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.")
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
}

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,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">(&#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>`
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, "")
})