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